Compare commits

...
Author SHA1 Message Date
enricobuehler 1535d69852 docs(release): the 0.25.0 notes cover the 37 commits since the bump
ci / docs-site (pull_request) Successful in 1m13s
ci / web (pull_request) Successful in 1m15s
apple / swift (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m14s
android / android (pull_request) Successful in 4m34s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m45s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m27s
ci / rust (pull_request) Successful in 14m5s
main moved from 8983ec04 to 35ba64ca while this branch sat open, taking the
release from 98 commits to 135. Merged in and folded the new work into the notes.

The largest addition is a new `## Before you update` section, because this batch
carries changes that need the reader to DO something and they were not going to
survive being buried in a Fixed bullet:

  * Linux users of the virtual Steam Deck pad must `usermod -aG punktfunk` and
    log back in, or it stops attaching — the capability moved off the `input`
    group (which every gamepad guide tells you to join) onto its own, because it
    can emulate arbitrary USB hardware.
  * Plugin UIs moved to their own origin on PORT+1, so a self-signed console
    needs the new port trusted once, and custom firewalls/proxies need it opened.
  * Saving a custom launch command re-confirms the console password, and add-ons
    may no longer set launch/pre-launch commands at all — a real break for any
    third-party add-on that populated them.
  * A fresh install now runs the plugin runner by default (upgrades untouched).
  * The Deck setup script used to leave the generated console password
    world-readable, so rotating it is worth a sentence.

The library-sources work is written as GROUNDWORK, deliberately. All six built-in
scanners still ship, still on by default, and nothing is removed — and none of
the replacement add-ons are published yet, so the migration banner only appears
as they arrive. Promising a user they can move Steam to an add-on today would be
the v0.22.3 mistake again: notes describing a build nobody is getting.

Two other honesty items. The Android HUD entry says outright that the stream did
not get faster and the headline number only got smaller because it stopped
counting the compositor's wait — otherwise every reader takes it for a speed-up.
The Windows non-C: settings entry says plainly that nothing is recoverable,
because the writes never reached disk, so there is no orphaned copy to restore
and the reader has to re-enter their preferences once.

`56adb470` (pad-audio WASAPI module path) is deliberately NOT a user-facing Fixed
entry: verified it is not an ancestor of v0.24.0, so it repairs a Windows build
break in code that has never shipped. It folds into the pad-audio feature. Same
for `19f637ea`, which is CI-only.

Under the hood gained the origin-isolation mechanism, the allowlist authorization
gate that fails the build on an unclassified route, store claims and the v2
library.json shape, the registry auth work, the config-writer fallback, send
pacing, and the vendored Deck WSI layer. The unverified list grew too: the origin
split has not been in a real browser, the packaging default-on changes have had
no installer run, and no launcher tile has ever been clicked.

Re-verified after the merge, all green: lock diff versions-only 32/32 against
origin/main, `cargo metadata --locked` resolves (35 members), `cargo fmt
--all --check` clean in both workspaces, doc lazy-continuation scanner 0 hits
over 521 files, notes body 0 internal-vocabulary hits above `## Under the hood`,
Play notes still 494/500 by android.yml's own gate logic. Wire 2, C ABI 16, and
the capability bytes are all unchanged from the bump commit — host_caps still has
exactly one free bit (0x80).

Play's "What's new" is left as it stands: at 494/500 there is no room, and the
only Android-facing additions here (the stats-overlay measurement change and a
certificate-strictness fix) are both worth less to a phone user than any line
already in it.
2026-08-06 00:45:25 +02:00
enricobuehler 745864423b Merge remote-tracking branch 'origin/main' into worktree-release-0250 2026-08-06 00:40:26 +02:00
enricobuehler 35ba64ca0f Merge pull request 'fix(flatpak): Deck HDR works on a plain install' (#64) from worktree-deck-hdr-wsi-env into main
ci / rust-arm64 (push) Failing after 4s
ci / rust (push) Failing after 4s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 19s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
docker / builders-arm64cross (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 23s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m8s
docker / deploy-docs (push) Successful in 28s
flatpak / build-publish (push) Failing after 3m9s
Reviewed-on: #64
2026-08-05 22:21:51 +00:00
enricobuehler 5f71aeb024 feat(flatpak): vendor the gamescope WSI layer so Deck HDR works on a plain install
ci / web (pull_request) Successful in 56s
ci / docs-site (pull_request) Successful in 1m6s
ci / rust-arm64 (pull_request) Successful in 2m10s
ci / rust (pull_request) Successful in 7m59s
HDR on a Deck needed a manual second step nobody took:
  flatpak install --user flathub org.freedesktop.Platform.VulkanLayer.gamescope//25.08
documented only in a comment in this file. Build the layer ourselves
instead, so a plain `flatpak install` is all it takes.

The layer is genuinely required, not legacy. Measured on SteamOS 3.8.16
(gamescope 3.16.23.4): the gamescope-0 socket advertises
gamescope_swapchain_factory_v2 but NOT wp_color_manager_v1, with HDR both
off and on — so Mesa's Wayland WSI has no colour-management protocol to
negotiate HDR10 through, and this layer is the only thing that can append
the ST.2084 surface formats. Removing the extension gives zero
[Gamescope WSI] lines and hdr10_format=None.

Vendored rather than declared via add-extensions autodownload: the
extension is 94 MB of whole-gamescope for one 4 MB .so, its layer JSON
hardcodes a /usr library_path that an app-scoped extension mounted under
/app would not satisfy, and it would make flathub a hard install-time
dependency of an app we self-host on flatpak.unom.io.

enable_gamescope=false skips subdir('src') and every compositor
dependency, so only protocol/ and layer/ build. buildsystem is simple
rather than meson because glm and stb ship no meson.build of their own -
the wraps' patch_directory supplies it, and without that copy configure
dies with "Subproject exists but has no meson.build file".

meson generates the layer JSON from prefix+libdir, so it self-writes
library_path=/app/lib/... into /app/share/vulkan/implicit_layer.d, which
XDG_DATA_DIRS already covers. VK_ADD_IMPLICIT_LAYER_PATH is therefore
dropped - keeping it would also risk double-loading two same-named layers
for anyone who still has the flathub extension installed.

Pinned to the same gamescope rev as packaging/gamescope/PKGBUILD so the
client's layer and the host's punktfunk-gamescope come from one tree.

Verified on a Deck OLED: builds offline (--wrap-mode=nodownload) in
org.gnome.Sdk//50, and the resulting .so drives the Deck's system
gamescope to "hdr formats exposed to client: true" with
hdr10_format=Some(A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT).

Still user-side, and not fixable in packaging: gamescope's hdr_enabled
convar (Steam's HDR display setting) must be on.
2026-08-06 00:15:16 +02:00
enricobuehler e1adc5d6d7 fix(flatpak): export GAMESCOPE_WAYLAND_DISPLAY so the Deck actually gets HDR
The gamescope WSI layer decides whether to engage from one signal:
isRunningUnderGamescope() reads $GAMESCOPE_WAYLAND_DISPLAY and nothing
else. flatpak does not forward host env into the sandbox, so it arrived
unset and the layer's CreateInstance early-returned before creating a
GamescopeInstance — no gamescope surface, so the HDR10/ST.2084 formats
were never appended and the surface stayed SDR.

The layer still loads and still logs its generic bits in that state, so
it reads as working. It is not: the three settings already here (layer
search path, ENABLE_GAMESCOPE_WSI, the socket bind) all sit downstream
of this gate and buy nothing without it.

Measured on a Deck OLED (Galileo, SteamOS 3.8.16), client --browse,
reading "swapchain config":
  unset              -> no [Gamescope WSI] Surface state block, None
  set, hdr_enabled=0 -> server hdr output enabled: false, None
  set, hdr_enabled=1 -> hdr formats exposed to client: true,
                        Some(A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT)

Matches the field report of "HDR->SDR" in the stats overlay on a
correct HDR host. DXVK_HDR was ruled out by measurement. The remaining
gate (gamescope's hdr_enabled convar = Steam's HDR display setting) is
a user-side step, not a packaging one.
2026-08-05 23:59:45 +02:00
enricobuehler 76a271b97a Merge pull request 'Worktree decky brand name' (#63) from worktree-decky-brand-name into main
ci / docs-site (push) Successful in 1m21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 46s
ci / web (push) Successful in 1m26s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 12s
ci / rust-arm64 (push) Successful in 2m1s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 13s
docker / builders-arm64cross (push) Skipped
decky / build-publish (push) Successful in 39s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m13s
ci / rust (push) Successful in 6m40s
docker / deploy-docs (push) Successful in 6m32s
Reviewed-on: #63
2026-08-05 21:41:17 +00:00
enricobuehler b53568c99f fix(decky): a host saved under its own IP now shows the name it advertises
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m30s
ci / rust-arm64 (pull_request) Successful in 2m35s
ci / rust (pull_request) Successful in 6m51s
The panel captioned most rows with an IP address. The saved records were
the source: `hosts add` falls back to the address when the pairing path
knew nothing better, so `name` is literally "192.168.1.21" — and
`mergeHosts` took `s.name || s.addr` unconditionally. The fallback only
ever fired for an EMPTY name, so a name that was already a copy of the
address sailed through as if it were meaningful, and the row printed the
address twice: once as its title, once as its subtitle.

The friendly name was in hand the whole time. The row is built by joining
the saved record to the live advert, and that advert carries the host's
actual hostname — the join was already trusted for address, port, online
and OS, and only the name was read from the saved side alone.

So treat a name equal to the record's own address as the placeholder it is
and yield to the advert. A real saved name still wins, even when stale: it
may be one the user chose, and an advert must never silently overwrite it.
The comparison is against the SAVED address, so a host that moved DHCP
lease still recognises its old address as a placeholder rather than
mistaking it for a chosen name.

Checked against the Deck that reported this, over its actual store and
browse: three online rows turn into home-worker-5, ENRICOS-DESKTOP and
steamdeck, the four offline ones keep their address (nothing is
advertising a better name for them yet), and a user-chosen name survives a
conflicting advert.
2026-08-05 23:37:18 +02:00
enricobuehler db0637928b fix(decky): the shortcut liveness guard answered "alive" for every appId
`shortcutStillExists()` extracted the store method before calling it:

    const get = appStore?.GetAppOverviewByAppID;
    return get(appId) != null;

`GetAppOverviewByAppID` reads the store's own state (`this.m_mapApps`), so
the unbound call throws on the lost `this` — and the function's own
`catch { return true }` swallowed it. The guard therefore returned "still
exists" for EVERY appId. Not a stale-data bug: it never once answered no.

Everything downstream of it was consequently inert. A dangling appId — the
documented hazard this guard exists to catch, since the id outlives the
shortcut in Steam's CEF localStorage across a plugin reinstall — was never
dropped, so `ensureGamepadUiShortcut` always took the reuse branch and
`SetShortcut*`'d a dead id (silent no-ops). The visible library entry never
came back, `recreateShortcuts` reported success having done nothing (its
toast only checks for a non-null appId, and the dead one is non-null), and
"Open Punktfunk" ran `RunGame` on the dead id — Steam answers that with
"Game configuration unavailable".

Call it as a method so `this` survives, and guard the global with `typeof`
first: `appStore` is Steam-injected, and a bare reference to a missing one
is a ReferenceError that optional chaining does not prevent — which would
have landed in the same catch.

Verified against the live Deck that hit this: evaluated both versions over
its actual appIds, and where the old guard says alive/alive, the fixed one
says alive for the live stream shortcut and dead for the dangling UI id —
so the stale key now drops and the entry is recreated on the next mount.
2026-08-05 23:33:25 +02:00
enricobuehler 22bc81238d fix(decky): Decky's plugin list says "Punktfunk", not "punktfunk"
The label Decky shows for an installed plugin is plugin.json "name", which
we had set to the lowercase directory name — so the one place every user
sees the plugin listed was the one place it was off-brand, while the panel
header (titleView) already read "Punktfunk".

The two were conflated because the name looked load-bearing: the zip's
top-level dir becomes ~/homebrew/plugins/<dir>, and the scripts derived
that dir FROM plugin.json "name". They are in fact independent — Decky
extracts the zip as-is and locates an installed plugin by MATCHING
plugin.json "name", never by folder name (that is how a plugin can live in
DeckWebBrowser/ and list itself as "Web Browser").

So brand-case the label and pin the on-disk dir to the literal `punktfunk`
in package.sh/deploy.sh/CI instead of deriving it. Pinning is the part that
matters: had the dir followed the label, this rename would have installed a
second `Punktfunk/` folder beside the existing `punktfunk/` and the plugin
would have shown up twice.

The self-update call passes the name Decky uninstalls before extracting, so
it moves to "Punktfunk" with it. The upgrade INTO this build still passes
"punktfunk" (the installed build's own value), which matches that build's
plugin.json — so the old folder is removed and the new zip lands in the
same lowercase dir either way. Decky's per-plugin settings dir is unused
(all state lives in ~/.config/punktfunk), so nothing is stranded.
2026-08-05 23:20:28 +02:00
enricobuehler de6b9e94ec Merge pull request 'fix(client/windows): settings persist when the app isn't installed on C:' (#62) from worktree-client-msix-persist into main
ci / web (push) Successful in 1m13s
ci / docs-site (push) Successful in 1m22s
apple / swift (push) Successful in 1m25s
ci / rust-arm64 (push) Successful in 1m39s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 25s
deb / build-publish-client-arm64 (push) Successful in 2m40s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
flatpak / build-publish (push) Failing after 4s
deb / build-publish-host (push) Successful in 4m43s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 33s
ci / rust (push) Failing after 9m30s
apple / screenshots (push) Successful in 10m16s
android / android (push) Successful in 13m9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 13m28s
deb / build-publish (push) Successful in 14m47s
arch / build-publish (push) Successful in 15m13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m26s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m4s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m12s
Reviewed-on: #62
2026-08-05 20:53:38 +00:00
enricobuehler 5ebe840320 fix(client/windows): settings persist when the app isn't installed on C:
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 22s
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m34s
ci / web (pull_request) Successful in 1m28s
ci / docs-site (pull_request) Successful in 1m23s
android / android (pull_request) Successful in 3m9s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m42s
ci / rust (pull_request) Successful in 7m46s
Reported from the field (2026-08-05): a fresh Windows 11 box with a data
partition, "New apps will save to: D:", and the client installed there. It
launches, finds hosts and streams — but no setting and no profile survives a
restart. Reinstalling to C: fixes it completely. The reporter's read was "it's
in read-only mode", and that is almost exactly right.

The one clue that localises it: the client creates its mTLS identity with a
plain `fs::write` on first run and hard-exits if that fails. Their app started,
so ordinary file creation in the config directory works. Only the config stores
were being lost — and those are the three files that go through `write_atomic`,
which writes a sibling temp and renames it over the target.

The rename is what breaks. The client ships as a full-trust MSIX package, so
its `%APPDATA%` writes are redirected into the package container. When the
package lives on a secondary drive, Windows keeps that redirected state on the
package's own volume: `C:\Users\<u>\AppData\Local\Packages\<pfn>\` stays a real
directory on C:, but its children (LocalCache, RoamingState, …) are junctions to
`D:\WpSystem\<SID>\…`. Both sides of our rename still spell `C:\Users\…`, so
nothing looks unusual, but they can resolve across that junction boundary — and
`std::fs::rename` is `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING` and *not*
`MOVEFILE_COPY_ALLOWED`, so a cross-volume move fails outright rather than
degrading to a copy. Creating files still works, which is why everything else
about the install looks healthy.

So the fix is not to make the rename work — it is to stop treating it as the
only way to persist. `write_atomic` now falls back to writing the target in
place when the atomic route fails. That is the same operation the identity files
already use, and those demonstrably round-trip on the affected installs, so the
fallback lands on a path we know resolves. It trades crash-atomicity for exactly
the writes that would otherwise be lost, and nowhere else: temp+rename stays the
normal route everywhere it works.

Writing into a redirected location cannot desync from reading it — Microsoft
documents one private-location-first resolution order for both, so whichever
layer a write lands in is the layer the next read finds. The fallback verifies
anyway, by reading the bytes straight back: a write that reports success and
disappears is precisely the bug being fixed, so this path does not get to claim
success on an `Ok(())` alone. It costs nothing normally — it only runs on an
install that has already shown it does something unusual.

Two things this uncovered on the way:

The temp file was a single shared `<name>.json.tmp`, but these stores have five
whole-file writers (WinUI shell, session, console UI, CLI, Decky). Two saving at
once collide on it — on Windows the second write hits a sharing violation, and
worse, one process can rename the other's half-written bytes over the target.
The scratch path now carries the pid.

And none of this was visible to anyone. Every save on this page is
fire-and-forget by design (a failed settings write must never take a stream
down), so ~15 call sites discard the error and the UI cheerfully shows the
toggle you just moved. The reporter had no log file to send either, because
"Open log folder" was handing out a phantom path — a separate bug, already fixed
in f3c0ee47 but not in the 0.24.0 they were running. `store_health` records the
last persistence failure centrally, and Settings shows an error bar naming the
path when the store is refusing writes, so a client that cannot save says so
instead of pretending.

`update.rs` had hand-rolled the same temp+rename inline, so it neither cleaned
up its temp on a failed rename nor picks up the fallback; it now goes through
the one writer. The update floor silently never rising is how a declined update
comes back forever.

Deliberately NOT done: disabling MSIX AppData virtualization in the manifest
(`desktop6:FileSystemWriteVirtualization`). It would stop the redirection at the
source, but every existing packaged install's settings, profiles and pairings
live inside the container today — turning it off points the client at an empty
real `%APPDATA%` and silently resets all of them. That needs a migration, not a
manifest flag.

Also considered and not taken: resolving the destination directory with
`GetFinalPathNameByHandleW` and creating the temp inside the resolved path, to
keep atomicity. It does not reliably close this hole — when the target file
exists only in the unvirtualized layer while its directory resolves to the
private one, the rename still straddles the boundary — and it would rest on
canonicalisation behaving through the redirection, which we have never verified
on a packaged run.

Verified on the RTX box (.173, Windows 11 26200), which is the platform that
actually has these rename semantics: `cargo fmt --all --check`, the full
`pf-client-core` lib suite (109 passed), and clippy `-D warnings --all-targets`
on both `pf-client-core` and `punktfunk-client-windows` — all clean. Also green
under linux/amd64 (116 passed). Three new tests: the pid-scoped scratch path,
the fallback actually persisting and reading back when the atomic route is
blocked, and a genuinely unwritable store surfacing its error instead of
swallowing it.

The mechanism above is established from documentation and third-party reports,
not from a reproduction on a second-drive install — that box does not exist
here. The fix does not depend on the diagnosis being exactly right: it repairs
any install where the rename fails but a direct write succeeds.
2026-08-05 22:33:36 +02:00
enricobuehler 4b1ce6b905 Merge pull request 'fix(android/hud): stop charging the compositor's wait to the stream' (#61) from worktree-android-hud-os-floor into main
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 3m14s
ci / docs-site (push) Successful in 1m20s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 15s
docker / builders-arm64cross (push) Skipped
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m19s
docker / deploy-docs (push) Successful in 1m10s
ci / rust (push) Successful in 8m28s
android / android (push) Successful in 9m24s
Reviewed-on: #61
2026-08-05 20:31:46 +00:00
enricobuehler a11c672bea fix(android/hud): stop charging the compositor's wait to the stream
ci / web (pull_request) Successful in 1m24s
ci / docs-site (pull_request) Successful in 3m21s
ci / rust-arm64 (pull_request) Successful in 3m30s
android / android (pull_request) Successful in 9m40s
ci / rust (pull_request) Successful in 16m25s
The Android HUD headlined `capture→displayed` with SurfaceFlinger's latch
and scanout inside it — pipeline depth no client can pace under. The usual
Android streaming overlays stop measuring at decode-complete, so users
comparing overlays read our honesty as latency: on a 60 Hz panel that floor
alone clears 30 ms, more than everything those overlays display put together.

Exclude it, the way the Apple clients have since the presentation rebuild
(8a40e467): shave the measured floor off the shown display and end-to-end at
every tier, and name what came off in Detailed as `os present +N excluded
(display pipeline minimum)`. The equation still tiles the headline, because
the `display` term is shaved by the same amount.

The floor is the `latch` p50 we already measure (release→OnFrameRendered),
not a modelled 2/refresh: it moves with the panel rate, tunnelled playback
and the vendor's low-latency mode, and it exists on every render path (the
release stamp is parked on all three), so it does not depend on the timeline
presenter being active. Unmeasured reads 0.0 and nothing is shaved — we
exclude only what we actually measured. With the floor out, the `display`
term is already just `pace`, so the `(pace + latch)` split now renders only
on a window where no latch sample paired, and the hardcoded 2-refresh
Apple-equivalence twin is gone with it.

Raw numbers are untouched in the 1 Hz `pf.present` logcat line, so HUD-off
A/Bs and cross-session comparisons still read unshaved values.
2026-08-05 22:28:43 +02:00
enricobuehler cbd0e9664d Merge pull request 'fix(ci): builder-image pushes authenticate, and :latest stops being a tag anyone can move' (#60) from worktree-security-h6-registry-auth into main
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 54s
ci / web (push) Successful in 2m24s
ci / docs-site (push) Successful in 2m29s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 12s
docker / builders-arm64cross (push) Skipped
ci / rust-arm64 (push) Successful in 3m2s
ci / rust (push) Successful in 6m38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6m49s
docker / deploy-docs (push) Successful in 35s
Reviewed-on: #60
2026-08-05 20:11:17 +00:00
enricobuehler 66df1624b6 Merge pull request 'Library scanners become plugins — the bridge half (host, wire, kit, console, packaging)' (#59) from worktree-library-plugins into main
apple / swift (push) Successful in 1m29s
ci / web (push) Successful in 1m56s
ci / rust-arm64 (push) Successful in 2m3s
ci / docs-site (push) Successful in 2m16s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 20s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 19s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
deb / build-publish-client-arm64 (push) Successful in 3m4s
android / android (push) Successful in 6m34s
deb / build-publish (push) Successful in 6m33s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 35s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 22s
apple / screenshots (push) Successful in 5m51s
docker / builders-arm64cross (push) Successful in 20s
deb / build-publish-host (push) Successful in 6m3s
docker / deploy-docs (push) Successful in 1m13s
arch / build-publish (push) Successful in 9m1s
ci / rust (push) Successful in 9m42s
windows-host / package (push) Failing after 11m36s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m57s
flatpak / build-publish (push) Successful in 9m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m44s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m4s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m14s
Reviewed-on: #59
2026-08-05 19:58:44 +00:00
enricobuehler 6f07bd94d3 feat(library): launcher tiles a plugin can actually publish
ci / docs-site (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 2m28s
android / android (pull_request) Successful in 4m10s
ci / rust (pull_request) Successful in 6m11s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m56s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m35s
Design D4 promised entries that open the LAUNCHER — Steam Big Picture, Heroic,
Lutris — and the plumbing for it landed in M2/M4: the `role` field, the
`steam_ui` kind, the console's Launchers rail. But nothing could flow through it
for anything except Steam.

D4 said the other launchers would ride the `command` kind. The 2026-08-05 review
then made `launch.kind = "command"` operator-only (it is handed to a shell), so a
plugin publishing one is refused with a 403. The two changes are individually
right and jointly leave a hole: `steam_ui` was the only launcher kind a plugin
could publish, so a Heroic or Lutris tile was unreachable.

New `launcher_ui` kind, valued by store id. One kind rather than one per store
because every launcher except Steam has exactly a single UI to open; Steam keeps
its own kind because it genuinely has two. D1 is preserved — the plugin names a
launcher, the host builds the command, and no shell string crosses the wire:

  heroic -> the same native-or-Flatpak resolution the `heroic` game kind uses,
            minus --no-gui and minus the URI, so the window itself opens
  lutris -> bare `lutris`, which opens the window (the URI form is `lutris_id`)

Platform-gated to what this host can actually resolve, and validated INBOUND: a
value naming a launcher this OS cannot open is a 400 the plugin author can act
on, not a tile that silently does nothing when a user clicks it. Windows
launchers (Epic, GOG Galaxy, Xbox app) are deliberately absent — each needs its
own verified activation and a guess would ship exactly that dead tile.

Also closes a WP4.3 item I under-delivered and did not flag: the console's
add/edit form had no way to mark an entry as a launcher, so even hand-adding one
was impossible. It now has the checkbox — and `formFrom` round-trips it, without
which editing a launcher entry would silently demote it to a game, which is the
precise bug that file's own comment warns about.

Gates on .21: punktfunk-host 435 passed / 0 failed (two new), workspace clippy
-D warnings clean, cargo fmt --all --check clean, OpenAPI drift green. Console:
orval + paraglide regen, tsc clean, check-i18n at 604 messages for en + de.

Still unproven on hardware: no launcher tile has been clicked on a real host.
The steam plugin (the first to emit one) is not built yet.
2026-08-05 21:12:19 +02:00
enricobuehler d2085879da Merge main: plugin art rides THROUGH the H-2 confinement, not around it
ci / web (pull_request) Successful in 58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m1s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m0s
ci / rust-arm64 (pull_request) Successful in 3m45s
ci / rust (pull_request) Successful in 9m22s
PR #58 hardened the art proxy in the same three files this branch rewrote, and
the two changes pull in opposite directions: #58 narrowed what the host will read
from disk, while WP1.2 widened what counts as a local art path so an extracted
scanner's covers can be served at all. Resolved so the widening goes through the
gate rather than beside it.

Kept from #58, unchanged: art_path_is_confined (UNC refusal, canonicalize-or-
refuse, config-dir exclusion, roots check), the image-extension whitelist,
sniff_image_type, validate_art_paths as write-time validation, the AuthLane
privileged-field check on every entry in a reconcile payload, and the launch
redaction in GET /library.

Three reconciliations:

  * `local_art_bytes` converts a `file://` value to a path BEFORE calling
    art_path_is_servable, so the confinement check and the read see the same
    path. Ordering is the point: percent-decoding happens before
    canonicalization, so a `%2e%2e` escape cannot hide from the traversal check.
    Pinned by a test.

  * `art_roots()` gains $HOME on POSIX. This is the one that would have bitten
    silently: the list was empty on non-Windows, which was correct while
    is_local_art_path was Windows-shaped (Playnite is Windows-only, so nothing on
    a POSIX host was ever classified as local art and the confinement had nothing
    to confine). Once WP1.2 classifies POSIX paths as local, an empty root list
    is not "secure by default" — it serves NO plugin art on Linux, which is every
    cover the lutris and steam plugins emit. $HOME is the exact analogue of the
    Windows users base #58 already ships, and covers Steam's librarycache and
    grid overrides, Lutris's coverart/banners (both copies), Heroic's caches and
    all the Flatpak variants. It is not the load-bearing control: a value still
    needs an image extension, must canonicalize to a real regular file inside a
    root and outside the config dir, and must CONTAIN image bytes.

  * The two tests that both wanted to mutate PUNKTFUNK_LIBRARY_ART_ROOTS became
    one. Cargo runs tests as parallel threads of a single process, so two tests
    setting the same env var race. The `file://` and confinement assertions moved
    into #58's existing confined test; what remains of the WP1.2 test is the
    pure classification/rewrite half, which touches neither env nor filesystem.

Also: `steam_ui` was missing from the list of host-resolved launch kinds in
privileged_field's doc comment and in the 403 a plugin sees. Prose only — the
check is a denylist (prep, launch.kind = "command"), so steam_ui was never
actually refused — but a plugin author reading that error would have concluded
otherwise.

Gates on .21: punktfunk-host 433 passed / 0 failed (including #58's H-2 tests and
the new file:// ones), full workspace tests clean, workspace clippy -D warnings
clean, cargo fmt --all --check clean, OpenAPI drift test green.
2026-08-05 19:59:11 +02:00
enricobuehler 19f637ea6e fix(ci): builder-image pushes authenticate, and :latest stops being a tag anyone can move
ci / docs-site (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 1m25s
ci / rust-arm64 (pull_request) Successful in 1m40s
ci / rust (pull_request) Successful in 6m8s
Second half of security-review-2026-08-05 H-6. The infra half (unom/infra,
runners/ci-core/) split the LAN registry in two: :5010 serves GET/HEAD only and
refuses everything else with 405, :5011 demands basic auth on every request
including the /v2/ ping. Both fronts sit on one store, and a registry keys by
repository name rather than by the host:port the client used, so an image
pushed to :5011 is the identical image every consumer pulls from :5010.

So: builds tag the write port, a docker login precedes the push, and the
release-tag manifest PUTs authenticate. Consumers are untouched — every
`container:` in every other workflow still pulls anonymously from :5010, and
ci/rust-ci-arm64cross.Dockerfile's `FROM 192.168.1.58:5010/...` still resolves.

Not doing the digest pinning the review asked for, deliberately, and the header
says why at length. Once pushes are authenticated, the people who can overwrite
a tag are exactly the people who can push to main and edit a pinned digest in
this file — a pin defends against nobody it did not already trust, and costs a
two-commit dance on every ci/ change (~3x a month) during which consumers run a
builder image predating the change they are testing.

What does close the residual gap is making :latest a checked function of the
tree. reconcile-latest.sh asserts on every run that :latest and :ck-$KEY are the
same digest, re-points it when they are not, and warns loudly. An out-of-band
overwrite is caught on the next push to main with no churn, and it fixes a
pre-existing bug on the side: reverting ci/ used to leave :latest on the newer
build forever, because the older key is a cache hit and nothing re-pointed it.
Repair rather than fail, because a legitimate revert must not red-line main.

Verified against the live registry from a runner host with the real docker
client: unauthenticated push denied, push to :5010 refused 405, authenticated
push to :5011 accepted, that same image pulled back anonymously from :5010.
reconcile-latest.sh exercised over all three cases (diverged -> repaired,
already equal -> no-op, missing key -> exit 1). All seven builder images are
consistent with their content keys today, so the new step is a silent no-op on
its first real run.
2026-08-05 19:52:19 +02:00
enricobuehler a1b8627e70 feat(plugin-kit): the lutris pilot as a worked example, and the export gap it found
plugin-kit-publish / publish (push) Successful in 29s
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m28s
ci / rust-arm64 (pull_request) Successful in 2m50s
android / android (pull_request) Successful in 4m28s
ci / docs-site (pull_request) Successful in 1m23s
ci / rust (pull_request) Successful in 7m11s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 2m58s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 3m52s
Writing a real scanner against the kit before six repos get cut from it, rather
than after. It is the lutris pilot (M5/WP5.1) — the smallest of the six and the
one that exercises the POSIX local-art path end to end.

It earned its keep immediately: withReadOnlyDb / openReadOnly were never exported
from the parsers barrel, so the single most distinctive thing the lutris plugin
needs was unreachable from @punktfunk/plugin-kit/library. Nothing caught that,
because nothing had consumed the public surface yet.

It also caught a vacuous green in this package: tsconfig's include was
["src","test"], so anything under examples/ type-checked as a no-op. `examples`
is now in the check scope; tsconfig.build.json still narrows to src and
package.json still ships only dist + README, so nothing new is published (verified
against the built dist).

The example carries two deliberate departures from the Rust original, both
documented inline: art is emitted as file:// URLs instead of inlined data: URLs
(the host proxies the bytes, so the payload stays small — inlining covers is what
blew the 2 MB body limit at 49 titles during the playnite work, and is exactly
why the POSIX art path exists), and the untrusted-slug guard is carried over
verbatim, since the slug comes from Lutris's own database and is interpolated
into a path the host will later be asked to serve.

What it demonstrates, which is the reason one-repo-per-plugin is safe: everything
below `scan` is store-specific parsing, and everything else — store claim, sync
engine, launcher entries, __config, console registration, and the CLI verbs
including the parity gate — comes from defineLibraryPlugin.

plugin-kit: tsc clean (now including examples), 56 tests pass, build clean.
2026-08-05 18:54:47 +02:00
enricobuehler 91fa32fbb6 feat(plugin-kit): the parity gate moves into the kit, so plugins can be one repo each
One plugin = one repo, matching the house pattern (playnite, rom-manager and
virtualhere are already each their own repo with their own biome/bunfig/tsconfig
/CI). The implementation plan's WP5.0 had proposed a single workspace repo for
all six library scanners; this is the piece that makes the split cost nothing.

Everything the six scanners share is already published rather than adjacent: the
parsers and defineLibraryPlugin live in @punktfunk/plugin-kit/library, so repo
boundaries are irrelevant to them. Fixtures are not shared in practice either —
the Rust scanners build theirs inline in code, there are no fixture files, and
the one genuinely cross-plugin builder (binary shortcuts.vdf) is already in this
package's own tests. A pga.db fixture is useless to the epic plugin.

The parity harness was the exception: generic across all six, and parked in the
shared repo the plan assumed. It moves here.

What it is: the acceptance gate for an extracted scanner. 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.

  punktfunk-plugin-steam parity --snapshot before.json   # host on its built-in
  punktfunk-plugin-steam parity --compare  before.json   # offline; exits non-zero

--compare runs the plugin's own scan rather than requiring it to be installed
first, so a mismatch is visible before anything is published and the run is
repeatable while you fix it.

Three judgement calls in the diff, each pinned by a test:
  * art is compared by PRESENCE, not value. The representation legitimately
    changes on extraction (a host-relative proxy path or inlined data: URL
    becomes a file:// path or a CDN URL), so comparing values would fail every
    run for no reason. Losing an art kind fails; gaining one does not.
  * launcher entries (role: "launcher") are reported separately instead of as
    unexpected extras — the built-in scanner had no concept of them, so they can
    never be in a baseline. An ORDINARY title the scanner never had still fails,
    which is what catches a bad tool filter.
  * absent and empty are the same thing in metadata: the host omits empty lists
    and nulls, so a plugin sending genres: [] has not changed anything.

plugin-kit: tsc clean, 56 tests pass (10 new).
2026-08-05 18:52:38 +02:00
enricobuehler ce8f3e9eaf feat(packaging): the plugin runner becomes a default component
WP6.1 of design/library-scanner-plugins-implementation-plan.md.

The library is a flagship surface and cannot depend on an opt-in subsystem
(design D9, closing G9): once the scanners are plugins, a host whose runner is
off comes up with an empty library and no obvious reason why. The security
posture for on-by-default was already built and shipped — LocalService on
Windows, a sandboxed systemd --user unit on Linux, the scoped plugin-token lane.

Windows (.iss): the PunktfunkScripting task is registered ENABLED and started on
a FRESH install, and left to the existing restore path on an upgrade. The
distinction is a new TaskExists probe taken before StopBunRuntimes disables
anything — TaskEnabled alone cannot tell a fresh install from an operator who
deliberately turned the runner off, and defaulting to "on" would silently switch
it back on for them.

deb/rpm: `systemctl --global enable` from the postinst/%post, guarded to first
install only so an upgrade never undoes a mask. `--global` because a maintainer
script has no user session to act on, and it is the only mechanism that makes a
--user unit on-by-default for everyone.

sysext: RPM scriptlets never run from a sysext image, so the enablement symlink
is baked in directly (/usr/lib/systemd/user/default.target.wants/). Without it
the runner would ship present-but-off on exactly the platform where an operator
is least likely to go looking for it.

Opt-out throughout is `systemctl --user mask punktfunk-scripting` — `mask`, not
`disable`, since a plain disable cannot remove a symlink under /etc or /usr. The
unit comment, both package descriptions, and the docs-site plugins page all say
so; the page also gains the Windows equivalent.

Not gated on hardware: none of this is verifiable from a Mac. The .iss change
needs an installer run (fresh + upgrade, and an upgrade with the task
deliberately disabled), and the deb/rpm/sysext changes need a package build.
2026-08-05 10:08:11 +02:00
enricobuehler bd383f1820 feat(web): one Game sources surface, launcher rail, and the migration nudge
M4 of design/library-scanner-plugins-implementation-plan.md, plus WP6.2.

WP4.1 — SourceToggles and ProvidersCard merge into Library/Sources.tsx. They
were two cards because they were two different things: scanners were compiled
into the host, plugins were an afterthought. After the extraction they are the
same thing — the host reports ONE list of sources whose ids match whether they
came from a built-in scanner or the plugin replacing it — so one surface is both
simpler and the only honest presentation. Each row carries its toggle, a
running/stopped badge for plugin sources, an entry count, filter, settings and
an uninstall that offers to remove the games too. An "Add a source" rail lists
uncatalogued library plugins with a "Detected" badge; `detected` is deliberately
tri-state, so only a POSITIVE probe badges — an entry with no probes for this
platform is unknown, and calling that "not installed" would be a lie.

The settings drawer (SourceSettings.tsx) renders a generic form from the
plugin's own JSON Schema over GET/PUT /__config, through the existing
session-gated /plugin-ui/<id>/ proxy — zero new host surface, and the browser
never learns the plugin's port or secret. It flattens allOf branches (effect
nests a checked schema's annotations there, so a form reading only the top level
silently loses every title and default) and falls back to a JSON editor when any
field is a shape it cannot express — partial rendering would be worse than none,
because a field missing from the form is a setting the operator cannot change.

WP4.2 — uiPlugins() now excludes category "library", which covers both the
sidebar and the mobile overflow since they share the selector. The
/plugins/$pluginId/$ route still resolves, so existing deep links keep working;
library plugins are just not advertised.

WP4.3 — LibraryGrid groups role:"launcher" entries into a rail above the grid,
and the empty state points at the sources surface rather than leaving a bare
grid (after extraction, "no games" is the expected first-run state).

WP6.2 — a migration banner offering one install per still-built-in scanner whose
plugin is catalogued. One button per scanner, never a single "migrate
everything" and never a silent auto-install: installing code stays an explicit
operator act, and per-scanner is what makes it safe to repeat (the claim
suppresses the built-in idempotently, so a half-finished migration is a valid
state).

WP4.4 — i18n en+de (kept under the existing "Game sources" label rather than
minting a third "Plugins"), Storybook stories for the sources card in three
states, the launcher rail and the banner. Gates: orval regen, tsc clean, vite
build clean, check-i18n green at 595 messages for both locales.

Still owed: the browser click-through (the store's Tabs-theme bug shipped
through green types and lint), and an AppShell nav story — that one needs the
plugins query mocked, which does not exist in this Storybook setup yet.
2026-08-05 10:03:24 +02:00
enricobuehler 8728d90e01 feat(plugin-kit): the library-plugin framework — parsers, __config, defineLibraryPlugin
M3 of design/library-scanner-plugins-implementation-plan.md. Target shape: a
first-party scanner plugin is its parsers plus a scan function.

WP3.1 — a parsers module under the new ./library subpath, porting what the six
in-host scanners hand-rolled: text VDF/ACF, the BINARY shortcuts.vdf KeyValues
walker with its CRC-32 appid derivation and the 64-bit rungameid composition,
read-only SQLite (bun:sqlite, immutable=1 so a scan can never take a lock or
spawn WAL sidecars next to a launcher's live database), a reg.exe wrapper,
capped readers, the path-confinement join that keeps a crafted goggame-*.info
from pointing a launch at an arbitrary program, Steam root/library discovery,
art location helpers, and a fetch helper carrying the host's no-redirect
anti-SSRF posture. Every parser is total: a missing launcher or a truncated file
degrades to "no titles", never to a throw.

Two deliberate departures from the Rust originals, both about the Windows
runner's account: steam root discovery now also reads HKLM Valve\Steam
InstallPath (a non-default install dir was previously uncovered), and the
registry wrapper refuses HKCU outright — as LocalService that is not the
operator's hive, so reading it would silently look like "not installed".

WP3.2 — GET/PUT /__config on the kit's UI server, so a plugin with settings does
not ship an SPA (closes G8). GET answers {schema, value}: the derived JSON Schema
and the raw operator-authored config. PUT validates by decoding and only then
persists RAW, so defaults are never baked into the file. The handler is split out
as makeConfigHandler and driven directly in tests.

WP3.3 — defineLibraryPlugin wires SyncEngine (poll + fs-watch + debounce), the
store-claiming reconcile, launcher entries appended to every sync, a UI server
serving only __config under category "library" (which keeps six installed
scanners out of the console nav), and the standard detect/scan/uninstall CLI
verbs. It warns ONCE when a pre-M2 host silently ignores the store claim — that
degradation is otherwise invisible except as duplicated titles.

M0/S2 is recorded here as a committed fixture rather than prose. Two findings the
original spike missed because deriving a schema does not exercise it:
withDecodingDefaultKey takes an Effect, not a thunk — a thunk type-checks, derives
fine, and dies at decode time; and a checked schema (Schema.Int) nests its
annotations under allOf, so a form must merge those branches. Both are pinned.

plugin-kit: version 0.3.0, tsc clean, 46 tests pass (16 ported parser tests, 10
config/derivation). Publishing (WP3.4) is deferred — it needs a tag and a push.
2026-08-05 09:53:58 +02:00
enricobuehler 3d4a659959 feat(host,sdk,kit): store claims, launcher entries, and plugin sources on the wire
M2 of design/library-scanner-plugins-implementation-plan.md. Everything a
library scanner plugin needs is now expressible over the API; all additive.

WP2.1/2.2 — store claims (D2). library.json gains a v2 shape ({entries, claims})
that loads the v1 bare array unchanged and is written on the first mutation.
PUT /library/provider/{p}?store=<s> claims a store for a provider: its entries
then surface with deterministic <store>:<external_id> ids and the store's own
badge instead of opaque custom:<id> ones. That identity is the whole point —
entry ids, GameStream FNV app ids, client art caches and Moonlight pins all
survive a title moving from an in-host scanner to a plugin. One provider per
store (409 otherwise); DELETE releases; an empty reconcile does NOT (a store can
legitimately have zero titles). While a claim is held, all_games() skips the
matching built-in scanner, so the two never double-list during the bridge.

WP2.3 — DetectHint gains steam_appid and env_marker, the two store-derived
signals the host used to read for itself. Without them a steam plugin's lease
tracking would drop from reaper-exact to dir-prefix, and Heroic-under-Proton
would lose the only signal that works. Malformed markers are dropped, not
honoured — this feeds a path that can end processes.

WP2.4/2.5 — role: game|launcher on the entry shapes (serde-default, skipped when
default), and a steam_ui launch kind valued bigpicture|desktop that opens the
Steam client itself. Validated inbound as well as at launch.

WP2.6 — GET/PUT /library/scanners generalizes to SOURCES: built-in scanners
minus claimed ones, plus claimed stores, plus any provider with entries. The
same library-scanners.json disabled-set backs all of them and the ids match by
construction, so a user's disabled state carries over verbatim through the whole
migration. A disabled plugin source has its entries filtered at read time,
exactly like a disabled scanner.

WP2.7/2.8 — plugin registration gains a category field (the console keeps
library plugins out of the nav); index entries gain categories and per-platform
detect probes, evaluated existence-only into CatalogEntry.detected so the host
never re-grows per-store knowledge. Index SCHEMA stays 1 — additive.

WP2.9 — OpenAPI + SDK regenerated on Linux; kit wire widened (LaunchSpec.kind is
now a plain string documented against the host's vocabulary — closes G3), and
ProviderClient.reconcile takes an optional store and returns the host's echoed
entries so a caller can detect a pre-M2 host silently ignoring the claim.

Also fixes a bug the S3 spike turned up: is_steam_launch gated on a steam:// URI,
so a steam_ui launcher entry would have skipped BOTH gamescope's --steam mode and
the B1 single-instance free — on a box autologged into game mode, the nested
second Steam would see the first and exit, crashing the spawn. It now tests the
first token.

Gates on .21: workspace tests green (punktfunk-host 425 passed), workspace
clippy -D warnings clean, cargo fmt --all --check clean, OpenAPI drift test
green. plugin-kit: tsc clean, 20 tests pass.
2026-08-05 09:39:31 +02:00
enricobuehler a418d2852a refactor(host/library): launch helpers into launch.rs, art proxy resolves any id
M1 of design/library-scanner-plugins-implementation-plan.md — behavior-frozen
groundwork for lifting the six scanners out into plugins.

WP1.1: heroic_command/heroic_launch_prefix, epic_launch_uri, gog_spawn,
valid_steam_appid and shortcut_gameid move into library/launch.rs with their
unit tests. The scanner modules beside it now do enumeration only, so they can
be deleted wholesale later without taking launch logic with them (D1).

WP1.2: is_local_art_path accepts file:// (the plugin contract) and POSIX
absolute paths, excluding the two /-leading shapes the host itself emits (its
own /api/ proxy path and protocol-relative CDN URLs). local_art_bytes
percent-decodes and converts a file:// value first. The art proxy and
fetch_box_art resolve ANY id against library.json before the legacy steam:
branch, so a plugin's entries serve art without the host knowing its store.

No API change; no user-visible change.
2026-08-05 09:09:19 +02:00
enricobuehler f702f27bd3 chore(release): bump workspace version to 0.25.0
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m14s
ci / docs-site (pull_request) Successful in 1m19s
android / android (pull_request) Successful in 5m33s
ci / rust-arm64 (pull_request) Successful in 5m21s
ci / rust (pull_request) Failing after 5m56s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 41s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 43s
A minor bump: 98 commits since v0.24.0. The headline is DualSense pad audio
(PR #23) — a wired DualSense playing a game's voice-coil haptics and its own
speaker, streamed from the host, on Android and the desktop session client
against a Windows host with Steam's driver present. Behind it: the haptics
sweep's twelve milestones closing more than twenty controller faults across
every client and both hosts; the audio quality/latency work (256 kbps stereo,
the Steam Streaming Microphone endpoint root cause, and the de-jitter ratchet
that left audio permanently behind the picture); and MTU resilience plus
mid-session shard renegotiation, which turns the silent all-black stream on a
sub-1330-byte path into a diagnosed warning that heals itself. Plus the Decky
plugin reduced to a launcher, system-button routing with hold-Select, gamepad-UI
profiles on all three UIs, `discover`/`launch --request-access` in the CLI, and
the Sunshine false-conflict and crashed-host display-restore fixes.

The canary base is already 0.25 — scripts/ci/pf-version.sh derives it as one
minor ahead of the latest stable tag — so this is the version canary has been
publishing against all along.

Wire protocol stays at 2: every addition this cycle is optional or
capability-gated (an optional trailing max_shard_payload on Hello, the 0x08/0x09
renegotiation pair, the 0xD1 pad-audio plane, the 0xD2 redundant desktop-audio
plane, MAX_DATAGRAM_BYTES 2048 -> 9216). C ABI moves 14 -> 16 in two steps: 15
retroactively declares the floor that guarantees the rumble policy engine's C
surface (which shipped while the constant still read 7), and 16 adds the
pad-audio surface and mirrors its two capability bits. Four new capability bits
land in the client/host bytes (audio redundancy 0x04/0x20, pad audio 0x08/0x40);
the video-caps byte was NOT touched and stays full from 0.23.0, so the standing
"next video cap needs a second byte and an ABI bump" note still holds. host_caps
is now down to its last free bit (0x80). Virtual-display driver protocol 6 and
the Windows gamepad channel 3 are untouched — pf-driver-proto is byte-for-byte
identical to v0.24.0. The generated header is in sync (ABI 16, both cap mirrors).

Breaking for C embedders: 149 unprefixed macros are now PUNKTFUNK_-prefixed
(139 #defines renamed in the checked-in header). Mechanical to fix, and it
cannot break silently — the old spellings cease to exist, so it is always an
undeclared-identifier error rather than the wrong value a colliding #define
used to produce.

Lock touched for the 32 workspace members only, via `cargo update --workspace`:
diff against origin/main is versions-only, 32 insertions and 32 deletions. Unlike
the last cut there is no third-party crate sitting on the outgoing version to
trip the count — `wasapi` is at 0.23.0 and was never a candidate. `cargo metadata
--locked` resolves (35 members; fec-rs, pf-driver-proto and usbip-sim keep their
own versions by design). `cargo fmt --all --check` clean in both the main and
packaging/windows/drivers workspaces. Doc lazy-continuation scanner: 0 hits over
521 files — that regex is the exact defect that made the first v0.23.0 tag go red
on Windows clippy, and no Windows leg runs on a main push, so main being green
proves nothing about the tag fan-out.

api/openapi.json is deliberately left at 0.23.0: it tracks API edits and lags,
as in every prior cut. It is now two releases behind and worth a look.

Notes at docs/releases/v0.25.0.md, per docs/releases/README.md — authored with
the bump so CI's ensure_release seeds the release body at tag creation. Body
voice checked programmatically: 0 internal-vocabulary hits above `## Under the
hood`. Play's "What's new" at docs/releases/whatsnew/v0.25.0.txt (494/500 chars),
verified by running android.yml's gate logic verbatim against it, including the
byte-identical-to-another-release check.
2026-08-05 00:05:40 +02:00
84 changed files with 6624 additions and 729 deletions
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Assert that a builder image's :latest is the SAME manifest as its content key, and
# re-point it when it isn't.
#
# This is what we do instead of pinning consumers by @sha256: digest
# (security-review-2026-08-05, H-6 — see the reasoning at the top of docker.yml). The
# content key is a hash of the ci/ tree, so "which image should :latest be?" has an
# answer derivable from the commit alone. Checking it on every run turns :latest from a
# tag someone remembered to move into a function of the tree.
#
# Two different things make them diverge and neither is distinguishable from here:
#
# - Someone overwrote :latest out of band. Post-fix that needs the push credential,
# but it is exactly the H-6 attack and it must not pass silently.
# - ci/ was reverted. The older key is already a cache hit, so nothing rebuilds and
# nothing re-points :latest — it stays on the newer build forever while every
# consumer pulls a builder that does not match the tree it is building. That bug
# predates this script.
#
# Both are repaired identically, so: repair, and shout. Failing the build instead would
# turn a legitimate revert into a red main with no way forward.
#
# Reads go to the anonymous port, the single write to the authenticated one.
set -euo pipefail
IMAGE="${1:?usage: reconcile-latest.sh <image> <content-key>}"
KEY="${2:?usage: reconcile-latest.sh <image> <content-key>}"
: "${CI_REGISTRY:?CI_REGISTRY not set}"
: "${CI_REGISTRY_PUSH:?CI_REGISTRY_PUSH not set}"
: "${CI_REGISTRY_PASSWORD:?CI_REGISTRY_PASSWORD not set}"
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
# Digest of a tag, or empty if the tag does not exist. Never fails the script itself —
# "missing" is a state this has to reason about, not an error to abort on.
digest_of() {
curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$1" 2>/dev/null \
| tr -d '\r' | sed -n 's/^[Dd]ocker-[Cc]ontent-[Dd]igest: //p' || true
}
key_digest=$(digest_of "$KEY")
latest_digest=$(digest_of latest)
if [ -z "$key_digest" ]; then
echo "::error::$IMAGE:$KEY has no manifest — the build or push above did not land"
exit 1
fi
if [ "$key_digest" = "$latest_digest" ]; then
echo "$IMAGE:latest == :$KEY ($key_digest)"
exit 0
fi
echo "::warning::$IMAGE:latest did not match its content key :$KEY — re-pointing it. If ci/ was not just reverted, someone overwrote this tag out of band: check the registry access log on home-ci-core."
echo " was: ${latest_digest:-<no :latest tag>}"
echo " wanted: $key_digest (:$KEY)"
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
media_type=$(curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY" \
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
curl -sf -H "$ACCEPT" -o "$tmp" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $media_type" \
--data-binary @"$tmp" "http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/latest"
now=$(digest_of latest)
[ "$now" = "$key_digest" ] || { echo "::error::re-point failed: :latest is $now"; exit 1; }
echo "$IMAGE:latest re-pointed to $key_digest"
+4 -1
View File
@@ -46,7 +46,10 @@ env:
REGISTRY: git.unom.io
OWNER: unom
PACKAGE: punktfunk-decky # generic-registry package name
PLUGIN: punktfunk # plugin.json "name" == zip top-level dir
# The plugin's ON-DISK dir == the zip's top-level dir. Deliberately NOT plugin.json "name"
# (that is the brand-cased label Decky lists, and it locates a plugin by matching it, not by
# the folder) — see clients/decky/scripts/package.sh.
PLUGIN: punktfunk
jobs:
build-publish:
+105 -37
View File
@@ -3,13 +3,18 @@
# Two very different image families now:
#
# BUILDER images (punktfunk-rust-ci{,-noble,-arm64cross}, punktfunk-fedora{,44}-rpm)
# live on the LAN registry (home-ci-core, 192.168.1.58:5010 — unom/infra
# runners/ci-core/) and are CONTENT-KEYED: the tag is a hash of what they are built
# from (the ci/ tree, + rust-toolchain.toml for the cross image), and a build only
# happens when that key has no manifest yet. A push that doesn't touch ci/ costs one
# curl per image (~seconds), pushes nothing over the WAN, and mints no per-SHA tag
# debris on the runners — the failure mode that filled the fleet's disks. `:latest`
# is re-pushed alongside every new key and is what the consuming workflows pin.
# live on the LAN registry (home-ci-core — unom/infra runners/ci-core/) and are
# CONTENT-KEYED: the tag is a hash of what they are built from (the ci/ tree, +
# rust-toolchain.toml for the cross image), and a build only happens when that key
# has no manifest yet. A push that doesn't touch ci/ costs one curl per image
# (~seconds), pushes nothing over the WAN, and mints no per-SHA tag debris on the
# runners — the failure mode that filled the fleet's disks. `:latest` is re-pushed
# alongside every new key and is what the consuming workflows pin.
#
# READS come from :5010 and need no credential. WRITES go to :5011 and need
# CI_REGISTRY_PASSWORD. Same store behind both — a registry keys by repository name,
# not by the host:port the client used — so an image pushed to :5011 is the same
# image every consumer pulls from :5010.
#
# APP images (punktfunk-web, punktfunk-docs) are deployables: they keep going to the
# Gitea registry (git.unom.io) with :latest + :sha-<8> (+ :vX.Y.Z on tags), because
@@ -17,26 +22,38 @@
#
# Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left").
#
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images only —
# the LAN registry is unauthenticated inside the LAN).
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images).
# CI_REGISTRY_PASSWORD: repo Actions secret, the LAN registry's push credential for user
# `ci`. Generated on ci-core into /srv/ci/stack/registry-secret; rotate in both places.
#
# ⚠ 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/`.
# --- security-review-2026-08-05 H-6, FIXED 2026-08-05 -------------------------------
# The registry used to accept anonymous pushes from any LAN peer, and every
# secret-bearing job in this repo runs INSIDE an image pulled from it. Attacker
# position #1 of the project's own threat model did not need to break any signing
# logic: push one tag, and the next android.yml run executes their code in the same job
# that does `echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > release.jks`. Same shape for
# rpm.yml (RPM_GPG_PRIVATE_KEY) and android-promote.yml (SERVICE_ACCOUNT_JSON).
#
# The 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.
# The infra half is done (unom/infra runners/ci-core/): :5010 serves GET/HEAD only and
# refuses everything else with 405, :5011 demands basic auth on every request. The half
# in this file is done below: pushes and release-tag manifest PUTs authenticate.
#
# ⚠ On the second half as the review originally worded it — "pin consumers by @sha256:
# digest". We deliberately do something else, because after authentication the digest
# pin no longer buys what it was meant to buy. The set of people who can overwrite a tag
# is now exactly the set who can push to main and edit a pinned digest in this very
# file: a pin defends against nobody it did not already trust, while costing a
# two-commit dance on every ci/ change (~3x a month) during which consumers silently run
# a builder image that predates the ci/ change they are testing.
#
# What actually closes the residual gap — a tag quietly overwritten out of band — is
# making :latest a CHECKED function of the tree instead of a tag someone remembered to
# move. The "Reconcile :latest" step below asserts on every run that :latest and
# :ck-$KEY are the same digest, repairs it when they are not, and says so loudly. That
# catches an out-of-band overwrite on the next push to main, needs no churn, and fixes
# a real pre-existing bug on the side: reverting ci/ used to leave :latest pointing at
# the newer build forever. Revisit inline digest pins if the push credential ever leaves
# the maintainer trust set.
#
# Bootstrap note: consuming workflows pull <LAN>/punktfunk-rust-ci:latest, so the LAN
# registry must hold a seeded :latest once (done 2026-07-29 from the last Gitea-registry
@@ -60,7 +77,10 @@ on:
env:
REGISTRY: git.unom.io
OWNER: unom
# Read port (anonymous, GET/HEAD only) and write port (basic auth). Two doors onto
# one store; see the header.
CI_REGISTRY: 192.168.1.58:5010
CI_REGISTRY_PUSH: 192.168.1.58:5011
jobs:
builders:
@@ -116,21 +136,40 @@ jobs:
echo "hit=false" >> "$GITHUB_OUTPUT"
fi
# Tagged for the WRITE port: :5010 refuses a push outright, so a tag that names it
# can only fail. Consumers still pull the identical image from :5010.
- name: Build
if: steps.exists.outputs.hit == 'false'
# --pull is cheap now: base images come through the ci-core pull-through mirror.
run: |
docker build --pull ${{ matrix.buildargs }} \
-f "${{ matrix.dockerfile }}" \
-t "$CI_REGISTRY/${{ matrix.image }}:$KEY" \
-t "$CI_REGISTRY/${{ matrix.image }}:latest" \
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY" \
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest" \
ci
- name: Log in to the LAN registry
run: |
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Push
if: steps.exists.outputs.hit == 'false'
run: |
docker push "$CI_REGISTRY/${{ matrix.image }}:$KEY"
docker push "$CI_REGISTRY/${{ matrix.image }}:latest"
docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY"
docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest"
# :latest must be whatever ci/ says it is, on every run — not only on the runs that
# happened to build. Two things break that: an out-of-band overwrite (the H-6
# attack, now only reachable by someone holding the push credential), and a plain
# revert of ci/, which leaves :latest on the newer build because the older key is
# already a cache hit and nothing re-points it. Both look identical from here and
# both are repaired the same way, so repair and shout rather than fail the build.
- name: Reconcile :latest with the content key
run: .gitea/scripts/reconcile-latest.sh "${{ matrix.image }}" "$KEY"
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
# A release pins reproducible builder images without any rebuild: copy the key's
# manifest to a vX.Y.Z tag via the registry API (no image bytes move).
@@ -142,8 +181,19 @@ jobs:
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$KEY"
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \
--data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY_PUSH/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
# Today the job container is ephemeral (the ubuntu-24.04 label is a docker://
# image), so the credential docker login wrote would die with it anyway. Don't
# make that a load-bearing assumption about a runner label somebody may change to
# a host runner later.
- name: Log out of the LAN registry
if: always()
run: docker logout "$CI_REGISTRY_PUSH" || true
# The aarch64 CROSS builder — a SEPARATE job because it is `FROM punktfunk-rust-ci:latest`
# (the LAN copy) and so must not race the matrix entry that publishes that base. Consumed
@@ -182,15 +232,26 @@ jobs:
run: |
docker build --pull \
-f ci/rust-ci-arm64cross.Dockerfile \
-t "$CI_REGISTRY/$IMAGE:$KEY" \
-t "$CI_REGISTRY/$IMAGE:latest" \
-t "$CI_REGISTRY_PUSH/$IMAGE:$KEY" \
-t "$CI_REGISTRY_PUSH/$IMAGE:latest" \
.
- name: Log in to the LAN registry
run: |
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Push
if: steps.exists.outputs.hit == 'false'
run: |
docker push "$CI_REGISTRY/$IMAGE:$KEY"
docker push "$CI_REGISTRY/$IMAGE:latest"
docker push "$CI_REGISTRY_PUSH/$IMAGE:$KEY"
docker push "$CI_REGISTRY_PUSH/$IMAGE:latest"
- name: Reconcile :latest with the content key
run: .gitea/scripts/reconcile-latest.sh "$IMAGE" "$KEY"
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Tag for release
if: startsWith(github.ref, 'refs/tags/v')
@@ -200,8 +261,15 @@ jobs:
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \
--data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Log out of the LAN registry
if: always()
run: docker logout "$CI_REGISTRY_PUSH" || true
# Deployable app images — unchanged flow, Gitea registry, per-SHA + release tags.
apps:
Generated
+32 -32
View File
@@ -947,7 +947,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"anyhow",
"pf-capture",
@@ -1036,7 +1036,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
@@ -2221,7 +2221,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.24.0"
version = "0.25.0"
[[package]]
name = "lazy_static"
@@ -2326,7 +2326,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"bindgen",
"cmake",
@@ -2361,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"punktfunk-core",
]
@@ -2850,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-capture"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2871,7 +2871,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"anyhow",
"ash",
@@ -2898,7 +2898,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2916,7 +2916,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"anyhow",
"ash",
@@ -2937,7 +2937,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"anyhow",
"ash",
@@ -2961,7 +2961,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"ash",
"bindgen",
@@ -2970,7 +2970,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"anyhow",
"libc",
@@ -2982,7 +2982,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -2996,11 +2996,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.24.0"
version = "0.25.0"
[[package]]
name = "pf-inject"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3029,14 +3029,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"anyhow",
"ash",
@@ -3051,7 +3051,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"serde",
"serde_json",
@@ -3059,7 +3059,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"anyhow",
"base64",
@@ -3071,7 +3071,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3104,7 +3104,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -3116,7 +3116,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"anyhow",
"ash",
@@ -3324,7 +3324,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3335,7 +3335,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"android_logger",
"jni",
@@ -3353,7 +3353,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3370,7 +3370,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3385,7 +3385,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"async-channel",
"ffmpeg-next",
@@ -3405,7 +3405,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3437,7 +3437,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3522,7 +3522,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3536,7 +3536,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"anyhow",
"ksni",
@@ -3559,7 +3559,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -53,7 +53,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.24.0"
version = "0.25.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+157 -9
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.23.0"
"version": "0.24.0"
},
"paths": {
"/api/v1/clients": {
@@ -1052,7 +1052,7 @@
"library"
],
"summary": "Fetch one cover-art image for a library entry",
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —\nit's what the user's Steam client already shows for it), the public Steam CDN's flat URL\nconvention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host\ncan't predict, in which case this 404s and the client falls through to its next art candidate).\nOnly Steam ids are backed today; any other store 404s.",
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file. A Steam title falls back\nto the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the\nuser's Steam client already shows for it), the public Steam CDN's flat URL convention second\n(newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case\nthis 404s and the client falls through to its next art candidate).",
"operationId": "getLibraryArt",
"parameters": [
{
@@ -1307,7 +1307,7 @@
"library"
],
"summary": "Replace a provider's library entries (declarative reconcile)",
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.",
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches. One provider\nper store; a second claimant gets 409. While a claim is held the matching built-in scanner is\nsuppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty\nreconcile (a store can legitimately have zero installed titles).",
"operationId": "reconcileProviderEntries",
"parameters": [
{
@@ -1318,6 +1318,15 @@
"schema": {
"type": "string"
}
},
{
"name": "store",
"in": "query",
"description": "Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)",
"required": false,
"schema": {
"type": "string"
}
}
],
"requestBody": {
@@ -1348,7 +1357,7 @@
}
},
"400": {
"description": "Invalid provider id or payload",
"description": "Invalid provider id, store id, or payload",
"content": {
"application/json": {
"schema": {
@@ -1367,6 +1376,16 @@
}
}
},
"409": {
"description": "That store is already claimed by another provider",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not persist the catalog",
"content": {
@@ -4159,7 +4178,8 @@
"tier",
"platforms",
"compatible",
"update_available"
"update_available",
"categories"
],
"properties": {
"author": {
@@ -4172,6 +4192,13 @@
],
"description": "A revocation covering the catalogued version — do not offer this without shouting."
},
"categories": {
"type": "array",
"items": {
"type": "string"
},
"description": "What kind of plugin this is — the console filters Browse by these, and the Game sources\nsurface's \"Add a source\" rail shows exactly the `library` ones (design D5/D6)."
},
"compatible": {
"type": "boolean",
"description": "Can this host install it?"
@@ -4179,6 +4206,13 @@
"description": {
"type": "string"
},
"detected": {
"type": [
"boolean",
"null"
],
"description": "Whether the launcher this plugin scans looks **installed on this host** (design D8), from the\nindex's own existence probes. `null` = the entry declares no probes for this platform, which\nthe console renders as \"unknown\" rather than \"not installed\"."
},
"homepage": {
"type": [
"string",
@@ -4365,6 +4399,17 @@
],
"description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)."
},
"role": {
"$ref": "#/components/schemas/GameRole",
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]."
},
"store": {
"type": [
"string",
"null"
],
"description": "The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.\n`None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.\n\nMaterialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an\nentry is self-describing: its id and its `store` badge derive from the entry alone, and stay\ncorrect even while the claim map is being rewritten."
},
"title": {
"type": "string"
}
@@ -4409,6 +4454,10 @@
},
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
},
"role": {
"$ref": "#/components/schemas/GameRole",
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher\nentry is legal (an operator may want a \"Steam\" tile without installing the steam plugin)."
},
"title": {
"type": "string"
}
@@ -4467,6 +4516,17 @@
"type": "object",
"description": "What an operator (or a provider plugin) can tell the host about recognizing a title — the wire\nhalf of [`DetectSpec`], and the only part of it that is ever accepted from outside.\n\nDeliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment\nmarker) are things the host discovers for itself and would be meaningless — or dangerous — to take\non someone's word. What is left is what a provider genuinely knows and the host cannot guess: where\nthe title is installed, which executable is the game, what the process is called. All three are\noptional; supplying none is the same as supplying no hint at all.\n\nNever returned by the catalog API — see the module docs on why detect data does not cross the wire\noutbound.",
"properties": {
"env_marker": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/EnvMarker",
"description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]."
}
]
},
"exe": {
"type": [
"string",
@@ -4487,6 +4547,15 @@
"null"
],
"description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]."
},
"steam_appid": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest**\nsignal that exists — Steam wraps every launch, native or Proton, in\n`reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's — so without it a\nsteam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching.",
"minimum": 0
}
}
},
@@ -4715,6 +4784,27 @@
}
}
},
"EnvMarker": {
"type": "object",
"description": "An environment variable a launcher stamps onto the game's process, identifying it.\n\nSerializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin\nthat knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to\nbe able to say so, since after extraction the host no longer reads that launcher's files itself.",
"required": [
"key"
],
"properties": {
"key": {
"type": "string",
"description": "The variable name (e.g. `HEROIC_GAME_ID`).",
"example": "HEROIC_APP_NAME"
},
"value": {
"type": [
"string",
"null"
],
"description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time."
}
}
},
"EventKind": {
"oneOf": [
{
@@ -5165,6 +5255,10 @@
],
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
},
"role": {
"$ref": "#/components/schemas/GameRole",
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]."
},
"store": {
"type": "string",
"description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.",
@@ -5296,6 +5390,14 @@
}
}
},
"GameRole": {
"type": "string",
"description": "What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big\nPicture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,\nleases and lists exactly like a game (design D4), and clients that don't know the field render it\nas a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged\nfor every entry that doesn't opt in.",
"enum": [
"game",
"launcher"
]
},
"GameSession": {
"type": "string",
"description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).",
@@ -6334,6 +6436,13 @@
"title"
],
"properties": {
"category": {
"type": [
"string",
"null"
],
"description": "What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui`\nbecause it describes the plugin, not its surface. The console knows one value today —\n`library` — which it filters **out of the nav**: six installed scanner plugins would otherwise\nflood the sidebar, and their real entry point is the Game sources surface (design D5). A\nlibrary plugin that genuinely wants its own page (rom-manager, which is much more than a\nscanner) simply omits the category."
},
"title": {
"type": "string",
"description": "Human-readable title for the console nav entry (164 chars; control chars stripped)."
@@ -6366,6 +6475,13 @@
"title"
],
"properties": {
"category": {
"type": [
"string",
"null"
],
"description": "The plugin's kind — see [`PluginRegistration::category`]."
},
"id": {
"type": "string"
},
@@ -6604,6 +6720,10 @@
},
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
},
"role": {
"$ref": "#/components/schemas/GameRole",
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin\nemits its `launchers(cfg)` entries with `role: \"launcher\"`."
},
"title": {
"type": "string"
}
@@ -6780,26 +6900,46 @@
},
"ScannerInfo": {
"type": "object",
"description": "One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a toggle for. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.",
"description": "One **game source** on this host, with its enable state — the unit the console renders a toggle\nfor. A source is either a scanner compiled into this build or a plugin that reconciles entries in\n(WP2.6); the console treats them identically, which is what makes the extraction invisible.",
"required": [
"id",
"label",
"enabled"
"enabled",
"origin"
],
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether this host runs the scanner (default true)."
"description": "Whether this host runs the source (default true)."
},
"entries": {
"type": [
"integer",
"null"
],
"description": "How many entries this source currently contributes. `None` for a built-in scanner, whose\ncount would mean walking every launcher's files just to render a toggle.",
"minimum": 0
},
"id": {
"type": "string",
"description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.",
"description": "Stable source id — the same string this source's entries carry in their `store` field. For a\nplugin source it is also its provider id and its store claim: one string, by construction, so\na user's disabled state survives a built-in scanner being replaced by its plugin.",
"example": "steam"
},
"label": {
"type": "string",
"description": "Human-facing name for the console toggle.",
"example": "Steam"
},
"origin": {
"$ref": "#/components/schemas/SourceOrigin",
"description": "Where the source comes from: `builtin` (a scanner in this host build) or `plugin`."
},
"provider": {
"type": [
"string",
"null"
],
"description": "The provider id backing a `plugin` source — absent for a built-in scanner."
}
}
},
@@ -6962,6 +7102,14 @@
}
}
},
"SourceOrigin": {
"type": "string",
"description": "Where a [`ScannerInfo`] comes from.",
"enum": [
"builtin",
"plugin"
]
},
"SourceView": {
"type": "object",
"description": "A configured catalog source and how its last refresh went.",
@@ -26,13 +26,25 @@ import kotlin.math.roundToInt
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow]`. Every read
* is length-guarded, so an older native lib simply omits the lines it can't feed.
*
* The shown `display` and `end-to-end` numbers EXCLUDE the OS present floor (see [osFloorMs]) at
* every tier, and the detailed tier names what was excluded on its own line. The principle is the
* Apple client's: metrics report what Punktfunk controls, so the compositor's own latch and scanout
* which no client can pace under is reported rather than charged. It also stops the HUD reading
* worse than it is: the usual Android streaming overlays stop measuring at decode-complete, so a
* headline that carried the compositor's wait was compared against numbers that never contained it.
*
* The RAW figures are not lost the native 1 Hz `pf.present` logcat line keeps `paceMs`, `latchMs`
* and `e2eMs` unshaved, so a HUD-off A/B and any cross-session comparison still work off the
* untouched numbers.
*
* [verbosity] selects how many lines render (each tier a superset of the last see
* [StatsVerbosity]):
* - [StatsVerbosity.COMPACT] one line, `fps · end-to-end ms · Mb/s` (+ a loss flag).
* - [StatsVerbosity.NORMAL] the res/fps/Mb·s line, the end-to-end p50/p95 headline, and the
* reliability counters (1821) when nonzero.
* - [StatsVerbosity.DETAILED] also the decoder label, the video-feed descriptor (1013), and the
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero).
* - [StatsVerbosity.DETAILED] also the decoder label, the video-feed descriptor (1013), the
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero),
* and the excluded-floor line when one was measured.
* [StatsVerbosity.OFF] renders nothing. Older native layouts simply omit the lines they lack (the
* counter line falls back to the cumulative `lostTotal` at index 9 on a pre-window lib).
*/
@@ -95,9 +107,15 @@ internal fun StatsOverlay(
// equation gains its `display` term; otherwise (older lib / no callbacks) the endpoint
// honestly stays capture→decoded — the equation always tiles the headline interval.
val dispValid = s.size >= 26 && s[22] != 0.0
// The OS present floor this window (see [osFloorMs]) is excluded from every shown
// display / end-to-end number, at every tier — it is pipeline depth no client can pace
// under, so charging it to Punktfunk made our HUD read worse than clients that simply
// never measure it. 0.0 when unmeasured, which leaves the numbers exactly as raw as
// they were.
val floorMs = osFloorMs(s)
val tag = if (skew) "" else " (same-host clock)"
val (p50, p95, endpoint) = if (dispValid) {
Triple(s[24], s[25], "capture→displayed")
Triple(shave(s[24], floorMs), shave(s[25], floorMs), "capture→displayed")
} else {
Triple(s[2], s[3], "capture→decoded")
}
@@ -120,6 +138,11 @@ internal fun StatsOverlay(
// dropping/serializing, an fps deficit is upstream.
val split = s.size >= 30 && s[29] != 0.0 && (s[26] > 0 || s[27] > 0)
val displayTerm = when {
// Floor excluded: what remains of the `display` term is the half Punktfunk
// owns (the presenter's pace wait), and the excluded line below carries the
// latch — printing the split too would report the same milliseconds twice.
dispValid && floorMs > 0 ->
" + display ${"%.1f".format(shave(s[23], floorMs))}"
dispValid && split ->
" + display ${"%.1f".format(s[23])} " +
"(pace ${"%.1f".format(s[26])} + latch ${"%.1f".format(s[27])})"
@@ -143,16 +166,14 @@ internal fun StatsOverlay(
"= $hostTerms + $decodeTerm$displayTerm$presents",
Color.White,
)
// Metric fairness: the Apple client's HUD shaves ~2 refresh periods of OS
// pipeline floor off its shown display/end-to-end; Android shows raw. This twin
// applies the same shave so iPhone↔Android HUD numbers compare directly.
if (dispValid && hz > 0) {
val shave = 2000.0 / hz
// What the numbers above leave out, named — the Apple client's
// `os present +N excluded` line, same wording so the two HUDs read alike.
// (This replaces the old "≈ Apple-HUD equiv" twin: both clients now shave, and
// Android's shave is measured rather than assumed at 2 refresh periods.)
if (floorMs > 0) {
statLine(
"≈ Apple-HUD equiv: end-to-end " +
"${"%.1f".format((s[24] - shave).coerceAtLeast(0.0))} · display " +
"${"%.1f".format((s[23] - shave).coerceAtLeast(0.0))} (2 refresh)",
Color(0xFFA8D8B8),
"os present +${"%.1f".format(floorMs)} excluded (display pipeline minimum)",
Color(0xFF9AA6B8),
)
}
}
@@ -167,6 +188,37 @@ private fun statLine(text: String, color: Color) {
Text(text, color = color, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
}
/**
* The OS present floor to exclude from the shown `display` / `end-to-end` numbers, ms the
* measured `latch` p50 at index 27, i.e. release`OnFrameRendered`: SurfaceFlinger's own latch and
* scanout. That is compositor pipeline depth no client can pace under, so it is reported as
* excluded rather than charged to Punktfunk the Apple client's policy since its presentation
* rebuild, where the same floor is measured from the display link's vend lead.
*
* Measured, not assumed: the previous Android treatment used a fixed `2000/hz` twin, but the latch
* varies with panel rate, tunnelled playback and the vendor's low-latency mode (~21 ms p50 observed
* where the ~2-interval model predicts less), and this term self-adapts to all three. It is also
* available on every render path the presenter's and both legacy release-immediately ones since
* the release stamp it starts from is parked on every render, so it does not depend on
* `presenterActive` (29).
*
* `0.0` means unmeasured no display stage this window (an older native lib, API < 33, or a
* platform that refused the callback), or no latch sample paired and every caller then leaves its
* number raw, which is the honest fallback: we exclude only what we actually measured.
*/
private fun osFloorMs(s: DoubleArray): Double {
val dispValid = s.size >= 26 && s[22] != 0.0
if (!dispValid || s.size < 28) return 0.0
return s[27].coerceAtLeast(0.0)
}
/**
* Subtract the excluded [floorMs] from a shown latency [ms], clamped at zero the percentiles are
* drawn from different sample sets (a p50 latch against a p50/p95 end-to-end), so the difference can
* legitimately go slightly negative on a well-paced window without anything being wrong.
*/
private fun shave(ms: Double, floorMs: Double): Double = (ms - floorMs).coerceAtLeast(0.0)
/**
* The single [StatsVerbosity.COMPACT] line: `238 fps · 1.3 ms · 921 Mb/s`. The end-to-end p50 term
* is dropped when no in-range latency sample landed (`latValid` false), and a loss flag
@@ -174,8 +226,9 @@ private fun statLine(text: String, color: Color) {
* one reliability signal worth surfacing even at the tersest tier.
*/
private fun compactLine(s: DoubleArray, latValid: Boolean): String {
// Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window.
val e2eP50 = if (s.size >= 26 && s[22] != 0.0) s[24] else s[2]
// Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window,
// less the excluded OS present floor — the same number the richer tiers headline.
val e2eP50 = if (s.size >= 26 && s[22] != 0.0) shave(s[24], osFloorMs(s)) else s[2]
val parts = buildList {
add("${s[0].roundToInt()} fps")
if (latValid) add("${"%.1f".format(e2eP50)} ms")
@@ -355,9 +355,11 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
// dispValid, displayP50, e2eDispP50, e2eDispP95].
// 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
// video-feed line; the display stage is valid (dispValid 1) so the headline is the
// directly-measured capture→displayed pair (1.8/2.6) and the Phase-2 stage terms
// (host 0.6 + network 0.3 + decode 0.4 + display 0.5) tile it, rendering the full split
// equation; the decoder label shows the ranked low-latency decoder. Light per-window loss
// directly-measured capture→displayed pair, less the excluded OS present floor (the 0.3
// latch p50) — 1.5/2.3 shown from 1.8/2.6 raw — and the Phase-2 stage terms
// (host 0.6 + network 0.3 + decode 0.4 + display 0.2) tile the shaved headline, with the
// `os present +0.3 excluded` line naming what came off; the decoder label shows the ranked
// low-latency decoder. Light per-window loss
// (lost 2 · skipped 1 · FEC 5 of 238) so the reliability line (NORMAL/DETAILED) and the
// compact loss flag both render.
StatsOverlay(
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "punktfunk",
"name": "Punktfunk",
"author": "enrico",
"flags": ["debug"],
"api_version": 1,
+3 -1
View File
@@ -12,7 +12,9 @@
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
DECK="${DECK:?set DECK=deck@<ip>}"
NAME="$(python3 -c 'import json;print(json.load(open("'"$HERE"'/plugin.json"))["name"])')"
# The on-disk plugin DIR (what scripts/package.sh staged into out/), not plugin.json "name"
# that field is the brand-cased label Decky shows in its plugin list. See package.sh's header.
NAME=punktfunk
STAGE_LOCAL="$HERE/out/$NAME"
[ -d "$STAGE_LOCAL" ] || { echo "$STAGE_LOCAL missing — run scripts/package.sh first" >&2; exit 1; }
+8 -4
View File
@@ -5,9 +5,13 @@
# package.json,decky.pyi,LICENSE,README.md}
# out/punktfunk/ (the same tree, unzipped — rsync this with scripts/deploy.sh)
#
# Decky extracts the zip with --strip-components=1, so the single top-level dir MUST equal
# plugin.json "name". Run after `pnpm build` (or use `pnpm run package`). Host-agnostic: needs
# only bash, python3 and zip.
# The single top-level dir is the plugin's ON-DISK folder name (Decky extracts the zip as-is,
# so the dir in the zip becomes ~/homebrew/plugins/<dir>). It is deliberately NOT read from
# plugin.json "name": that field is the user-visible label ("Punktfunk", brand-cased, shown in
# Decky's plugin list) and Decky locates an installed plugin by MATCHING it, never by the folder
# name. Keeping the folder lowercase means a rename of the label can't strand the old directory
# next to a new one (which would show up as two plugins).
# Run after `pnpm build` (or use `pnpm run package`). Host-agnostic: needs only bash, python3 and zip.
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
cd "$HERE"
@@ -15,7 +19,7 @@ cd "$HERE"
[ -f dist/index.js ] || { echo "dist/index.js missing — run 'pnpm build' first" >&2; exit 1; }
[ -f LICENSE ] || { echo "LICENSE missing (required by the Decky store)" >&2; exit 1; }
NAME="$(python3 -c 'import json;print(json.load(open("plugin.json"))["name"])')"
NAME=punktfunk # the on-disk plugin dir (see the header) — NOT plugin.json "name"
VER="$(python3 -c 'import json;print(json.load(open("package.json"))["version"])')"
STAGE="$(mktemp -d)"
+24 -2
View File
@@ -122,6 +122,25 @@ function advertMatchesSaved(a: DiscoveredHost, s: SavedHost): boolean {
);
}
/**
* The label a saved row shows.
*
* A saved record whose name IS its own address is a PLACEHOLDER, not a choice: `hosts add`
* falls back to the address when the pairing path had nothing better, so the row ends up
* captioned with the same string it already prints underneath. When the box is on the air it
* is advertising its actual hostname prefer that, and the row reads "home-worker-5" instead
* of "192.168.1.21".
*
* A real saved name always wins over the advert, even a stale one: it may be a name the user
* chose, and a live advert must never quietly overwrite that. Compared against the SAVED
* address, so a host that moved DHCP lease still recognises its old address as a placeholder.
*/
function hostLabel(s: SavedHost, advert?: DiscoveredHost): string {
const placeholder = !s.name || s.name === s.addr || s.name === `${s.addr}:${s.port}`;
if (!placeholder) return s.name;
return advert?.name || s.name || s.addr;
}
/**
* Join the saved store and the live browse into the rows the panel draws.
*
@@ -134,7 +153,7 @@ export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): Ho
// Prefer a live advert's address: the host may have moved since it was last saved.
const advert = discovered.find((a) => advertMatchesSaved(a, s));
return {
name: s.name || s.addr,
name: hostLabel(s, advert),
addr: advert?.addr ?? s.addr,
port: advert?.port ?? s.port,
fp: s.fp_hex,
@@ -387,7 +406,10 @@ export async function applyUpdate(
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
void backend.callable("utilities/install_plugin")(
info.artifact,
"punktfunk",
// The name Decky uninstalls before extracting the new zip — it locates the folder by
// matching plugin.json "name", so this must equal THIS build's plugin.json name (the
// brand-cased one), not the lowercase on-disk dir.
"Punktfunk",
info.latest,
info.hash,
INSTALL_TYPE_UPDATE,
+5 -3
View File
@@ -337,9 +337,11 @@ export default definePlugin(() => {
// controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load.
void ensureGamepadUiShortcut();
return {
// `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader
// keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk".
name: "punktfunk",
// `name` must stay in sync with plugin.json (the loader keys plugins by it) — and it is
// USER-VISIBLE: Decky labels the entry in its plugin list with it, so it carries the brand
// case. Decky finds an installed plugin by matching plugin.json "name" (never the folder
// name), so this is independent of the on-disk dir, which stays lowercase `punktfunk`.
name: "Punktfunk",
// `staticClasses?.Title` is guarded so a future client that drops the export can't throw
// at plugin-load time (an error boundary only catches render-time, not load-time, errors).
titleView: <div className={staticClasses?.Title}>Punktfunk</div>,
+12 -3
View File
@@ -70,9 +70,18 @@ declare const appStore:
* entry from a false "missing". A confident null means the shortcut was deleted recreate. */
function shortcutStillExists(appId: number): boolean {
try {
const get = appStore?.GetAppOverviewByAppID;
if (!get) return true; // no way to verify — preserve the reuse path
return get(appId) != null;
// Call it as a METHOD on appStore — NEVER as an extracted function. Its implementation
// reads the store's own state (`this.m_mapApps`), so `const get = appStore.GetAppOverview…;
// get(id)` throws on the lost `this`, and the catch below turns that into a permanent
// "true". That is not a stale-data bug but a total one: the guard then answers "still
// exists" for EVERY appId, so a dangling id is never dropped, the reuse path repoints a
// dead shortcut (silent no-ops), and "recreate" reports success having done nothing.
// `typeof` first: `appStore` is a Steam-injected global, and a bare reference to a missing
// one is a ReferenceError that optional chaining does NOT prevent.
if (typeof appStore === "undefined" || !appStore?.GetAppOverviewByAppID) {
return true; // no way to verify — preserve the reuse path
}
return appStore.GetAppOverviewByAppID(appId) != null;
} catch {
return true;
}
+1
View File
@@ -773,6 +773,7 @@ fn mock_library() -> (
title: title.to_string(),
art: crate::library::Artwork::default(),
platform: None,
role: None,
};
let games = vec![
game("steam:570", "steam", "Dota 2"),
+26 -1
View File
@@ -1911,10 +1911,35 @@ pub(crate) fn settings_page(
} else {
border(vstack(Vec::<Element>::new())).into()
};
// Every save on this page is fire-and-forget by design — a failed settings write must
// never take a stream down — so a client whose config store rejects writes looks entirely
// normal: toggles move, profiles appear, and NOTHING survives a restart. That is exactly
// how it reached us from the field ("it's in read-only mode"), with no log file to send
// either. When the store is refusing writes, say so, name the path, and stop pretending.
//
// Same always-mounted-slot discipline as `sheet_slot`: one child in both states, and the
// SAME KIND in both (a Border wrapping the bar, versus an empty background-less Border —
// which per style.rs is not hit-testable, so it swallows no clicks). Neither a grid child
// nor a vstack child is ever added or removed, which is where this reconciler's phantom
// bookkeeping breaks.
let store_slot: Element = match pf_client_core::trust::store_health::last_error() {
Some(err) => border(
InfoBar::new("Your changes aren\u{2019}t being saved")
.message(format!(
"Punktfunk can\u{2019}t write to its settings folder, so nothing on this \
page will survive a restart. {err}"
))
.error()
.is_closable(false),
)
.margin(edges(24.0, 12.0, 28.0, 0.0))
.into(),
None => border(vstack(Vec::<Element>::new())).into(),
};
// The bar rides an Auto row above the nav's Star row, so the nav (and the sheet's scrim
// over it) still fills the rest of the window.
grid(vec![
scope_bar.grid_row(0),
Element::from(vstack(vec![store_slot, scope_bar])).grid_row(0),
Element::from(grid(vec![nav.into(), sheet_slot, confirm])).grid_row(1),
])
.rows([GridLength::Auto, GridLength::STAR])
+14
View File
@@ -66,6 +66,20 @@ pub struct GameEntry {
/// host's flattened `GameMeta`; the rest of the metadata is not decoded until a UI needs it.
#[serde(default)]
pub platform: Option<String>,
/// `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that opens
/// the launcher itself (Steam Big Picture, Heroic) rather than a title. A UI may group these
/// separately; one that doesn't renders them as ordinary tiles, which is the intended
/// degradation (design D4). Kept a plain string: the host owns the vocabulary, and an unknown
/// future value must never fail the whole library decode.
#[serde(default)]
pub role: Option<String>,
}
impl GameEntry {
/// Whether this entry opens a launcher rather than a game.
pub fn is_launcher(&self) -> bool {
self.role.as_deref() == Some("launcher")
}
}
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
+224 -9
View File
@@ -91,22 +91,131 @@ fn lock_identity_perms(dir: &std::path::Path, key: &std::path::Path) {
let _ = std::fs::set_permissions(key, std::fs::Permissions::from_mode(0o600));
}
/// A sibling temp path unique to this process. The stores below have five whole-file writers
/// (WinUI shell, session, console UI, CLI, Decky) and a single shared `.json.tmp` lets two of
/// them interleave: on Windows the second `fs::write` hits a sharing violation, and worse, one
/// process can rename the OTHER's half-written bytes over the target. The pid keeps each
/// writer on its own scratch file; the rename below removes it, so a leftover only survives a
/// hard kill.
fn temp_sibling(path: &Path) -> PathBuf {
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(format!(".tmp-{}", std::process::id()));
path.with_file_name(name)
}
/// Write a config file the safe way: a sibling temp file, then a rename over the target. A
/// plain `fs::write` truncates first, so a crash, a full disk or a power cut between truncate
/// and the last byte leaves an empty/half file — and these stores are what a client needs to
/// find its hosts at all. Rename is atomic within a directory on both Unix and Windows
/// (`MoveFileEx` with replace), so a reader ever sees the old file or the new one, never a
/// torn one. Same discipline as the host's `session_settings.rs`.
///
/// **But the rename is not always available, and losing the write is far worse than a torn
/// one.** The Windows client ships as an MSIX package, so every path here is rewritten by the
/// container's AppData virtualization before it reaches the filesystem — and when the package
/// is installed to a secondary drive (Settings ▸ Storage ▸ "New apps will save to: D:"),
/// Windows stores that redirected AppData on the *package's* volume, under
/// `D:\WpSystem\<SID>\AppData\`. The literal path we name still says `C:\Users\…`, so a rename
/// can end up straddling two volumes, and `std::fs::rename` is `MoveFileExW` with
/// `MOVEFILE_REPLACE_EXISTING` and *not* `MOVEFILE_COPY_ALLOWED` — a cross-volume move fails
/// outright with `ERROR_NOT_SAME_DEVICE`. Creating and writing files works fine, which is why
/// such an install starts, streams and pairs happily while every setting and profile silently
/// evaporates (field report 2026-08-05: "it's in read-only mode").
///
/// So a failed rename falls back to writing the target in place. That is exactly what the
/// identity files already do a few lines up — and those demonstrably work on the affected
/// installs — so the fallback is a path we know resolves. It gives up crash-atomicity for that
/// one write and nothing else: the temp+rename stays the normal route everywhere it works.
///
/// Writes and reads of one literal path cannot disagree under that redirection — Microsoft
/// documents a single private-location-first resolution order for both, so whichever layer a
/// write lands in is the layer the next read finds. The fallback still verifies by reading
/// back: a silent write is the exact bug being fixed here, and this path only runs on an
/// install that has already proven it does something unusual.
pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, bytes)?;
match std::fs::rename(&tmp, path) {
Ok(()) => Ok(()),
Err(e) => {
// Don't leave the temp behind to confuse the next writer (or a backup tool).
let _ = std::fs::remove_file(&tmp);
Err(e)
let tmp = temp_sibling(path);
let atomic = std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path));
let Err(e) = atomic else {
store_health::clear();
return Ok(());
};
// Don't leave the temp behind to confuse the next writer (or a backup tool).
let _ = std::fs::remove_file(&tmp);
match std::fs::write(path, bytes) {
Ok(()) => {
tracing::warn!(
path = %path.display(),
error = %e,
"atomic replace unavailable in this install; wrote the config in place instead",
);
// Read it straight back. This whole bug was a write that reported success and
// vanished, so the fallback does not get to claim success on the strength of an
// `Ok(())` alone — on the one layered filesystem we know we run on, that is the
// failure mode to be paranoid about. Only on the degraded path, so the normal
// route pays nothing.
match std::fs::read(path) {
Ok(back) if back == bytes => {
store_health::clear();
Ok(())
}
Ok(_) => {
let e = std::io::Error::other(
"the file read back different from what was just written",
);
store_health::record(path, &e);
Err(e)
}
Err(reread) => {
store_health::record(path, &reread);
Err(reread)
}
}
}
// Both routes are gone: the store really is unwritable. Report the direct write's
// error — it describes the actual permission/space problem, where the rename's may
// only say the two paths landed on different volumes.
Err(direct) => {
store_health::record(path, &direct);
Err(direct)
}
}
}
/// Whether the config store is accepting writes, so a front-end can *say so* when it is not.
///
/// Every persistence call site in this crate is deliberately fire-and-forget — a failed
/// settings write must never take a stream down — which historically meant a client whose
/// store was unwritable looked completely normal: toggles moved, profiles appeared, and
/// nothing survived a restart. The field report that produced this module had no log file to
/// send either, so there was no signal anywhere. Recording the last failure centrally lets the
/// UI surface it without unpicking ~15 `let _ = …save()` call sites.
pub mod store_health {
use std::path::Path;
use std::sync::Mutex;
static LAST_ERROR: Mutex<Option<String>> = Mutex::new(None);
pub(crate) fn record(path: &Path, err: &std::io::Error) {
let msg = format!("{}: {err}", path.display());
tracing::error!(store = %path.display(), error = %err, "cannot persist client config");
if let Ok(mut slot) = LAST_ERROR.lock() {
*slot = Some(msg);
}
}
pub(crate) fn clear() {
if let Ok(mut slot) = LAST_ERROR.lock() {
*slot = None;
}
}
/// The most recent failure to persist a config file, if the last attempt failed.
///
/// Tracks the last *attempt*, not a per-file verdict: a store that cannot be written fails
/// every file, so this latches for as long as the problem lasts and goes quiet the moment
/// any write gets through.
pub fn last_error() -> Option<String> {
LAST_ERROR.lock().ok().and_then(|s| s.clone())
}
}
@@ -1940,6 +2049,7 @@ mod tests {
/// discipline all three client stores now share.
#[test]
fn write_atomic_replaces_and_cleans_up() {
let _guard = store_health_lock();
let dir = std::env::temp_dir().join(format!(
"pf-client-core-test-{}",
std::time::SystemTime::now()
@@ -1953,7 +2063,112 @@ mod tests {
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":1}");
write_atomic(&p, b"{\"a\":2}").unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":2}");
assert!(!p.with_extension("json.tmp").exists());
assert!(!temp_sibling(&p).exists());
// Nothing else in the directory either — the scratch file is gone, not renamed aside.
let left: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok().map(|e| e.file_name()))
.collect();
assert_eq!(left, vec![std::ffi::OsString::from("store.json")]);
let _ = std::fs::remove_dir_all(&dir);
}
/// `store_health` is process-global, so the two tests that read it must not run at the same
/// time — one's successful write clears the other's recorded failure. Nothing else in the
/// crate's tests reaches `write_atomic`, so this lock is the whole serialization needed.
fn store_health_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
/// Two processes saving at once must not share one scratch file — the pid keeps them apart.
/// (Same-process, so this only proves the name varies with the pid, not the interleaving.)
#[test]
fn temp_sibling_is_per_process_and_a_sibling() {
let p = Path::new("/tmp/pf/client-windows-settings.json");
let t = temp_sibling(p);
assert_eq!(t.parent(), p.parent());
assert_eq!(
t.file_name().unwrap().to_str().unwrap(),
format!("client-windows-settings.json.tmp-{}", std::process::id())
);
// Must not collide with the store itself, nor look like one to `load()`.
assert_ne!(t, p.to_path_buf());
}
/// **The fix itself.** When the temp+rename route is unavailable, the bytes must still
/// reach the target — that is the difference between the field's "read-only mode" and a
/// working client. Simulated by parking a DIRECTORY on the (deterministic) temp sibling
/// path so the temp leg cannot be written; the field's install fails one step later, at
/// the rename, but both funnel into the same fallback, which is what this pins.
#[test]
fn the_atomic_route_failing_falls_back_to_an_in_place_write() {
let _guard = store_health_lock();
let dir = std::env::temp_dir().join(format!(
"pf-client-core-inplace-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("store.json");
std::fs::write(&p, b"{\"old\":true}").unwrap();
// Block the scratch path, so the atomic route cannot complete.
std::fs::create_dir_all(temp_sibling(&p)).unwrap();
assert!(temp_sibling(&p).is_dir());
// The write must still report success AND actually be readable back — a silent
// `Ok(())` that lost the bytes is the bug, not the fix.
write_atomic(&p, b"{\"new\":true}").unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"new\":true}");
// Degraded, but not broken: nothing to warn the user about.
assert_eq!(store_health::last_error(), None);
let _ = std::fs::remove_dir_all(&dir);
}
/// The other end: when the in-place fallback ALSO fails, the error must surface rather
/// than be swallowed, because at that point nothing the user does on the page will stick.
#[test]
fn a_failed_rename_still_persists_the_write() {
let _guard = store_health_lock();
let dir = std::env::temp_dir().join(format!(
"pf-client-core-fallback-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).unwrap();
// Sanity: the healthy path reports a healthy store.
let ok = dir.join("store.json");
write_atomic(&ok, b"{}").unwrap();
assert_eq!(store_health::last_error(), None);
// Now the unwritable case: a directory in the target's place defeats BOTH the rename
// and the in-place write, so the error must surface instead of being swallowed.
let blocked = dir.join("blocked.json");
std::fs::create_dir_all(&blocked).unwrap();
std::fs::write(blocked.join("occupant"), b"x").unwrap();
assert!(write_atomic(&blocked, b"{\"a\":1}").is_err());
let reported = store_health::last_error().expect("an unwritable store must be reported");
assert!(
reported.contains("blocked.json"),
"the report names the store: {reported}"
);
// No scratch file left behind by the failed attempt.
assert!(!temp_sibling(&blocked).exists());
// And a later success clears it, so the UI stops warning once the store recovers.
write_atomic(&ok, b"{\"a\":2}").unwrap();
assert_eq!(store_health::last_error(), None);
assert_eq!(std::fs::read_to_string(&ok).unwrap(), "{\"a\":2}");
let _ = std::fs::remove_dir_all(&dir);
}
}
+6 -5
View File
@@ -270,7 +270,11 @@ fn load_floor(path: &Path, channel: &str) -> u64 {
.unwrap_or(0)
}
/// Raise (never lower) the floor; atomic tmp+rename so a power cut can't half-write it.
/// Raise (never lower) the floor, through the crate's one config writer — this used to
/// hand-roll its own tmp+rename, which meant it neither cleaned up its temp on a failed
/// rename nor picked up [`crate::trust::write_atomic`]'s in-place fallback, so on an install
/// where the rename cannot work the floor silently never rose and a declined update came
/// back forever.
fn store_floor(path: &Path, channel: &str, serial: u64) {
let mut file: FloorFile = std::fs::read(path)
.ok()
@@ -287,10 +291,7 @@ fn store_floor(path: &Path, channel: &str, serial: u64) {
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, &bytes).is_ok() {
let _ = std::fs::rename(&tmp, path);
}
let _ = crate::trust::write_atomic(path, &bytes);
}
// ---------------------------------------------------------------- check
@@ -2465,11 +2465,18 @@ pub fn ei_socket_file() -> std::path::PathBuf {
crate::with_env_lock(pf_paths::gamescope_ei_socket_file)
}
/// Does this resolved launch command start Steam (`steam … steam://…`)? Such a launch needs Steam's
/// single instance free before a dedicated spawn (B1). Pure + unit-tested.
/// Does this resolved launch command start the Steam **client**? Such a launch needs Steam's single
/// instance free before a dedicated spawn (B1), and wants gamescope's `--steam` integration on.
/// Pure + unit-tested.
///
/// The test is the first token, NOT the presence of a `steam://` URI. A `steam_ui` launcher entry
/// (design D4) resolves to a bare `steam -gamepadui` / `steam` with no URI at all, and it is *more*
/// exposed to the single-instance problem than a game launch is, not less: on a box that autologged
/// into game mode, the nested second Steam would see the first and exit, taking the spawn down with
/// it. A URI-gated check would silently skip both the instance free and `--steam` for exactly the
/// launch that most needs them.
fn is_steam_launch(cmd: &str) -> bool {
let mut it = cmd.split_whitespace();
it.next() == Some("steam") && cmd.contains("steam://")
cmd.split_whitespace().next() == Some("steam")
}
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
@@ -2865,7 +2872,13 @@ mod tests {
assert!(is_steam_launch("steam -silent steam://rungameid/570"));
assert!(!is_steam_launch("vkcube"));
assert!(!is_steam_launch("lutris lutris:rungameid/42"));
assert!(!is_steam_launch("steam -bigpicture")); // no URI = not a game launch
// A `steam_ui` LAUNCHER entry (design D4) carries no URI, and must still count: it needs the
// single instance freed (B1) and gamescope's `--steam` mode on. Gating on `steam://` would
// have skipped both for the one launch that is Big Picture itself.
assert!(is_steam_launch("steam -gamepadui"));
assert!(is_steam_launch("steam"));
// A command that merely mentions steam elsewhere is not a Steam client launch.
assert!(!is_steam_launch("mygame --steam-overlay"));
}
#[test]
@@ -2891,6 +2904,13 @@ mod tests {
shape_dedicated_command("steam -bigpicture"),
"steam -bigpicture"
);
// The `steam_ui` launcher entries (design D4) pass through untouched — the shaping only ever
// fires on a `steam://` game launch, so there is no way to end up with `-gamepadui` twice.
assert_eq!(
shape_dedicated_command("steam -gamepadui"),
"steam -gamepadui"
);
assert_eq!(shape_dedicated_command("steam"), "steam");
}
#[test]
@@ -245,6 +245,19 @@ mod tests {
}
}
/// The migration invariant D2 exists to protect. Moonlight caches app ids (and users pin them),
/// and the id is derived from the LIBRARY ID alone — so a title moving from the in-host scanner
/// to a claimed plugin entry keeps its GameStream id iff the library id is byte-identical. This
/// pins that the claimed shape is that shape, and that an unclaimed one would NOT have been.
#[test]
fn a_claimed_plugin_entry_keeps_the_scanners_gamestream_id() {
// What the built-in scanner produced, and what the steam plugin produces once it claims.
assert_eq!(stable_app_id("steam:440"), stable_app_id("steam:440"));
// The same title reconciled WITHOUT a claim gets an opaque `custom:` id — a different app
// id, i.e. exactly the breakage the claim prevents.
assert_ne!(stable_app_id("steam:440"), stable_app_id("custom:9f2c1a"));
}
#[test]
fn append_library_dedups_against_base_ids() {
// A base app whose id happens to fall in the library range must not be clobbered by a library
+54 -6
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::HashSet;
pub(crate) use std::collections::{BTreeMap, HashSet};
pub(crate) use std::path::{Path, PathBuf};
pub(crate) use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) use utoipa::ToSchema;
@@ -136,6 +136,29 @@ impl GameMeta {
}
}
/// What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big
/// Picture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,
/// leases and lists exactly like a game (design D4), and clients that don't know the field render it
/// as a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged
/// for every entry that doesn't opt in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum GameRole {
/// An ordinary title.
#[default]
Game,
/// The launcher application itself.
Launcher,
}
impl GameRole {
/// Whether this is the serde default (`game`) — the `skip_serializing_if` predicate that keeps
/// the field off the wire for the overwhelming majority of entries.
pub(crate) fn is_game(&self) -> bool {
matches!(self, Self::Game)
}
}
/// One title in the unified library, regardless of which store it came from.
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct GameEntry {
@@ -147,6 +170,9 @@ pub struct GameEntry {
pub store: String,
pub title: String,
pub art: Artwork,
/// Whether this entry is a game or the launcher itself — see [`GameRole`].
#[serde(default, skip_serializing_if = "GameRole::is_game")]
pub role: GameRole,
/// How the host would launch it, when known.
#[serde(skip_serializing_if = "Option::is_none")]
pub launch: Option<LaunchSpec>,
@@ -228,12 +254,26 @@ impl ArtKind {
}
}
/// The full library: every *enabled* store's titles merged + the custom entries, sorted by title.
/// The operator's scanner toggles (`scanners.rs`) gate each installed-store provider; the custom
/// store is not a scanner and always contributes.
/// The full library: every *enabled* source's titles merged + the custom entries, sorted by title.
///
/// Two independent gates run here, both at READ time so neither ever mutates stored state:
///
/// * **The operator's source toggles** (`scanners.rs`, persisted as a disabled-set in
/// `library-scanners.json`) hide a source's titles from every surface — this grid, native clients,
/// `/applist`, and launch resolution. They apply to built-in scanners *and* to plugin sources,
/// which is what lets one toggle keep working verbatim across the whole migration: the ids match
/// (provider id = claimed store id = old scanner id).
/// * **Store claims** (D2): while a library plugin holds a store's claim, the matching built-in
/// scanner is skipped so the two never double-list the same titles during the bridge releases.
/// Removing the plugin releases the claim and the built-in comes straight back.
///
/// The user-curated custom store is not a source and always contributes.
pub fn all_games() -> Vec<GameEntry> {
let off = disabled_scanners();
let on = |id: &str| !off.contains(id);
let claimed = claimed_stores();
// A built-in scanner runs when the operator hasn't disabled it AND no plugin has claimed its
// store out from under it.
let on = |id: &str| !off.contains(id) && !claimed.contains_key(id);
let mut games = Vec::new();
if on("steam") {
games.extend(SteamProvider.list());
@@ -262,7 +302,15 @@ pub fn all_games() -> Vec<GameEntry> {
games.extend(XboxProvider.list());
}
}
games.extend(load_custom().into_iter().map(GameEntry::from));
// Stored entries: manual ones always contribute; a provider's are subject to the same source
// toggle a built-in scanner is (WP2.6). The plugin may keep reconciling while it is off — the
// entries stay stored and simply aren't surfaced, exactly like a disabled scanner's titles.
games.extend(
load_custom()
.into_iter()
.filter(|e| !source_id_for(e).is_some_and(|src| off.contains(src)))
.map(GameEntry::from),
);
games.sort_by_key(|g| g.title.to_lowercase());
games
}
+232 -13
View File
@@ -147,17 +147,82 @@ pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
/// A stored [`Artwork`] value that is a **local filesystem path** to an image on the host — as
/// opposed to an `http(s)`/`data:` URL or an already-relative host proxy path. Provider plugins that
/// run on the host (e.g. the Playnite sync plugin) set these: the reconcile payload stays tiny
/// (paths, not inlined bytes, so it scales to thousands of titles) and the host serves the bytes
/// through the art proxy, exactly like Steam's cache art. Windows-shaped only (`C:\…`, `C:/…`, or a
/// `\\server\share` UNC) — Playnite, the only local-art provider, is Windows-only, and this keeps the
/// check from ever mistaking the `/api/…` proxy path (or a POSIX abs path) for a local file.
/// run on the host (the Playnite sync plugin, and every library scanner plugin) set these: the
/// reconcile payload stays tiny (paths, not inlined bytes, so it scales to thousands of titles) and
/// the host serves the bytes through the art proxy, exactly like Steam's cache art.
///
/// Four accepted shapes:
/// * `file://…` — the **documented plugin contract** ([`file_url_to_path`]), unambiguous on every
/// platform, and what `@punktfunk/plugin-kit/library` emits.
/// * `C:\…` / `C:/…` drive-absolute and `\\server\share` UNC — Windows bare paths, kept for
/// Playnite back-compat (it predates the `file://` contract).
/// * POSIX absolute (`/home/u/covers/x.jpg`) — Lutris covers and Steam's `librarycache`.
///
/// The POSIX widening is why the two `/`-leading shapes the **host itself emits** must be excluded
/// explicitly: its own art-proxy path (`/api/v1/library/art/…`, which [`proxy_local_art`] writes and
/// which must survive a second pass unchanged) and a protocol-relative URL (`//cdn/…`, what GOG's and
/// Microsoft's catalogs return — see [`abs_url`]). Mistaking either for a file would break the proxy
/// round-trip or silently drop CDN art.
pub fn is_local_art_path(v: &str) -> bool {
if v.starts_with("http://") || v.starts_with("https://") || v.starts_with("data:") {
return false;
}
if v.starts_with("file://") {
return true;
}
let b = v.as_bytes();
(b.len() >= 3 && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')) || v.starts_with("\\\\")
// Windows drive-absolute (`C:\…`, `C:/…`) or UNC (`\\server\share`).
if (b.len() >= 3 && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')) || v.starts_with("\\\\") {
return true;
}
// POSIX absolute, minus the host's own `/`-leading shapes (see the doc comment).
v.starts_with('/') && !v.starts_with("//") && !v.starts_with("/api/")
}
/// Turn a `file://` art value into a plain filesystem path, percent-decoding it. The kit emits
/// properly encoded URLs (`file:///home/u/My%20Cover.jpg`); a raw path that happens to contain no
/// `%` round-trips either way, which keeps hand-written plugin payloads working.
///
/// `file:///home/u/c.jpg` → `/home/u/c.jpg`; `file:///C:/covers/c.jpg` → `C:/covers/c.jpg` (Windows
/// drive letters arrive after the empty authority's slash); a NON-empty authority
/// (`file://nas/share/c.jpg`) is a UNC reference → `\\nas\share\c.jpg`. Anything without the prefix
/// is returned untouched.
fn file_url_to_path(v: &str) -> std::borrow::Cow<'_, str> {
use std::borrow::Cow;
let Some(rest) = v.strip_prefix("file://") else {
return Cow::Borrowed(v);
};
let decoded = percent_decode(rest);
match decoded.strip_prefix('/') {
// `file:///…` — the empty-authority form. A Windows drive letter (`/C:/…`) loses the slash;
// a POSIX path keeps it.
Some(after) if after.as_bytes().get(1) == Some(&b':') => Cow::Owned(after.to_string()),
Some(_) => Cow::Owned(decoded),
// `file://server/share/…` — a UNC path in URL clothing.
None => Cow::Owned(format!("\\\\{}", decoded.replace('/', "\\"))),
}
}
/// Percent-decode `%XX` escapes. Invalid escapes are left verbatim (a bare `%` in a real path is far
/// likelier than a malformed URL from our own kit), and the result is only ever used as a path that
/// must then exist as a regular file — so a wrong decode degrades to "no art", never to a wrong read.
fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' && i + 2 < b.len() {
let hex = |c: u8| (c as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hex(b[i + 1]), hex(b[i + 2])) {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
}
out.push(b[i]);
i += 1;
}
String::from_utf8(out).unwrap_or_else(|_| s.to_string())
}
/// The filesystem roots the art proxy is allowed to read from.
@@ -192,6 +257,29 @@ fn art_roots() -> Vec<PathBuf> {
roots.push(PathBuf::from(drive).join("Users"));
}
}
// POSIX: the user's home, which is the exact analogue of the Windows users base above — and
// where every launcher this host reads art from actually keeps it. Steam's
// `appcache/librarycache` and `userdata/<id>/config/grid`, Lutris's `coverart`/`banners` (both
// the `~/.local/share` and `~/.cache` copies), Heroic's caches, and all three Flatpak
// `~/.var/app/…` variants are under it.
//
// Needed because `is_local_art_path` now classifies POSIX absolute paths as local art (the
// extracted Lutris/Steam plugins emit them). Before that widening this list was legitimately
// empty here: the only local-art provider was Playnite, which is Windows-only, so nothing on a
// POSIX host was ever classified local and the confinement had nothing to confine. Leaving it
// empty now would not be "secure by default" — it would silently serve no plugin art at all.
//
// Breadth matches what Windows already ships, and it is not the load-bearing control: a value
// still has to carry an image extension, canonicalize to a real regular file inside a root,
// sit outside the host config dir, and CONTAIN image bytes. `PUNKTFUNK_LIBRARY_ART_ROOTS`
// narrows or relocates this for a library that lives elsewhere.
#[cfg(not(windows))]
if let Some(home) = std::env::var_os("HOME") {
let home = PathBuf::from(home);
if !home.as_os_str().is_empty() {
roots.push(home);
}
}
roots
}
@@ -306,15 +394,20 @@ pub fn validate_art_paths(art: &Artwork) -> Result<(), String> {
///
/// This is the single place local art bytes are read — the mgmt art proxy and the GameStream
/// `/appasset` proxy both land here — so the confinement holds for every caller.
///
/// A `file://` value is converted to a path FIRST ([`file_url_to_path`]), so the confinement check
/// and the read see the same decoded path. Ordering matters: percent-decoding before
/// canonicalization is what stops a `%2e%2e` escape being invisible to the traversal check.
pub fn local_art_bytes(path: &str) -> Option<(Vec<u8>, String)> {
if !art_path_is_servable(path) {
let path = file_url_to_path(path);
if !art_path_is_servable(&path) {
tracing::debug!(
path,
path = %path,
"art proxy: refusing a path outside the allowed art roots"
);
return None;
}
let p = std::path::Path::new(path);
let p = std::path::Path::new(&*path);
let meta = std::fs::metadata(p).ok()?;
if !meta.is_file() || meta.len() == 0 || meta.len() > 16 * 1024 * 1024 {
return None;
@@ -358,9 +451,22 @@ pub fn proxy_local_art(id: &str, art: &mut Artwork) {
/// `(bytes, content-type)`. Resolves the id against the host's OWN library. Blocking — call off the
/// async runtime (e.g. `spawn_blocking`).
pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
// Steam's `Artwork` fields are now relative proxy paths (see `steam_art`) the *client* resolves
// against the host — meaningless to `fetch_image`, which expects an absolute URL. Resolve
// those kinds directly instead of going through the URL fields.
// Same resolution order as the management art proxy (WP1.2): the stored catalog first, for ANY
// id, so a library plugin's entries resolve without the warmer knowing its store.
if let Some(entry) = entry_for_library_id(id) {
return [
ArtKind::Portrait,
ArtKind::Header,
ArtKind::Hero,
ArtKind::Logo,
]
.into_iter()
.filter_map(|kind| art_field(&entry.art, kind))
.find_map(|v| resolve_art_bytes(&v));
}
// Legacy in-host Steam scanner: its `Artwork` fields are relative proxy paths (see `steam_art`)
// the *client* resolves against the host — meaningless to `fetch_image`, which expects an
// absolute URL. Resolve those kinds directly instead of going through the URL fields.
if let Some(appid) = id
.strip_prefix("steam:")
.and_then(|s| s.parse::<u32>().ok())
@@ -374,6 +480,7 @@ pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
.into_iter()
.find_map(|kind| steam_art_bytes(appid, kind));
}
// The remaining in-host scanners (heroic/lutris/epic/gog/xbox) carry absolute CDN URLs.
let g = all_games().into_iter().find(|g| g.id == id)?;
[g.art.portrait, g.art.header, g.art.hero, g.art.logo]
.into_iter()
@@ -472,19 +579,60 @@ mod tests {
assert!(fetch_image("data:image/png;base64,").is_none());
}
/// The full accept/exclude table (WP1.2). The exclusions are the load-bearing half: two of the
/// three `/`-leading shapes here are emitted by the host ITSELF, so a POSIX rule that swallowed
/// them would break the proxy round-trip and silently drop CDN art.
#[test]
fn local_art_path_detection() {
// Windows-shaped local paths a provider (Playnite) would store.
assert!(is_local_art_path(r"C:\Users\me\cover.jpg"));
assert!(is_local_art_path("C:/Users/me/cover.png"));
assert!(is_local_art_path(r"\\nas\share\art.jpg"));
// URLs and the host proxy path are NOT local files.
// The `file://` plugin contract, on both platform shapes.
assert!(is_local_art_path("file:///home/u/covers/x.jpg"));
assert!(is_local_art_path("file:///C:/covers/x.jpg"));
// POSIX absolute — lutris covers, steam librarycache.
assert!(is_local_art_path("/home/u/.cache/lutris/coverart/x.jpg"));
assert!(is_local_art_path("/var/lib/steam/librarycache/570/h.jpg"));
// URLs are NOT local files.
assert!(!is_local_art_path("https://cdn/x.jpg"));
assert!(!is_local_art_path("http://host/x.jpg"));
assert!(!is_local_art_path("data:image/png;base64,AAAA"));
// …nor is the host's OWN art-proxy path (it must survive a second `proxy_local_art` pass).
assert!(!is_local_art_path(
"/api/v1/library/art/custom:abc/portrait"
));
assert!(!is_local_art_path("/api/v1/library/art/steam:570/hero"));
// …nor a protocol-relative CDN URL (what GOG / the MS catalog return — see `abs_url`).
assert!(!is_local_art_path("//images.gog.com/abc_vertical.jpg"));
// A relative path is not absolute — nothing to serve.
assert!(!is_local_art_path("covers/x.jpg"));
assert!(!is_local_art_path(""));
}
#[test]
fn file_url_converts_to_a_path_and_percent_decodes() {
assert_eq!(file_url_to_path("file:///home/u/c.jpg"), "/home/u/c.jpg");
// Percent-encoded spaces — what a correct URL encoder emits for a real-world cover path.
assert_eq!(
file_url_to_path("file:///home/u/My%20Games/c%2Bx.jpg"),
"/home/u/My Games/c+x.jpg"
);
// Windows drive letters arrive after the empty authority's slash and lose it.
assert_eq!(
file_url_to_path("file:///C:/covers/c.jpg"),
"C:/covers/c.jpg"
);
// A non-empty authority is a UNC reference.
assert_eq!(
file_url_to_path("file://nas/share/c.jpg"),
r"\\nas\share\c.jpg"
);
// Non-`file://` values are returned untouched (bare paths still work).
assert_eq!(file_url_to_path("/home/u/c.jpg"), "/home/u/c.jpg");
assert_eq!(file_url_to_path(r"C:\c.jpg"), r"C:\c.jpg");
// A lone `%` (a legal path character) is not mangled into a decode failure.
assert_eq!(file_url_to_path("file:///home/100%.jpg"), "/home/100%.jpg");
}
#[test]
@@ -508,6 +656,46 @@ mod tests {
);
}
/// A POSIX local cover — the shape the lutris and steam plugins emit — is classified as local
/// art and rewritten to the proxy path. This is the case G4 blocked (Lutris art was inlined as
/// `data:` URLs and blew the 2 MB body limit at 49 covers).
///
/// Deliberately free of filesystem and env: the READ half is confined, and lives in
/// `local_art_bytes_is_confined_and_image_only` so that only ONE test mutates
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` (cargo runs these in parallel threads of one process, so two
/// would race).
#[test]
fn posix_local_art_is_classified_and_proxied() {
let path = if cfg!(windows) {
r"C:\covers\cover.jpg".to_string()
} else {
"/home/u/.cache/lutris/coverart/cover.jpg".to_string()
};
let mut art = Artwork {
portrait: Some(path.clone()),
hero: Some(format!("file://{path}")),
logo: Some("https://cdn/l.png".into()),
header: None,
};
assert!(is_local_art_path(&path));
proxy_local_art("lutris:42", &mut art);
assert_eq!(
art.portrait.as_deref(),
Some("/api/v1/library/art/lutris:42/portrait")
);
assert_eq!(
art.hero.as_deref(),
Some("/api/v1/library/art/lutris:42/hero"),
"a file:// value is local art too"
);
assert_eq!(art.logo.as_deref(), Some("https://cdn/l.png"));
// Re-running the rewrite is a no-op — the emitted proxy path must not be mistaken for a file.
let before = art.portrait.clone();
proxy_local_art("lutris:42", &mut art);
assert_eq!(art.portrait, before);
}
const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
/// The art proxy reads bytes in the HOST process (LocalSystem on Windows) from a path the
@@ -562,6 +750,37 @@ mod tests {
);
assert!(local_art_bytes(dir.join("nope.png").to_str().unwrap()).is_none());
// A directory is not a servable cover — the proxy must never become a directory reader.
assert!(local_art_bytes(dir.to_str().unwrap()).is_none());
// The `file://` plugin contract reaches the SAME bytes through the SAME gate. This is the
// half that matters for the extracted scanners: they emit `file://` values, so if the
// conversion happened after the confinement check the check would be inspecting a string
// that is not the path being read.
let as_url = format!("file://{}", cover.to_str().unwrap());
assert_eq!(
local_art_bytes(&as_url)
.expect("file:// reads the same cover")
.0,
PNG
);
// …and a `file://` value is confined exactly like a bare one — no bypass by spelling.
assert!(
local_art_bytes(&format!("file://{}", elsewhere.to_str().unwrap())).is_none(),
"file:// must not escape the art roots"
);
// Percent-encoded traversal is decoded BEFORE canonicalization, so it cannot hide from the
// `..` check.
assert!(
local_art_bytes(&format!(
"file://{}/%2e%2e/{}/cover.png",
dir.to_str().unwrap(),
outside.file_name().unwrap().to_str().unwrap()
))
.is_none(),
"percent-encoded traversal must be refused"
);
// A UNC path is refused outright (outbound SMB auth coercion), before any filesystem hit.
assert!(!art_path_is_servable(r"\\attacker\share\a.png"));
+449 -68
View File
@@ -28,6 +28,17 @@ pub struct CustomEntry {
/// host-assigned `id` stays stable across reconciles. Present iff `provider` is.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub external_id: Option<String>,
/// The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.
/// `None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.
///
/// Materialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an
/// entry is self-describing: its id and its `store` badge derive from the entry alone, and stay
/// correct even while the claim map is being rewritten.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub store: Option<String>,
/// Whether this entry is a game or the launcher itself — see [`GameRole`].
#[serde(default, skip_serializing_if = "GameRole::is_game")]
pub role: GameRole,
/// How to recognize this title's process once it is running (design §9) — the one thing a
/// provider knows that the host cannot work out for itself.
///
@@ -53,6 +64,10 @@ pub struct CustomInput {
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
#[serde(default)]
pub prep: Vec<crate::hooks::PrepCmd>,
/// Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher
/// entry is legal (an operator may want a "Steam" tile without installing the steam plugin).
#[serde(default)]
pub role: GameRole,
/// How to recognize this title's process — see [`CustomEntry::detect`].
#[serde(default)]
pub detect: DetectHint,
@@ -76,6 +91,10 @@ pub struct ProviderEntryInput {
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
#[serde(default)]
pub prep: Vec<crate::hooks::PrepCmd>,
/// Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin
/// emits its `launchers(cfg)` entries with `role: "launcher"`.
#[serde(default)]
pub role: GameRole,
/// How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its
/// titles' install directories (Playnite does) should send them: it is what lets a game launched
/// through the provider's own client still end its session when the player quits.
@@ -101,10 +120,13 @@ impl From<CustomEntry> for GameEntry {
.unwrap_or_default()
.or_hint(&c.detect);
GameEntry {
id: format!("custom:{}", c.id),
store: "custom".into(),
id: library_id_for(&c),
// A claimed entry wears its store's badge; everything else is `custom`. `provider` rides
// along either way, so attribution ("synced by the steam plugin") survives the claim.
store: c.store.clone().unwrap_or_else(|| "custom".into()),
title: c.title,
art: c.art,
role: c.role,
launch: c.launch,
provider: c.provider,
detect,
@@ -122,42 +144,123 @@ fn custom_path() -> PathBuf {
pf_paths::config_dir().join("library.json")
}
/// Load the custom entries (empty + non-fatal if the file is absent or malformed).
pub fn load_custom() -> Vec<CustomEntry> {
/// The persisted catalog (`library.json` **v2**): the entries plus the store-claim map (D2).
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Catalog {
#[serde(default)]
pub entries: Vec<CustomEntry>,
/// `store id → provider id`. One provider per store; a second claimant is refused (409).
///
/// The map — not the entries — is the authority for a claim, which is exactly why it survives an
/// **empty reconcile**: a store the plugin legitimately owns can have zero installed titles, and
/// the built-in scanner it suppresses must stay suppressed anyway. Releasing is explicit
/// (`DELETE /library/provider/{p}`, or the plugin claiming a different store).
#[serde(default)]
pub claims: BTreeMap<String, String>,
}
/// What `library.json` may contain on disk. v1 was a bare array of entries; v2 is the [`Catalog`]
/// object. Untagged, so an existing v1 file loads unchanged — and the host always WRITES v2, so the
/// first mutation after an upgrade migrates the file in place with no separate migration step.
#[derive(Deserialize)]
#[serde(untagged)]
enum LibraryFile {
V2(Catalog),
Legacy(Vec<CustomEntry>),
}
/// Load the whole catalog (default + non-fatal if the file is absent or malformed).
pub fn load_catalog() -> Catalog {
match std::fs::read_to_string(custom_path()) {
Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| {
tracing::warn!(error = %e, "library.json malformed — ignoring custom entries");
Vec::new()
}),
Err(_) => Vec::new(),
Ok(raw) => match serde_json::from_str::<LibraryFile>(&raw) {
Ok(LibraryFile::V2(c)) => c,
Ok(LibraryFile::Legacy(entries)) => Catalog {
entries,
claims: BTreeMap::new(),
},
Err(e) => {
tracing::warn!(error = %e, "library.json malformed — ignoring custom entries");
Catalog::default()
}
},
Err(_) => Catalog::default(),
}
}
/// Serve a custom/provider entry's stored **local** art file for one [`ArtKind`] — the non-Steam
/// branch of the art proxy (`GET /library/art/custom:<id>/<kind>`). `id` is the bare custom id (the
/// `custom:` prefix already stripped by the handler). `None` if the entry is unknown, has no art of
/// that kind, or that art value isn't a servable local file (e.g. an `http` URL the client fetches
/// itself). Blocking IO — call off the async runtime.
pub fn custom_local_art_bytes(id: &str, kind: ArtKind) -> Option<(Vec<u8>, String)> {
let entry = load_custom().into_iter().find(|e| e.id == id)?;
let field = match kind {
ArtKind::Portrait => entry.art.portrait,
ArtKind::Hero => entry.art.hero,
ArtKind::Logo => entry.art.logo,
ArtKind::Header => entry.art.header,
}?;
/// Load just the entries — the read path every library surface uses.
pub fn load_custom() -> Vec<CustomEntry> {
load_catalog().entries
}
/// The active store claims (`store → provider`). Read per library scan to suppress the built-in
/// scanner a plugin has taken over (D2).
pub fn claimed_stores() -> BTreeMap<String, String> {
load_catalog().claims
}
/// The library id a stored entry surfaces as. **The single source of truth for the mapping** —
/// [`From<CustomEntry> for GameEntry`] and every id→entry lookup go through it, so the id scheme
/// can't drift between the catalog, the art proxy and the launch resolver.
///
/// A **claimed** entry (D2) gets the deterministic `<store>:<external_id>` its built-in scanner used
/// to produce — `steam:440`, `heroic:legendary:Quail` — so entry ids, GameStream FNV-1a app ids,
/// client art caches and Moonlight pins all survive the migration to a plugin untouched. That is the
/// whole point of the claim: extraction must be invisible to everything downstream. An unclaimed
/// entry keeps the opaque host-assigned `custom:<id>`.
pub(crate) fn library_id_for(e: &CustomEntry) -> String {
match (e.store.as_deref(), e.external_id.as_deref()) {
(Some(store), Some(external)) => format!("{store}:{external}"),
_ => format!("custom:{}", e.id),
}
}
/// The **source id** an entry is toggled by (WP2.6): its claimed store when it has one, else its
/// provider id. `None` for a manual entry — the custom store is not a source and can never be
/// switched off. Since the claimed store id, the provider id and the old scanner id are all the same
/// string by construction, a user's existing disabled state carries over verbatim.
pub(crate) fn source_id_for(e: &CustomEntry) -> Option<&str> {
e.store.as_deref().or(e.provider.as_deref())
}
/// The stored entry a full **library id** refers to, or `None`. The art proxy resolves *any* id this
/// way before falling back to the legacy per-store branches (WP1.2), which is what lets a plugin's
/// entries be served regardless of what their ids look like.
pub fn entry_for_library_id(library_id: &str) -> Option<CustomEntry> {
load_custom()
.into_iter()
.find(|e| library_id_for(e) == library_id)
}
/// Serve a stored entry's **local** art file for one [`ArtKind`] — the `library.json` branch of the
/// art proxy (`GET /library/art/<library id>/<kind>`). `None` if the id names no stored entry, it has
/// no art of that kind, or that art value isn't a servable local file (e.g. an `http` URL the client
/// fetches itself). Blocking IO — call off the async runtime.
pub fn library_local_art_bytes(library_id: &str, kind: ArtKind) -> Option<(Vec<u8>, String)> {
let field = art_field(&entry_for_library_id(library_id)?.art, kind)?;
is_local_art_path(&field)
.then(|| local_art_bytes(&field))
.flatten()
}
fn save_custom(entries: &[CustomEntry]) -> Result<()> {
/// One [`Artwork`] field by kind — the tiny mapping the proxy and the box-art ladder share.
pub(crate) fn art_field(art: &Artwork, kind: ArtKind) -> Option<String> {
match kind {
ArtKind::Portrait => art.portrait.clone(),
ArtKind::Hero => art.hero.clone(),
ArtKind::Logo => art.logo.clone(),
ArtKind::Header => art.header.clone(),
}
}
/// Persist the catalog in the **v2** shape (write-then-rename, restrictive perms). Every mutation
/// path funnels through here, so a v1 file is upgraded by the first write.
fn save_catalog(catalog: &Catalog) -> Result<()> {
let dir = pf_paths::config_dir();
// Owner-private dir (0700 / SYSTEM+Admins DACL) so a non-privileged local user can't plant a
// library.json whose `prep`/`launch` commands the host would later execute — the same trust
// boundary hooks.json and the mgmt token already use.
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
let json = serde_json::to_string_pretty(entries)?;
let json = serde_json::to_string_pretty(catalog)?;
// Write-then-rename so a crash mid-write never truncates the catalog; `write_secret_file` gives
// the temp file its restrictive perms (0600 / SYSTEM+Admins DACL) before the rename carries them
// to the final path.
@@ -177,19 +280,26 @@ fn new_id(title: &str) -> String {
hex::encode(&Sha256::digest(format!("{title}:{nanos}").as_bytes())[..6])
}
/// Outcome of a manual mutation against an id — distinguishes "no such entry" from "exists,
/// but a provider owns it" (the mgmt layer maps the latter to 409, not 404).
/// Outcome of a mutation — distinguishes "no such entry" from the two conflict cases the mgmt
/// layer maps to 409 rather than 404.
pub enum MutateOutcome<T> {
Done(T),
NotFound,
/// The entry belongs to this provider — mutate it through the provider reconcile API
/// (or remove the whole provider set); manual edits would be clobbered at the next sync.
ProviderOwned(String),
/// The requested store claim is already held by a DIFFERENT provider (D2: one provider per
/// store). Refusing is the point — two plugins both emitting `steam:440` would collide on entry
/// ids, so the second claimant is told who holds it instead of silently taking over.
StoreClaimed {
store: String,
provider: String,
},
}
/// Create a custom (manual) entry, returning it with its assigned id.
pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
let mut entries = load_custom();
let mut catalog = load_catalog();
let entry = CustomEntry {
id: new_id(&input.title),
title: input.title,
@@ -198,11 +308,13 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
prep: input.prep,
provider: None,
external_id: None,
store: None,
role: input.role,
detect: input.detect,
meta: input.meta,
};
entries.push(entry.clone());
save_custom(&entries)?;
catalog.entries.push(entry.clone());
save_catalog(&catalog)?;
emit_changed("manual");
Ok(entry)
}
@@ -210,8 +322,8 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
/// Replace a manual entry's fields (id preserved). Provider-owned entries are refused —
/// their state belongs to the provider's reconcile (RFC §8 ownership rule).
pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<CustomEntry>> {
let mut entries = load_custom();
let Some(slot) = entries.iter_mut().find(|e| e.id == id) else {
let mut catalog = load_catalog();
let Some(slot) = catalog.entries.iter_mut().find(|e| e.id == id) else {
return Ok(MutateOutcome::NotFound);
};
if let Some(provider) = &slot.provider {
@@ -221,25 +333,26 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<Custo
slot.art = input.art;
slot.launch = input.launch;
slot.prep = input.prep;
slot.role = input.role;
slot.detect = input.detect;
slot.meta = input.meta;
let updated = slot.clone();
save_custom(&entries)?;
save_catalog(&catalog)?;
emit_changed("manual");
Ok(MutateOutcome::Done(updated))
}
/// Delete a manual entry. Provider-owned entries are refused (see [`update_custom`]).
pub fn delete_custom(id: &str) -> Result<MutateOutcome<()>> {
let mut entries = load_custom();
let Some(entry) = entries.iter().find(|e| e.id == id) else {
let mut catalog = load_catalog();
let Some(entry) = catalog.entries.iter().find(|e| e.id == id) else {
return Ok(MutateOutcome::NotFound);
};
if let Some(provider) = &entry.provider {
return Ok(MutateOutcome::ProviderOwned(provider.clone()));
}
entries.retain(|e| e.id != id);
save_custom(&entries)?;
catalog.entries.retain(|e| e.id != id);
save_catalog(&catalog)?;
emit_changed("manual");
Ok(MutateOutcome::Done(()))
}
@@ -258,7 +371,8 @@ pub fn delete_custom(id: &str) -> Result<MutateOutcome<()>> {
/// 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
/// The other launch kinds (`steam_appid`, `steam_ui`, `launcher_ui`, `epic`, `gog`, `aumid`,
/// `lutris_id`, `heroic`) are all
/// host-resolved from a validated id and stay open to every lane — a provider plugin can still
/// publish its whole catalogue, it just cannot hand the host a shell command to run.
pub fn privileged_field(
@@ -293,6 +407,26 @@ pub fn validate_provider_name(provider: &str) -> Result<(), String> {
}
}
/// Store claims become the **prefix of every claimed entry's library id**, so they are far more
/// constrained than a provider name: no dots (an id is split on the first `:`, and a dotted store
/// would read as a hostname in logs), and the two host-owned namespaces are off-limits — `custom` is
/// the unclaimed-entry namespace and `manual` is the no-provider sentinel in `library.changed`.
pub fn validate_store_claim(store: &str) -> Result<(), String> {
if store == "custom" || store == "manual" {
return Err(format!("store id `{store}` is reserved"));
}
let ok = !store.is_empty()
&& store.len() <= 32
&& store
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_'));
if ok {
Ok(())
} else {
Err("store id must be 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> {
@@ -310,6 +444,40 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
e.external_id
));
}
// Closed-vocabulary launch kinds are checked on the way IN as well as at launch time, so a
// plugin gets a 400 it can act on rather than a tile that silently refuses to start.
if let Some(launch) = &e.launch {
if launch.kind == "steam_ui" && !valid_steam_ui(&launch.value) {
return Err(format!(
"entries[{i}]: `launch.value` for kind `steam_ui` must be `bigpicture` or `desktop`"
));
}
// Refused rather than silently accepted, because the failure is otherwise invisible
// until a user clicks the tile: an unresolvable value yields no command at launch time.
if launch.kind == "launcher_ui" && !valid_launcher_ui(&launch.value) {
return Err(format!(
"entries[{i}]: `launch.value` for kind `launcher_ui` names a launcher this host \
cannot open (`{}`)",
launch.value
));
}
}
if let Some(marker) = &e.detect.env_marker {
if !valid_env_key(&marker.key) {
return Err(format!(
"entries[{i}]: `detect.env_marker.key` must be 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(())
}
@@ -321,6 +489,7 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
fn reconcile_entries(
entries: &mut Vec<CustomEntry>,
provider: &str,
store: Option<&str>,
inputs: Vec<ProviderEntryInput>,
) -> Vec<CustomEntry> {
// The provider's current entries, keyed by its own stable id.
@@ -345,6 +514,10 @@ fn reconcile_entries(
prep: input.prep,
provider: Some(provider.to_string()),
external_id: Some(input.external_id),
// Stamping the claim per entry is what makes the surfaced id deterministic
// (`<store>:<external_id>`) — see `library_id_for`.
store: store.map(str::to_string),
role: input.role,
detect: input.detect,
meta: input.meta,
});
@@ -354,43 +527,86 @@ fn reconcile_entries(
result
}
/// Atomically replace `provider`'s entry set (RFC §8: `PUT /library/provider/{provider}`).
/// The caller validates the name and payload first. Emits `library.changed` with the provider
/// as the source.
/// Atomically replace `provider`'s entry set (RFC §8: `PUT /library/provider/{provider}`), optionally
/// under a **store claim** (D2: `?store=steam`). The caller validates the name and payload first.
/// Emits `library.changed` with the provider as the source.
///
/// Claiming is idempotent for the holder and refused for anyone else. A provider holds at most one
/// store, so claiming a new one releases whatever it held before — otherwise an abandoned claim would
/// go on suppressing a built-in scanner with nothing to replace it.
pub fn reconcile_provider(
provider: &str,
store: Option<&str>,
inputs: Vec<ProviderEntryInput>,
) -> Result<Vec<CustomEntry>> {
let mut entries = load_custom();
let result = reconcile_entries(&mut entries, provider, inputs);
save_custom(&entries)?;
) -> Result<MutateOutcome<Vec<CustomEntry>>> {
let mut catalog = load_catalog();
if let Some(store) = store {
if let Some(holder) = catalog.claims.get(store) {
if holder != provider {
return Ok(MutateOutcome::StoreClaimed {
store: store.to_string(),
provider: holder.clone(),
});
}
}
let previous: Vec<String> = catalog
.claims
.iter()
.filter(|(s, p)| p.as_str() == provider && s.as_str() != store)
.map(|(s, _)| s.clone())
.collect();
for stale in previous {
tracing::info!(provider, released = %stale, claimed = store, "library: provider moved its store claim");
catalog.claims.remove(&stale);
}
if catalog
.claims
.insert(store.to_string(), provider.to_string())
.is_none()
{
tracing::info!(provider, store, "library: store claimed by a provider");
}
}
let result = reconcile_entries(&mut catalog.entries, provider, store, inputs);
save_catalog(&catalog)?;
emit_changed(provider);
Ok(result)
Ok(MutateOutcome::Done(result))
}
/// Remove every entry of `provider` (RFC §8: `DELETE /library/provider/{provider}` — the
/// clean-uninstall path). Returns how many were removed; no event when nothing was.
/// Remove every entry of `provider` **and release its store claim** (RFC §8:
/// `DELETE /library/provider/{provider}` — the clean-uninstall path). Returns how many entries were
/// removed; no event when nothing changed at all.
///
/// Releasing here — and only here — is what makes uninstalling a library plugin bring its built-in
/// scanner straight back, with no restart and nothing to undo by hand.
pub fn delete_provider(provider: &str) -> Result<usize> {
let mut entries = load_custom();
let before = entries.len();
entries.retain(|e| e.provider.as_deref() != Some(provider));
let removed = before - entries.len();
if removed > 0 {
save_custom(&entries)?;
let mut catalog = load_catalog();
let before = catalog.entries.len();
catalog
.entries
.retain(|e| e.provider.as_deref() != Some(provider));
let removed = before - catalog.entries.len();
let claims_before = catalog.claims.len();
catalog.claims.retain(|_, p| p != provider);
let released = claims_before - catalog.claims.len();
if removed > 0 || released > 0 {
if released > 0 {
tracing::info!(provider, released, "library: store claim released");
}
save_catalog(&catalog)?;
emit_changed(provider);
}
Ok(removed)
}
/// The prep/undo steps for a library id — `custom:<id>` entries only (the other stores have no
/// The prep/undo steps for a library id — any **stored** entry (the in-host scanners have no
/// per-title config surface; a GameStream `apps.json` entry carries its own `prep` instead).
///
/// Resolved through [`entry_for_library_id`] rather than by stripping a `custom:` prefix, so a
/// claimed entry's prep still runs: after extraction a `steam:440` entry is a stored one, and
/// per-title prep is exactly the kind of thing an operator sets on a game they play.
pub fn prep_for(library_id: &str) -> Vec<crate::hooks::PrepCmd> {
let Some(id) = library_id.strip_prefix("custom:") else {
return Vec::new();
};
load_custom()
.into_iter()
.find(|e| e.id == id)
entry_for_library_id(library_id)
.map(|e| e.prep)
.unwrap_or_default()
}
@@ -403,13 +619,7 @@ fn emit_changed(source: &str) {
});
}
/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it
/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax).
/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping
/// ([`windows_launch_for`]).
pub(crate) fn valid_steam_appid(value: &str) -> bool {
!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
}
// `valid_steam_appid` moved to `launch.rs` (WP1.1) — it validates a launch value, not a store entry.
#[cfg(test)]
mod tests {
@@ -424,6 +634,8 @@ mod tests {
prep: Vec::new(),
provider: None,
external_id: None,
store: None,
role: GameRole::Game,
detect: DetectHint::default(),
meta: GameMeta::default(),
}
@@ -436,6 +648,7 @@ mod tests {
art: Artwork::default(),
launch: None,
prep: Vec::new(),
role: GameRole::Game,
detect: DetectHint::default(),
meta: GameMeta::default(),
}
@@ -457,6 +670,79 @@ mod tests {
assert_eq!(g.meta.platform.as_deref(), Some("PS2"));
}
/// D2's core promise: a **claimed** entry is indistinguishable from what the built-in scanner
/// produced. Same id, same store badge — plus the provider attribution the scanner never had.
#[test]
fn a_claimed_entry_reproduces_the_scanner_identity() {
let mut e = manual("host-assigned", "Portal 2");
e.provider = Some("steam".into());
e.external_id = Some("620".into());
e.store = Some("steam".into());
assert_eq!(library_id_for(&e), "steam:620");
let g: GameEntry = e.clone().into();
assert_eq!(g.id, "steam:620", "exactly what the scanner emitted");
assert_eq!(g.store, "steam", "the store badge, not `custom`");
assert_eq!(
g.provider.as_deref(),
Some("steam"),
"attribution rides along too"
);
// Unclaimed provider entries are untouched by any of this — rom-manager/playnite keep the
// opaque host id they have always had.
let mut u = manual("abc", "Chrono Trigger");
u.provider = Some("romm".into());
u.external_id = Some("rom-1".into());
assert_eq!(library_id_for(&u), "custom:abc");
assert_eq!(GameEntry::from(u).store, "custom");
// The source a toggle addresses: the claimed store when there is one, else the provider.
assert_eq!(source_id_for(&e), Some("steam"));
let mut r = manual("z", "T");
r.provider = Some("romm".into());
assert_eq!(source_id_for(&r), Some("romm"));
assert_eq!(
source_id_for(&manual("m", "Manual")),
None,
"never hideable"
);
}
/// A claimed entry keeps its `<store>:<external_id>` id across reconciles no matter what the
/// host-assigned id does — which is what keeps GameStream's FNV-1a app ids, client art caches
/// and Moonlight pins valid through the migration (the whole point of D2).
#[test]
fn claimed_ids_are_deterministic_across_reconciles() {
let mut entries = Vec::new();
let r1 = reconcile_entries(
&mut entries,
"steam",
Some("steam"),
vec![input("440", "Team Fortress 2"), input("620", "Portal 2")],
);
let ids: Vec<String> = r1.iter().map(library_id_for).collect();
assert_eq!(ids, ["steam:440", "steam:620"]);
// Re-sync with a renamed title and a new entry: the surfaced ids for surviving titles are
// byte-identical, and a brand-new title's id is derived, not random.
let r2 = reconcile_entries(
&mut entries,
"steam",
Some("steam"),
vec![
input("440", "Team Fortress 2 (2026)"),
input("70", "Half-Life"),
],
);
let ids2: Vec<String> = r2.iter().map(library_id_for).collect();
assert_eq!(ids2, ["steam:440", "steam:70"]);
// Dropping the claim on a later reconcile reverts them to opaque custom ids — the entries
// are the same rows, so this is exactly the "plugin stopped claiming" degradation.
let r3 = reconcile_entries(&mut entries, "steam", None, vec![input("440", "TF2")]);
assert!(library_id_for(&r3[0]).starts_with("custom:"));
}
/// The metadata contract on the wire and on disk: fields serialize FLAT (no `meta` nesting —
/// clients and plugins see `platform` beside `title`), absent fields vanish entirely, and a
/// pre-metadata `library.json` / payload still parses (all-optional).
@@ -505,6 +791,7 @@ mod tests {
let r1 = reconcile_entries(
&mut entries,
"romm",
None,
vec![input("rom-a", "Game A"), input("rom-b", "Game B")],
);
assert_eq!(r1.len(), 2);
@@ -516,6 +803,7 @@ mod tests {
let r2 = reconcile_entries(
&mut entries,
"romm",
None,
vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")],
);
assert_eq!(r2.len(), 2);
@@ -534,6 +822,7 @@ mod tests {
let r3 = reconcile_entries(
&mut entries,
"romm",
None,
vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")],
);
assert_eq!(
@@ -554,7 +843,7 @@ mod tests {
.any(|e| e.id == "oth1" && e.provider.as_deref() == Some("itch")));
// Empty payload = remove everything the provider owns (same as DELETE).
let r4 = reconcile_entries(&mut entries, "romm", Vec::new());
let r4 = reconcile_entries(&mut entries, "romm", None, Vec::new());
assert!(r4.is_empty());
assert_eq!(
entries.len(),
@@ -563,6 +852,98 @@ mod tests {
);
}
/// `library.json` v1 (a bare array) must keep loading, and v2 (the claims object) must round
/// trip. This is the only migration in the whole program — get it wrong and an existing host
/// silently loses its manual entries on upgrade.
#[test]
fn v1_and_v2_library_files_both_load() {
// v1: exactly what a shipped host has on disk today.
let v1 = r#"[{"id":"abc","title":"Old Manual"}]"#;
let c = match serde_json::from_str::<LibraryFile>(v1).unwrap() {
LibraryFile::Legacy(entries) => Catalog {
entries,
claims: BTreeMap::new(),
},
LibraryFile::V2(_) => panic!("an array must not parse as v2"),
};
assert_eq!(c.entries.len(), 1);
assert_eq!(c.entries[0].title, "Old Manual");
assert!(c.claims.is_empty());
// v2, including a claim.
let v2 = r#"{"entries":[{"id":"abc","title":"New"}],"claims":{"steam":"steam"}}"#;
let c = match serde_json::from_str::<LibraryFile>(v2).unwrap() {
LibraryFile::V2(c) => c,
LibraryFile::Legacy(_) => panic!("an object must not parse as v1"),
};
assert_eq!(c.entries.len(), 1);
assert_eq!(c.claims.get("steam").map(String::as_str), Some("steam"));
// A v2 file with no claims key at all (what the first write after upgrade produces before
// anything is claimed) still loads.
let bare = r#"{"entries":[]}"#;
assert!(matches!(
serde_json::from_str::<LibraryFile>(bare).unwrap(),
LibraryFile::V2(_)
));
// And what we WRITE is v2, so one mutation upgrades the file in place.
let written = serde_json::to_string(&Catalog::default()).unwrap();
assert!(written.contains("\"entries\""));
assert!(written.contains("\"claims\""));
}
#[test]
fn store_claim_validation() {
assert!(validate_store_claim("steam").is_ok());
assert!(validate_store_claim("epic-games").is_ok());
assert!(validate_store_claim("xbox_pc").is_ok());
// The two host-owned namespaces are off-limits.
assert!(validate_store_claim("custom").is_err());
assert!(validate_store_claim("manual").is_err());
assert!(validate_store_claim("").is_err());
assert!(validate_store_claim("Steam").is_err()); // no uppercase
// A dot would read as a hostname in a log line and muddies the `store:id` split.
assert!(validate_store_claim("my.store").is_err());
assert!(validate_store_claim(&"s".repeat(33)).is_err());
}
/// The closed-vocabulary fields are rejected at the door, so a plugin gets a 400 rather than a
/// tile that silently refuses to launch.
#[test]
fn payload_validation_covers_the_new_closed_vocabularies() {
let with_launch = |kind: &str, value: &str| {
let mut i = input("a", "A");
i.launch = Some(LaunchSpec {
kind: kind.into(),
value: value.into(),
});
i
};
assert!(validate_provider_payload(&[with_launch("steam_ui", "bigpicture")]).is_ok());
assert!(validate_provider_payload(&[with_launch("steam_ui", "desktop")]).is_ok());
assert!(validate_provider_payload(&[with_launch("steam_ui", "gamepad")]).is_err());
assert!(validate_provider_payload(&[with_launch("steam_ui", "")]).is_err());
// Other kinds are unconstrained here (the host validates them per-kind at launch).
assert!(validate_provider_payload(&[with_launch("command", "anything")]).is_ok());
let with_env = |key: &str, value: Option<&str>| {
let mut i = input("a", "A");
i.detect.env_marker = Some(EnvMarker {
key: key.into(),
value: value.map(str::to_string),
});
i
};
assert!(validate_provider_payload(&[with_env("HEROIC_APP_NAME", Some("Quail"))]).is_ok());
assert!(validate_provider_payload(&[with_env("BAD-KEY", None)]).is_err());
assert!(validate_provider_payload(&[with_env("", None)]).is_err());
assert!(
validate_provider_payload(&[with_env("K", Some(&"x".repeat(MAX_ENV_VALUE + 1)))])
.is_err()
);
}
/// The field-authority rule behind the 2026-08-05 review's H-1: exactly the two fields the host
/// later hands to a shell are operator-only. Everything else — including every host-resolved
/// launch kind — stays open, so a provider plugin can publish its whole catalogue.
+119 -6
View File
@@ -19,15 +19,34 @@
use super::*;
/// An environment variable a launcher stamps onto the game's process, identifying it.
#[derive(Clone, Debug, PartialEq, Eq)]
///
/// Serializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin
/// that knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to
/// be able to say so, since after extraction the host no longer reads that launcher's files itself.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct EnvMarker {
/// The variable name (e.g. `HEROIC_GAME_ID`).
#[schema(example = "HEROIC_APP_NAME")]
pub key: String,
/// The exact value to require, when the launcher's value identifies *this* title. `None` matches
/// the key's mere presence — only safe for launchers that run one game at a time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
}
/// The env-var name charset a hint may carry: `[A-Za-z0-9_]{1,64}`, POSIX-shaped. An out-of-charset
/// key is not a real environment variable, so accepting one could only ever produce a matcher rule
/// that never fires (or, with an absurd length, a needless per-process comparison cost).
pub(crate) fn valid_env_key(key: &str) -> bool {
!key.is_empty()
&& key.len() <= 64
&& key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
}
/// Longest env-var VALUE a hint may pin. Values are compared against every candidate process's
/// environment, so an unbounded one is a (small) DoS lever and never a legitimate game id.
pub(crate) const MAX_ENV_VALUE: usize = 256;
/// The signals that identify a launched title's process(es). Every field is optional and
/// independent; an all-`None` spec means "this title can't be tracked" (the lease degrades to
/// [`crate::gamelease::LeaseKind::Untracked`] and both lifetime behaviors stay inert for it).
@@ -115,6 +134,11 @@ impl DetectSpec {
self.install_dir = self.install_dir.or(from.install_dir);
self.exe = self.exe.or(from.exe);
self.process_name = self.process_name.or(from.process_name);
// D3: the two store-derived signals are fillable from a hint now that the store may live in
// a plugin. Same rule as the other three — the host's own finding wins where it has one,
// which for a provider entry is moot (the host scanned nothing for it).
self.steam_appid = self.steam_appid.or(from.steam_appid);
self.env_marker = self.env_marker.or(from.env_marker);
self
}
}
@@ -143,12 +167,31 @@ pub struct DetectHint {
/// — see [`DetectSpec::process_name`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub process_name: Option<String>,
/// The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest**
/// signal that exists — Steam wraps every launch, native or Proton, in
/// `reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's — so without it a
/// steam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub steam_appid: Option<u32>,
/// A launcher-stamped environment marker (D3) — see [`EnvMarker`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env_marker: Option<EnvMarker>,
}
impl DetectHint {
/// Whether the hint says anything at all (all-empty is treated as absent).
pub fn is_empty(&self) -> bool {
self.trimmed().is_none()
self.trimmed().is_none() && self.steam_appid.is_none() && self.env_marker().is_none()
}
/// The env marker, if it is well-formed. A malformed one is dropped rather than rejected, for
/// the same reason a blank `install_dir` is: hint fields are hand-writable plugin input, and the
/// matcher must never be handed a rule it can't honour.
fn env_marker(&self) -> Option<&EnvMarker> {
self.env_marker
.as_ref()
.filter(|m| valid_env_key(&m.key))
.filter(|m| m.value.as_ref().is_none_or(|v| v.len() <= MAX_ENV_VALUE))
}
/// The hint with blank fields dropped, or `None` if nothing is left. Console text inputs and
@@ -166,14 +209,13 @@ impl DetectHint {
/// A provider's hint becomes a spec — the one inbound path into [`DetectSpec`].
impl From<&DetectHint> for DetectSpec {
fn from(h: &DetectHint) -> Self {
let Some((install_dir, exe, process_name)) = h.trimmed() else {
return Self::default();
};
let (install_dir, exe, process_name) = h.trimmed().unwrap_or((None, None, None));
Self {
install_dir: install_dir.map(PathBuf::from),
exe: exe.map(PathBuf::from),
process_name: process_name.map(str::to_string),
..Default::default()
steam_appid: h.steam_appid,
env_marker: h.env_marker().cloned(),
}
}
}
@@ -273,6 +315,7 @@ mod tests {
install_dir: Some("".into()),
exe: Some(" ".into()),
process_name: Some("\t".into()),
..Default::default()
};
assert!(blank.is_empty());
assert!(DetectSpec::from(&blank).is_empty(), "nothing to match on");
@@ -281,6 +324,7 @@ mod tests {
install_dir: Some(" /games/quail ".into()),
exe: None,
process_name: Some("quail".into()),
..Default::default()
};
assert!(!hint.is_empty());
let spec = DetectSpec::from(&hint);
@@ -299,6 +343,7 @@ mod tests {
install_dir: Some("/games/wrong".into()),
exe: Some("/games/real/run".into()),
process_name: None,
..Default::default()
};
let merged = found.or_hint(&hint);
assert_eq!(
@@ -317,6 +362,74 @@ mod tests {
.is_empty());
}
/// D3: the two store-derived signals now ride the hint, because after extraction the host no
/// longer reads Steam's or Heroic's files itself. Without them a plugin's lease tracking would
/// silently degrade — reaper-exact to dir-prefix on Linux Steam, and gone entirely for Heroic
/// under Proton, where the env marker is the only thing that works.
#[test]
fn a_hint_can_carry_the_store_derived_signals() {
let hint = DetectHint {
steam_appid: Some(440),
env_marker: Some(EnvMarker {
key: "HEROIC_APP_NAME".into(),
value: Some("Quail".into()),
}),
..Default::default()
};
assert!(!hint.is_empty(), "either field alone is a real hint");
let spec = DetectSpec::from(&hint);
assert_eq!(spec.steam_appid, Some(440));
assert_eq!(spec.env_marker.as_ref().unwrap().key, "HEROIC_APP_NAME");
// A steam_appid on its own is enough to be trackable.
let only_appid = DetectHint {
steam_appid: Some(620),
..Default::default()
};
assert!(!only_appid.is_empty());
assert!(!DetectSpec::from(&only_appid).is_empty());
// The host's own finding still wins where it has one (unchanged rule).
let found = DetectSpec::steam(70);
assert_eq!(found.or_hint(&hint).steam_appid, Some(70));
// …but a field the host had nothing for is filled in.
assert_eq!(
DetectSpec::dir("/games/x")
.or_hint(&hint)
.env_marker
.unwrap()
.key,
"HEROIC_APP_NAME"
);
}
/// A malformed marker is DROPPED, not honoured — same posture as a blank `install_dir`. The
/// matcher must never be handed a rule it cannot evaluate, and these values reach a code path
/// that can end processes.
#[test]
fn a_malformed_env_marker_says_nothing() {
let bad = |key: &str, value: Option<String>| DetectHint {
env_marker: Some(EnvMarker {
key: key.into(),
value,
}),
..Default::default()
};
assert!(bad("", None).is_empty());
assert!(bad("HAS-DASH", None).is_empty(), "not a POSIX env name");
assert!(bad("HAS SPACE", None).is_empty());
assert!(bad(&"K".repeat(65), None).is_empty(), "over the key cap");
assert!(
bad("K", Some("v".repeat(MAX_ENV_VALUE + 1))).is_empty(),
"over the value cap"
);
// …and a well-formed one at exactly the caps is kept.
assert!(!bad(&"K".repeat(64), Some("v".repeat(MAX_ENV_VALUE))).is_empty());
assert!(DetectSpec::from(&bad("HAS-DASH", None))
.env_marker
.is_none());
}
#[test]
fn first_token_handles_quotes_and_spaces() {
assert_eq!(
+3 -34
View File
@@ -100,6 +100,7 @@ fn epic_entry(
};
Some(GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id: format!("epic:{app_name}"),
store: "epic".into(),
@@ -186,25 +187,8 @@ fn epic_art_index(catcache: &Path) -> std::collections::HashMap<String, Artwork>
map
}
/// Build the `com.epicgames.launcher://` launch URI from a stored launch value — the triple
/// `<namespace>:<catalogItemId>:<appName>` (colons URL-encoded), or a bare `<appName>` fallback.
/// Each part is charset-validated (host-derived, but belt-and-suspenders) so no shell/URI injection.
#[cfg(windows)]
pub(crate) fn epic_launch_uri(value: &str) -> Option<String> {
let ok = |s: &str| {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
};
let inner = match value.split(':').collect::<Vec<_>>().as_slice() {
[ns, cat, app] if ok(ns) && ok(cat) && ok(app) => format!("{ns}%3A{cat}%3A{app}"),
[app] if ok(app) => (*app).to_string(),
_ => return None,
};
Some(format!(
"com.epicgames.launcher://apps/{inner}?action=launch&silent=true"
))
}
// The `epic` launch mapping (`epic_launch_uri`) lives in `launch.rs` (WP1.1) — this module
// enumerates, it does not launch.
#[cfg(test)]
mod tests {
@@ -236,19 +220,4 @@ mod tests {
assert!(epic_entry(&gone, &empty).is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(windows)]
#[test]
fn epic_launch_uri_triple_bare_and_guard() {
assert_eq!(
epic_launch_uri("fn:abc:Fortnite").as_deref(),
Some("com.epicgames.launcher://apps/fn%3Aabc%3AFortnite?action=launch&silent=true")
);
assert_eq!(
epic_launch_uri("Fortnite").as_deref(),
Some("com.epicgames.launcher://apps/Fortnite?action=launch&silent=true")
);
assert!(epic_launch_uri("bad part:x:y").is_none()); // a space → rejected
assert!(epic_launch_uri("").is_none());
}
}
+3 -27
View File
@@ -57,6 +57,7 @@ fn gog_games() -> Vec<GameEntry> {
let detect = DetectSpec::exe(&exe).with_dir(&path);
out.push(GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id,
store: "gog".into(),
@@ -133,38 +134,13 @@ fn gog_play_task(install: &str, id: &str) -> Option<(String, String, String)> {
))
}
/// Build the spawn `(command line, working dir)` for a `gog` launch value (`exe \t args \t workdir`,
/// all host-resolved from the operator's own disk). Direct exe — no shell, no Galaxy.
#[cfg(windows)]
pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
let mut parts = value.split('\t');
let exe = parts.next().filter(|s| !s.is_empty())?;
let args = parts.next().unwrap_or("");
let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from);
let cmdline = if args.trim().is_empty() {
format!("\"{exe}\"")
} else {
format!("\"{exe}\" {args}")
};
Some((cmdline, workdir))
}
// The `gog` launch mapping (`gog_spawn`) lives in `launch.rs` (WP1.1) — this module enumerates and
// resolves the spawn triple off disk, but turning that triple into a command line is launch-side.
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn gog_spawn_parses_and_guards() {
let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap();
assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip");
assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3")));
let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap();
assert_eq!(cmd2, "\"C:\\g.exe\"");
assert!(wd2.is_none());
assert!(gog_spawn("").is_none());
}
#[cfg(windows)]
#[test]
fn gog_play_task_picks_primary_filetask() {
+3 -42
View File
@@ -109,6 +109,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
};
games.push(GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id: format!("heroic:{runner}:{app_name}"),
store: "heroic".into(),
@@ -128,48 +129,8 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
Ok(games)
}
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY
/// running on the box, the spawned process forwards the URI and exits, which would tear the session
/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic.
#[cfg(target_os = "linux")]
pub(crate) fn heroic_command(value: &str) -> Option<String> {
let (runner, app) = value.split_once(':')?;
if !matches!(runner, "legendary" | "gog" | "nile") {
return None;
}
// appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token.
if app.is_empty()
|| !app
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
{
return None;
}
let prefix = heroic_launch_prefix()?;
// No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName
// is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed).
Some(format!(
"{prefix} --no-gui heroic://launch?appName={app}&runner={runner}"
))
}
/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data
/// root is present. `None` ⇒ Heroic not found, so no launch command.
#[cfg(target_os = "linux")]
fn heroic_launch_prefix() -> Option<String> {
let on_path = std::env::var_os("PATH")
.is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file()));
if on_path {
return Some("heroic".into());
}
let flatpak = std::env::var_os("HOME")
.map(PathBuf::from)
.is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir());
flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into())
}
// The `heroic` launch mapping (`heroic_command` + its launcher-prefix probe) lives in `launch.rs`
// (WP1.1) — this module enumerates, it does not launch.
#[cfg(test)]
mod tests {
+326 -5
View File
@@ -1,12 +1,14 @@
//! Title launch: resolve a library id / raw command into an executable command line (per-store +
//! per-OS), and the gamescope-session launch helpers. Split out of the `library` facade (plan §W5).
//!
//! This module owns the **whole launch side** of the library: the `kind` vocabulary, its per-kind
//! charset validators, and the per-OS resolvers. That split is deliberate and load-bearing — the
//! scanner modules beside it do *enumeration only*, so they can be lifted out into library plugins
//! without taking any launch logic with them (design/library-scanner-plugins.md D1: a client sends
//! only an entry id and the host resolves the [`LaunchSpec`] it holds, which stays true whether the
//! entry was enumerated in-process or reconciled in by a plugin).
use super::custom::valid_steam_appid;
#[cfg(target_os = "linux")]
use super::heroic::heroic_command;
use super::*;
#[cfg(windows)]
use super::{epic::epic_launch_uri, gog::gog_spawn};
/// Everything a session needs about the title it is launching, resolved in **one** library scan:
/// what to run, what to call it, and how to recognize it once it is running.
@@ -84,6 +86,25 @@ fn command_for(spec: &LaunchSpec) -> Option<String> {
// Heroic: `<runner>:<appName>` → the validated heroic://launch command (see heroic_command).
#[cfg(target_os = "linux")]
"heroic" => heroic_command(&spec.value),
// A launcher entry (D4): open the Steam client itself, in Big Picture or on the desktop.
// Nested in gamescope this is the SteamOS game-mode shape.
"steam_ui" => match spec.value.as_str() {
"bigpicture" => Some("steam -gamepadui".into()),
"desktop" => Some("steam".into()),
_ => None,
},
// The other launchers' own UIs (D4). The host builds the command — a plugin only names
// which launcher — so no shell string ever crosses the wire.
#[cfg(target_os = "linux")]
"launcher_ui" => match spec.value.as_str() {
// The same resolution the `heroic` game launches use (native binary, else Flatpak), just
// without `--no-gui` and without a URI: that opens Heroic's window, which IS the tile.
"heroic" => heroic_launch_prefix(),
// Bare `lutris` opens the Lutris window; with a `lutris:rungameid/…` URI it launches a
// game instead (the `lutris_id` kind above).
"lutris" => Some("lutris".into()),
_ => None,
},
// Trusted: the command comes from the host's own custom store, never the client.
"command" => (!spec.value.trim().is_empty()).then(|| spec.value.clone()),
_ => None,
@@ -138,6 +159,21 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::Pa
};
Some((cmdline, None))
}
// A launcher entry (D4): open the Steam client's own UI. Same Steam.exe-then-explorer ladder
// as `steam_appid`, and the URI is one of exactly two host-owned literals — nothing from the
// entry is interpolated at all.
"steam_ui" => {
let uri = match spec.value.as_str() {
"bigpicture" => "steam://open/bigpicture",
"desktop" => "steam://open/main",
_ => return None,
};
let cmdline = match steam_exe() {
Some(exe) => format!("\"{}\" \"{uri}\"", exe.display()),
None => format!("explorer.exe \"{uri}\""),
};
Some((cmdline, None))
}
// Epic: open the (host-built, validated) com.epicgames.launcher:// URI via explorer.exe — a
// concrete EXE that resolves the registered protocol handler as the user; the URI is a single
// argv element (no shell, no cmd /c). Same pattern as the steam explorer fallback.
@@ -191,6 +227,152 @@ fn steam_exe() -> Option<std::path::PathBuf> {
None
}
// ------------------------------------------------------- per-kind launch values (host-owned ABI)
//
// Each helper below turns a store's launch VALUE — the only part a scanner (or, after extraction, a
// library plugin) supplies — into the URI/command line the host actually runs. They live here rather
// than beside the enumeration that produces the value because the host keeps owning URI construction
// and spawning no matter where the enumeration came from (D1). Every one of them is total and
// validating: an unparseable or hostile value yields `None`, never a partially-interpolated command.
/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it
/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax).
/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping
/// ([`windows_launch_for`]).
///
/// Also accepts the 64-bit non-Steam-shortcut game id ([`shortcut_gameid`]), which is likewise
/// digits — the two share the `steam_appid` kind precisely because `rungameid` takes either.
pub(crate) fn valid_steam_appid(value: &str) -> bool {
!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
}
/// The 64-bit game id `steam://rungameid/` needs to launch a non-Steam shortcut: high dword = the
/// 32-bit shortcut appid, low dword = the shortcut marker `0x0200_0000`. (Handing `rungameid` the
/// bare 32-bit appid does not launch a shortcut — it must be this composed id.)
pub(crate) fn shortcut_gameid(appid: u32) -> u64 {
((appid as u64) << 32) | 0x0200_0000
}
/// The `steam_ui` launch values (D4) — which Steam UI a launcher entry opens. A closed two-value
/// enum, validated on the way IN (the reconcile payload) as well as on the way out, so an entry can
/// never carry a third value that silently resolves to nothing at launch time.
pub(crate) fn valid_steam_ui(value: &str) -> bool {
matches!(value, "bigpicture" | "desktop")
}
/// The launcher UIs **this host** can open, as `launcher_ui` values (D4).
///
/// One kind for every launcher but Steam, rather than one kind each: they all have exactly a single
/// UI to open, so the value is just which launcher. Steam keeps its own [`valid_steam_ui`] kind
/// because it has two (Big Picture and the desktop client), which is a genuinely different choice.
///
/// Platform-gated, because a value naming a launcher this OS cannot run is not a tile that merely
/// looks odd — it is one that fails at launch. Validated inbound too, so a plugin gets a 400 it can
/// act on instead of publishing a dead entry.
///
/// **Why a typed kind at all**, when design D4 originally said non-Steam launchers would ride the
/// `command` kind: the 2026-08-05 review made `launch.kind = "command"` operator-only (it is handed
/// to a shell), so a plugin publishing one is refused. A typed kind keeps D1's rule intact — the
/// plugin supplies a validated *value*, the host builds the command — and is the only way a scanner
/// plugin can offer a launcher tile at all.
fn launcher_ui_stores() -> &'static [&'static str] {
#[cfg(target_os = "linux")]
{
&["heroic", "lutris"]
}
// Windows launchers (Epic, GOG Galaxy, the Xbox app) are not wired yet — each needs its own
// verified activation, and an unverified guess would ship a tile that does nothing.
#[cfg(not(target_os = "linux"))]
{
&[]
}
}
/// Is this a `launcher_ui` value this host can resolve?
pub(crate) fn valid_launcher_ui(value: &str) -> bool {
launcher_ui_stores().contains(&value)
}
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY
/// running on the box, the spawned process forwards the URI and exits, which would tear the session
/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic.
#[cfg(target_os = "linux")]
pub(crate) fn heroic_command(value: &str) -> Option<String> {
let (runner, app) = value.split_once(':')?;
if !matches!(runner, "legendary" | "gog" | "nile") {
return None;
}
// appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token.
if app.is_empty()
|| !app
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
{
return None;
}
let prefix = heroic_launch_prefix()?;
// No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName
// is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed).
Some(format!(
"{prefix} --no-gui heroic://launch?appName={app}&runner={runner}"
))
}
/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data
/// root is present. `None` ⇒ Heroic not found, so no launch command.
#[cfg(target_os = "linux")]
fn heroic_launch_prefix() -> Option<String> {
let on_path = std::env::var_os("PATH")
.is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file()));
if on_path {
return Some("heroic".into());
}
let flatpak = std::env::var_os("HOME")
.map(PathBuf::from)
.is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir());
flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into())
}
/// Map an `epic` LaunchSpec value to the Epic Games Launcher URI. The value is either the full
/// `<namespace>:<catalogItemId>:<appName>` triple (what the manifests carry) or a bare `appName`;
/// every part is charset-checked so the URI stays one safe argv token.
#[cfg(windows)]
pub(crate) fn epic_launch_uri(value: &str) -> Option<String> {
let ok = |s: &str| {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
};
let inner = match value.split(':').collect::<Vec<_>>().as_slice() {
[ns, cat, app] if ok(ns) && ok(cat) && ok(app) => format!("{ns}%3A{cat}%3A{app}"),
[app] if ok(app) => (*app).to_string(),
_ => return None,
};
Some(format!(
"com.epicgames.launcher://apps/{inner}?action=launch&silent=true"
))
}
/// Map a `gog` LaunchSpec value — the tab-separated `exe \t args \t workdir` spawn triple the scanner
/// derived from `goggame-<id>.info` — to a `(command line, working dir)`. GOG games are spawned
/// directly (no Galaxy), so the exe is quoted and the arguments ride verbatim.
#[cfg(windows)]
pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
let mut parts = value.split('\t');
let exe = parts.next().filter(|s| !s.is_empty())?;
let args = parts.next().unwrap_or("");
let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from);
let cmdline = if args.trim().is_empty() {
format!("\"{exe}\"")
} else {
format!("\"{exe}\" {args}")
};
Some((cmdline, workdir))
}
/// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the
/// interactive Windows user session, AFTER capture is up (the host is SYSTEM). The Linux paths go
/// through the compositor-aware [`launch_session_command`] instead.
@@ -360,6 +542,145 @@ mod tests {
}
}
/// The `steam_ui` launcher kind (D4): a closed two-value enum, mapped to the Steam client's own
/// UI on each OS. Nothing from the entry is interpolated — the value only SELECTS between two
/// host-owned literals — so there is no injection surface at all here.
#[test]
fn steam_ui_is_a_closed_two_value_enum() {
assert!(valid_steam_ui("bigpicture"));
assert!(valid_steam_ui("desktop"));
assert!(!valid_steam_ui("gamepadui"));
assert!(!valid_steam_ui(""));
assert!(!valid_steam_ui("bigpicture; rm -rf ~"));
}
/// The `launcher_ui` kind exists because D4's original plan — non-Steam launchers riding the
/// `command` kind — stopped being available to plugins when the 2026-08-05 review made
/// `command` operator-only. A plugin names a launcher; the host builds the command.
#[test]
fn launcher_ui_accepts_only_launchers_this_host_can_open() {
#[cfg(target_os = "linux")]
{
assert!(valid_launcher_ui("heroic"));
assert!(valid_launcher_ui("lutris"));
// Not wired on this OS — refused inbound rather than becoming a tile that does nothing.
assert!(!valid_launcher_ui("gog"));
}
#[cfg(not(target_os = "linux"))]
{
// No Windows/macOS launcher UIs are wired yet, so every value is refused.
assert!(!valid_launcher_ui("heroic"));
assert!(!valid_launcher_ui("gog"));
}
assert!(!valid_launcher_ui(""));
assert!(!valid_launcher_ui("lutris; rm -rf ~"));
}
#[cfg(target_os = "linux")]
#[test]
fn launcher_ui_opens_the_launcher_itself() {
let ui = |v: &str| {
command_for(&LaunchSpec {
kind: "launcher_ui".into(),
value: v.into(),
})
};
// Bare `lutris` opens the window; the URI form is the `lutris_id` kind and launches a game.
assert_eq!(ui("lutris").as_deref(), Some("lutris"));
assert!(!ui("lutris").unwrap().contains("rungameid"));
// Heroic resolves the same way its game launches do, but with no `--no-gui` and no URI — so
// the window IS what opens. `None` on a box without Heroic, which is a correct answer.
if let Some(cmd) = ui("heroic") {
assert!(!cmd.contains("--no-gui"), "the GUI is the point: {cmd:?}");
assert!(!cmd.contains("heroic://"), "no game URI: {cmd:?}");
}
assert_eq!(ui("nonsense"), None);
assert_eq!(ui(""), None);
}
#[cfg(not(windows))]
#[test]
fn steam_ui_resolves_to_the_client_ui_on_linux() {
let ui = |v: &str| {
command_for(&LaunchSpec {
kind: "steam_ui".into(),
value: v.into(),
})
};
// Big Picture is the SteamOS game-mode shape; nested in gamescope this is what `--steam`
// integration is built around.
assert_eq!(ui("bigpicture").as_deref(), Some("steam -gamepadui"));
assert_eq!(ui("desktop").as_deref(), Some("steam"));
assert_eq!(ui("nonsense"), None);
assert_eq!(ui(""), None);
}
#[cfg(windows)]
#[test]
fn steam_ui_resolves_to_the_client_ui_on_windows() {
let ui = |v: &str| {
windows_launch_for(&LaunchSpec {
kind: "steam_ui".into(),
value: v.into(),
})
};
let (bp, wd) = ui("bigpicture").expect("bigpicture recipe");
assert!(bp.contains("steam://open/bigpicture"), "line was {bp:?}");
assert!(wd.is_none());
let (desk, _) = ui("desktop").expect("desktop recipe");
assert!(desk.contains("steam://open/main"), "line was {desk:?}");
assert!(ui("nonsense").is_none());
assert!(ui("").is_none());
}
#[test]
fn steam_appid_validation_accepts_appids_and_shortcut_gameids() {
assert!(valid_steam_appid("570"));
// The 64-bit shortcut game id shares the `steam_appid` kind — `rungameid` takes either.
assert!(valid_steam_appid(
&shortcut_gameid(2_456_789_012).to_string()
));
assert!(!valid_steam_appid(""));
assert!(!valid_steam_appid("570; rm -rf ~"));
assert!(!valid_steam_appid("-1"));
}
/// Moved here with `shortcut_gameid` (WP1.1): the composed id is launch vocabulary, not
/// enumeration — the scanner only supplies the 32-bit appid it read out of `shortcuts.vdf`.
#[test]
fn shortcut_gameid_composes_appid_and_marker() {
let id = shortcut_gameid(0x8000_0000);
assert_eq!(id >> 32, 0x8000_0000, "high dword is the shortcut appid");
assert_eq!(id & 0xFFFF_FFFF, 0x0200_0000, "low dword is the marker");
}
#[cfg(windows)]
#[test]
fn epic_launch_uri_triple_bare_and_guard() {
assert_eq!(
epic_launch_uri("fn:abc:Fortnite").as_deref(),
Some("com.epicgames.launcher://apps/fn%3Aabc%3AFortnite?action=launch&silent=true")
);
assert_eq!(
epic_launch_uri("Fortnite").as_deref(),
Some("com.epicgames.launcher://apps/Fortnite?action=launch&silent=true")
);
assert!(epic_launch_uri("bad part:x:y").is_none()); // a space → rejected
assert!(epic_launch_uri("").is_none());
}
#[cfg(windows)]
#[test]
fn gog_spawn_parses_and_guards() {
let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap();
assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip");
assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3")));
let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap();
assert_eq!(cmd2, "\"C:\\g.exe\"");
assert!(wd2.is_none());
assert!(gog_spawn("").is_none());
}
#[cfg(windows)]
#[test]
fn windows_launch_for_maps_and_guards() {
@@ -84,6 +84,7 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
for (id, slug, name, directory) in rows.flatten() {
games.push(GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id: format!("lutris:{id}"),
store: "lutris".into(),
+103 -14
View File
@@ -12,19 +12,41 @@
use super::*;
/// One installed-store scanner this host build supports, with its enable state — the unit the
/// console renders a toggle for. The list is platform-gated at compile time (the scanners are),
/// so the console never shows a toggle that cannot do anything on this host.
/// One **game source** on this host, with its enable state — the unit the console renders a toggle
/// for. A source is either a scanner compiled into this build or a plugin that reconciles entries in
/// (WP2.6); the console treats them identically, which is what makes the extraction invisible.
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct ScannerInfo {
/// Stable scanner id — the same string the scanner's entries carry in their `store` field.
/// Stable source id — the same string this source's entries carry in their `store` field. For a
/// plugin source it is also its provider id and its store claim: one string, by construction, so
/// a user's disabled state survives a built-in scanner being replaced by its plugin.
#[schema(example = "steam")]
pub id: String,
/// Human-facing name for the console toggle.
#[schema(example = "Steam")]
pub label: String,
/// Whether this host runs the scanner (default true).
/// Whether this host runs the source (default true).
pub enabled: bool,
/// Where the source comes from: `builtin` (a scanner in this host build) or `plugin`.
#[schema(example = "builtin")]
pub origin: SourceOrigin,
/// The provider id backing a `plugin` source — absent for a built-in scanner.
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// How many entries this source currently contributes. `None` for a built-in scanner, whose
/// count would mean walking every launcher's files just to render a toggle.
#[serde(skip_serializing_if = "Option::is_none")]
pub entries: Option<usize>,
}
/// Where a [`ScannerInfo`] comes from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum SourceOrigin {
/// A scanner compiled into this host build.
Builtin,
/// A plugin reconciling entries over the provider API.
Plugin,
}
/// The scanners compiled into THIS host build: (id, label). Steam is cross-platform; the rest are
@@ -87,26 +109,93 @@ pub(crate) fn disabled_scanners() -> HashSet<String> {
load_settings().disabled.into_iter().collect()
}
/// The scanners available on this platform with their current enable state, in the fixed
/// definition order (stable for the console).
/// Every game source on this host with its current enable state (WP2.6):
///
/// 1. the built-in scanners this build compiled in, **minus** any whose store a plugin has claimed
/// (the plugin replaces it, so showing both would offer two toggles for one thing);
/// 2. the claimed stores themselves, as plugin sources;
/// 3. any other provider that has entries — the *emergent* case (rom-manager, playnite), which has
/// never had a toggle before and gets one for free here.
///
/// Built-ins keep their fixed definition order (stable for the console); plugin sources follow,
/// sorted by id.
pub fn list_scanners() -> Vec<ScannerInfo> {
let off = disabled_scanners();
scanner_defs()
let claims = crate::library::claimed_stores();
let entries = crate::library::load_custom();
let mut out: Vec<ScannerInfo> = scanner_defs()
.into_iter()
.filter(|(id, _)| !claims.contains_key(*id))
.map(|(id, label)| ScannerInfo {
id: id.to_string(),
label: label.to_string(),
enabled: !off.contains(id),
origin: SourceOrigin::Builtin,
provider: None,
entries: None,
})
.collect()
.collect();
// A claimed store shows under the SCANNER's label where we know one, so the row a user has been
// toggling for releases doesn't rename itself out from under them mid-migration.
let label_for = |id: &str| {
scanner_defs()
.into_iter()
.find(|(sid, _)| *sid == id)
.map(|(_, label)| label.to_string())
.unwrap_or_else(|| id.to_string())
};
let mut plugin_ids: Vec<(String, String)> = claims
.iter()
.map(|(store, provider)| (store.clone(), provider.clone()))
.collect();
// Emergent providers: any provider with entries that isn't already listed via a claim.
for e in &entries {
let Some(provider) = e.provider.as_deref() else {
continue;
};
if e.store.is_none() && !plugin_ids.iter().any(|(id, _)| id == provider) {
plugin_ids.push((provider.to_string(), provider.to_string()));
}
}
plugin_ids.sort();
plugin_ids.dedup();
out.extend(plugin_ids.into_iter().map(|(id, provider)| {
let count = entries
.iter()
.filter(|e| crate::library::source_id_for(e) == Some(id.as_str()))
.count();
ScannerInfo {
label: label_for(&id),
enabled: !off.contains(&id),
origin: SourceOrigin::Plugin,
provider: Some(provider),
entries: Some(count),
id,
}
}));
out
}
/// Enable/disable one scanner. `None` when `id` names no scanner available on this platform (the
/// mgmt layer maps that to 404 — the console only ever sees this host's own list). Persists and
/// emits `library.changed` (source = the scanner id) only when the state actually changed, so a
/// repeated PUT is a cheap no-op.
/// Whether `id` names a source that exists on this host right now — a compiled-in scanner, a claimed
/// store, or a provider with entries. The toggle accepts exactly these (an unknown id still 404s).
fn is_known_source(id: &str) -> bool {
scanner_defs().iter().any(|(sid, _)| *sid == id) || list_scanners().iter().any(|s| s.id == id)
}
/// Enable/disable one source. `None` when `id` names no source on this host (the mgmt layer maps
/// that to 404 — the console only ever sees this host's own list). Persists and emits
/// `library.changed` (source = the id) only when the state actually changed, so a repeated PUT is a
/// cheap no-op.
///
/// The **same** `library-scanners.json` disabled-set backs built-in and plugin sources alike, and
/// the ids match by construction — so a user who disabled `steam` before the migration still has it
/// disabled after the steam plugin claims the store, with nothing to carry over.
pub fn set_scanner_enabled(id: &str, enabled: bool) -> Result<Option<Vec<ScannerInfo>>> {
if !scanner_defs().iter().any(|(sid, _)| *sid == id) {
if !is_known_source(id) {
return Ok(None);
}
let mut settings = load_settings();
+5 -12
View File
@@ -29,6 +29,7 @@ impl LibraryProvider for SteamProvider {
.filter(|app| !is_steam_tool(app.appid, &app.name))
.map(|app| GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id: format!("steam:{}", app.appid),
store: "steam".into(),
@@ -383,6 +384,7 @@ fn shortcut_entry(sc: Shortcut) -> Option<GameEntry> {
}
Some(GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id: format!("steam:{}", sc.appid),
store: "steam".into(),
@@ -426,12 +428,8 @@ fn shortcuts_files() -> Vec<PathBuf> {
files
}
/// The 64-bit game id `steam://rungameid/` needs to launch a non-Steam shortcut: high dword = the
/// 32-bit shortcut appid, low dword = the shortcut marker `0x0200_0000`. (Handing `rungameid` the
/// bare 32-bit appid does not launch a shortcut — it must be this composed id.)
fn shortcut_gameid(appid: u32) -> u64 {
((appid as u64) << 32) | 0x0200_0000
}
// `shortcut_gameid` (the 64-bit `rungameid` composition) moved to `launch.rs` (WP1.1) — it is launch
// vocabulary; this module only reads the 32-bit appid out of `shortcuts.vdf`.
/// The 32-bit appid Steam derives for a shortcut from its target+name — `crc32(exe + name)` with the
/// high bit set. Only used when `shortcuts.vdf` omits the stored `appid` (very old Steam); modern
@@ -762,12 +760,7 @@ mod tests {
assert!(launch.value.bytes().all(|b| b.is_ascii_digit()));
}
#[test]
fn shortcut_gameid_composes_appid_and_marker() {
let id = shortcut_gameid(0x8000_0000);
assert_eq!(id >> 32, 0x8000_0000); // high dword is the appid
assert_eq!(id & 0xFFFF_FFFF, 0x0200_0000); // low dword is the shortcut marker
}
// `shortcut_gameid_composes_appid_and_marker` moved with the function to `launch.rs` (WP1.1).
#[test]
fn crc32_matches_the_known_check_value_and_derives_a_high_bit_appid() {
@@ -70,6 +70,7 @@ fn xbox_games() -> Vec<GameEntry> {
let art = cached_art(&id).unwrap_or_default();
games.push(GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id,
store: "xbox".into(),
+72 -24
View File
@@ -31,7 +31,8 @@ fn check_entry_fields(
&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"
launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, lutris_id, heroic) \
instead"
),
));
}
@@ -249,6 +250,11 @@ pub(crate) async fn update_custom_game(
StatusCode::CONFLICT,
&format!("entry is owned by provider `{p}` — update it through its reconcile"),
),
// Store claims are a reconcile-only concern — the manual CRUD never requests one.
Ok(MutateOutcome::StoreClaimed { .. }) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
"unexpected claim outcome",
),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
@@ -280,6 +286,11 @@ pub(crate) async fn delete_custom_game(Path(id): Path<String>) -> Response {
"entry is owned by provider `{p}` — remove it there, or DELETE the provider set"
),
),
// Store claims are a reconcile-only concern — the manual CRUD never requests one.
Ok(MutateOutcome::StoreClaimed { .. }) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
"unexpected claim outcome",
),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
@@ -291,6 +302,13 @@ pub(crate) struct ProviderRemoved {
removed: usize,
}
/// Query for `reconcileProviderEntries` — the optional store claim (D2).
#[derive(Deserialize)]
pub(crate) struct ReconcileQuery {
/// Claim this store for the provider, so its entries take the store's own identity.
store: Option<String>,
}
/// Replace a provider's library entries (declarative reconcile)
///
/// Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the
@@ -298,28 +316,47 @@ pub(crate) struct ProviderRemoved {
/// surviving title's host id stable across reconciles, drops orphans, and never touches manual
/// entries or other providers'. An empty array removes everything the provider owns. Emits
/// `library.changed` with the provider as `source`.
///
/// `?store=` additionally **claims** that store for the provider: its entries then surface with
/// deterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque
/// `custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner
/// used to produce, right down to the GameStream app ids and client-side art caches. One provider
/// per store; a second claimant gets 409. While a claim is held the matching built-in scanner is
/// suppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty
/// reconcile (a store can legitimately have zero installed titles).
#[utoipa::path(
put,
path = "/library/provider/{provider}",
tag = "library",
operation_id = "reconcileProviderEntries",
params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")),
params(
("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)"),
("store" = Option<String>, Query, description = "Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)"),
),
request_body = Vec<crate::library::ProviderEntryInput>,
responses(
(status = OK, description = "The provider's resulting entries (host ids assigned/kept)", body = [crate::library::CustomEntry]),
(status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError),
(status = BAD_REQUEST, description = "Invalid provider id, store id, or payload", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = CONFLICT, description = "That store is already claimed by another provider", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
)
)]
pub(crate) async fn reconcile_provider_entries(
Extension(lane): Extension<AuthLane>,
Path(provider): Path<String>,
Query(q): Query<ReconcileQuery>,
ApiJson(inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
) -> Response {
if let Err(e) = crate::library::validate_provider_name(&provider) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
let store = q.store.filter(|s| !s.is_empty());
if let Some(store) = &store {
if let Err(e) = crate::library::validate_store_claim(store) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
}
if let Err(e) = crate::library::validate_provider_payload(&inputs) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
@@ -335,15 +372,24 @@ pub(crate) async fn reconcile_provider_entries(
return denied;
}
}
match crate::library::reconcile_provider(&provider, inputs) {
Ok(entries) => {
match crate::library::reconcile_provider(&provider, store.as_deref(), inputs) {
Ok(crate::library::MutateOutcome::Done(entries)) => {
tracing::info!(
provider,
store = store.as_deref().unwrap_or("-"),
count = entries.len(),
"library provider reconciled"
);
Json(entries).into_response()
}
Ok(crate::library::MutateOutcome::StoreClaimed { store, provider }) => api_error(
StatusCode::CONFLICT,
&format!("store `{store}` is already claimed by provider `{provider}`"),
),
Ok(_) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
"unexpected reconcile outcome",
),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
@@ -383,11 +429,12 @@ pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> Res
/// Fetch one cover-art image for a library entry
///
/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
/// the image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —
/// it's what the user's Steam client already shows for it), the public Steam CDN's flat URL
/// convention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host
/// can't predict, in which case this 404s and the client falls through to its next art candidate).
/// Only Steam ids are backed today; any other store 404s.
/// the image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,
/// and a library plugin's claimed-store entries) serves its local art file. A Steam title falls back
/// to the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the
/// user's Steam client already shows for it), the public Steam CDN's flat URL convention second
/// (newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case
/// this 404s and the client falls through to its next art candidate).
#[utoipa::path(
get,
path = "/library/art/{id}/{kind}",
@@ -407,7 +454,20 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) ->
let Some(kind) = crate::library::ArtKind::parse(&kind) else {
return api_error(StatusCode::NOT_FOUND, "unknown art kind");
};
// Steam: CDN / local-cache proxy (id `steam:<appid>`).
// `library.json` FIRST, for ANY id (WP1.2). Stored entries — manual, provider-synced, and (once
// store claims land) a scanner plugin's `steam:570` — all serve their local art file from here,
// so the proxy never has to know which store an id belongs to. Steam ids aren't stored today, so
// this misses and the legacy branch below still answers them.
let stored = {
let id = id.clone();
tokio::task::spawn_blocking(move || crate::library::library_local_art_bytes(&id, kind))
.await
};
if let Ok(Some((bytes, ctype))) = stored {
return ([(header::CONTENT_TYPE, ctype)], bytes).into_response();
}
// Legacy in-host Steam scanner: local Steam cache, then the flat CDN URL. Retired with the
// scanner itself once the steam plugin claims the store (M6).
if let Some(appid) = id
.strip_prefix("steam:")
.and_then(|s| s.parse::<u32>().ok())
@@ -421,17 +481,5 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) ->
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
};
}
// Custom/provider entry (id `custom:<id>`): serve its stored LOCAL art file — e.g. the Playnite
// plugin's covers, reconciled as on-host paths rather than inlined bytes.
if let Some(cid) = id.strip_prefix("custom:").map(str::to_owned) {
return match tokio::task::spawn_blocking(move || {
crate::library::custom_local_art_bytes(&cid, kind)
})
.await
{
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
};
}
api_error(StatusCode::NOT_FOUND, "no art proxy for this store")
api_error(StatusCode::NOT_FOUND, "no art of that kind for this title")
}
+71 -6
View File
@@ -64,6 +64,14 @@ pub(crate) struct PluginRegistration {
/// entry only (e.g. a future runner-management listing) and grows no nav entry.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui: Option<PluginUi>,
/// What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui`
/// because it describes the plugin, not its surface. The console knows one value today —
/// `library` — which it filters **out of the nav**: six installed scanner plugins would otherwise
/// flood the sidebar, and their real entry point is the Game sources surface (design D5). A
/// library plugin that genuinely wants its own page (rom-manager, which is much more than a
/// scanner) simply omits the category.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
}
/// One log line produced by the runner or a plugin inside it (`POST /plugins/logs`).
@@ -104,6 +112,9 @@ pub(crate) struct PluginSummary {
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ui: Option<PluginUiPublic>,
/// The plugin's kind — see [`PluginRegistration::category`].
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
}
/// `GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback).
@@ -129,14 +140,19 @@ struct Stored {
title: String,
version: Option<String>,
ui: Option<StoredUi>,
category: Option<String>,
expires_at: Instant,
}
impl Stored {
/// Do the operator-visible fields match (ignoring the lease clock)? A pure lease renewal leaves
/// these unchanged and emits no event; a restart (new secret) or a re-scan (new title/icon) does.
fn public_eq(&self, title: &str, version: &Option<String>, ui: &Option<StoredUi>) -> bool {
self.title == title && self.version == *version && self.ui == *ui
/// these unchanged and emits no event; a restart (new secret) or a re-scan (new title/icon/
/// category) does.
fn public_eq(&self, v: &Valid) -> bool {
self.title == v.title
&& self.version == v.version
&& self.ui == v.ui
&& self.category == v.category
}
}
@@ -150,6 +166,7 @@ struct Valid {
title: String,
version: Option<String>,
ui: Option<StoredUi>,
category: Option<String>,
}
impl PluginRegistry {
@@ -167,7 +184,7 @@ impl PluginRegistry {
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
let changed = match map.get(id) {
// An *expired* prior entry counts as a change (it had stopped listing).
Some(prev) => !prev.is_live() || !prev.public_eq(&v.title, &v.version, &v.ui),
Some(prev) => !prev.is_live() || !prev.public_eq(&v),
None => true,
};
map.insert(
@@ -176,6 +193,7 @@ impl PluginRegistry {
title: v.title,
version: v.version,
ui: v.ui,
category: v.category,
expires_at,
},
);
@@ -207,6 +225,7 @@ impl PluginRegistry {
port: u.port,
icon: u.icon.clone(),
}),
category: s.category.clone(),
})
.collect();
live.sort_by(|a, b| a.title.cmp(&b.title).then_with(|| a.id.cmp(&b.id)));
@@ -333,7 +352,31 @@ fn validate(reg: PluginRegistration) -> Result<Valid, String> {
Some(u) => Some(validate_ui(u)?),
None => None,
};
Ok(Valid { title, version, ui })
// Categories are grouping keys the console switches on — a closed charset, but deliberately not
// a closed VOCABULARY: an unknown category is stored and simply matches no console rule, so a
// newer plugin registering against an older host degrades to "shows in the nav", never to a
// failed registration.
let category = match reg.category {
Some(c) => {
let ok = (1..=32).contains(&c.len())
&& c.starts_with(|ch: char| ch.is_ascii_lowercase())
&& c.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
if !ok {
return Err(
"category must be 132 chars of [a-z0-9-], starting with a letter".into(),
);
}
Some(c)
}
None => None,
};
Ok(Valid {
title,
version,
ui,
category,
})
}
fn validate_ui(u: PluginUi) -> Result<StoredUi, String> {
@@ -558,6 +601,7 @@ mod tests {
secret: secret.into(),
icon: Some("gamepad-2".into()),
}),
category: None,
}
}
@@ -584,10 +628,30 @@ mod tests {
title: "Ro\u{7}m\n".into(),
version: None,
ui: None,
category: None,
})
.unwrap();
assert_eq!(v.title, "Rom");
// privileged port rejected
// Category charset (WP2.7): the console's one known value passes; the shapes that would
// break a grouping key don't. An UNKNOWN-but-well-formed category is accepted on purpose —
// a newer plugin must not fail to register against an older host.
let lib = |c: &str| PluginRegistration {
title: "X".into(),
version: None,
ui: None,
category: Some(c.into()),
};
assert_eq!(
validate(lib("library")).unwrap().category.as_deref(),
Some("library")
);
assert!(validate(lib("some-future-kind")).is_ok());
assert!(validate(lib("")).is_err());
assert!(validate(lib("Library")).is_err()); // no uppercase
assert!(validate(lib("9lives")).is_err()); // must start with a letter
assert!(validate(lib("lib_rary")).is_err()); // no underscore
assert!(validate(lib(&"a".repeat(33))).is_err()); // too long
// privileged port rejected
assert!(validate(reg("x", 80, SECRET)).is_err());
// short secret rejected
assert!(validate(reg("x", 49321, "tooshort")).is_err());
@@ -641,6 +705,7 @@ mod tests {
title: "Headless".into(),
version: None,
ui: None,
category: None,
})
.unwrap(),
);
+10
View File
@@ -108,6 +108,14 @@ pub(crate) struct CatalogEntry {
/// A revocation covering the catalogued version — do not offer this without shouting.
#[serde(skip_serializing_if = "Option::is_none")]
pub blocked: Option<String>,
/// What kind of plugin this is — the console filters Browse by these, and the Game sources
/// surface's "Add a source" rail shows exactly the `library` ones (design D5/D6).
pub categories: Vec<String>,
/// Whether the launcher this plugin scans looks **installed on this host** (design D8), from the
/// index's own existence probes. `null` = the entry declares no probes for this platform, which
/// the console renders as "unknown" rather than "not installed".
#[serde(skip_serializing_if = "Option::is_none")]
pub detected: Option<bool>,
}
#[derive(Serialize, ToSchema)]
@@ -277,6 +285,8 @@ fn build_catalog(force: bool) -> CatalogResponse {
update_available: installed_version.as_deref().is_some_and(|v| v != e.version),
installed_version,
blocked: store::advisory_for(&e.pkg, Some(&e.version)).map(|a| a.reason),
categories: e.categories.clone(),
detected: e.detected(),
});
}
}
+211
View File
@@ -97,6 +97,31 @@ pub(crate) struct Entry {
/// Host platforms this plugin works on (`linux`/`windows`/`macos`). Empty ⇒ all.
#[serde(default)]
pub platforms: Vec<String>,
/// What kinds of plugin this is (`[a-z][a-z0-9-]{0,31}`, ≤4). The console filters Browse by
/// these, and the Game sources surface's "Add a source" rail lists exactly the entries carrying
/// `library` (design D5/D6). Additive: an older host ignores the field, a newer one just sees no
/// categories on an older index.
#[serde(default)]
pub categories: Vec<String>,
/// Optional per-platform "is this launcher installed here?" probes (design D8).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detect: Option<DetectProbes>,
}
/// Existence probes that let the console badge a catalog row "detected on this host" **without the
/// host re-growing per-store knowledge** — the whole point of extracting the scanners. Store
/// knowledge lives in the updatable, signed index; the host stays generic and only evaluates.
///
/// Deliberately anaemic: a probe is a path or an `HKLM\…` registry key, checked for EXISTENCE only.
/// No reads, no content matching, no globbing beyond a single `*` segment. The index is
/// operator-trusted but remotely updatable, so a probe must never be able to exfiltrate anything or
/// cost more than a stat.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub(crate) struct DetectProbes {
#[serde(default)]
pub linux: Vec<String>,
#[serde(default)]
pub windows: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -228,9 +253,40 @@ impl Entry {
self.platforms
.retain(|p| matches!(p.as_str(), "linux" | "windows" | "macos"));
self.platforms.truncate(4);
// Categories and probes are cosmetic/advisory: a malformed one is dropped, never fatal to
// the entry — a plugin must stay installable even if a future index writes a category this
// host build has never heard of.
self.categories.retain(|c| valid_category(c));
self.categories.truncate(4);
if let Some(d) = &mut self.detect {
d.linux.retain(|p| valid_probe(p));
d.windows.retain(|p| valid_probe(p));
d.linux.truncate(MAX_PROBES);
d.windows.truncate(MAX_PROBES);
if d.linux.is_empty() && d.windows.is_empty() {
self.detect = None;
}
}
Ok(())
}
/// Does this entry's platform probe match on the running host? `None` = the entry declares no
/// probes for this platform, i.e. "unknown", which the console renders differently from "no".
pub(crate) fn detected(&self) -> Option<bool> {
let probes = self.detect.as_ref()?;
let list = if cfg!(windows) {
&probes.windows
} else if cfg!(target_os = "linux") {
&probes.linux
} else {
return None;
};
if list.is_empty() {
return None;
}
Some(list.iter().any(|p| probe_matches(p)))
}
/// Is this entry installable on the running host? Returns the operator-facing reason when not.
pub(crate) fn incompatible_reason(&self) -> Option<String> {
if !self.platforms.is_empty() && !self.platforms.iter().any(|p| p == HOST_PLATFORM) {
@@ -372,6 +428,94 @@ fn is_https(url: &str) -> bool {
url.starts_with("https://") && url.len() > "https://".len()
}
/// A plugin category (design D5): same shape the registration API accepts, so a plugin's declared
/// category and its catalog row can never disagree about spelling.
fn valid_category(c: &str) -> bool {
(1..=32).contains(&c.len())
&& c.starts_with(|ch: char| ch.is_ascii_lowercase())
&& c.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
/// How many probes one platform may declare — a handful of well-chosen paths covers any launcher,
/// and the cap bounds the stat cost of rendering the catalog.
const MAX_PROBES: usize = 8;
/// Is this a probe the host will evaluate? An **absolute** filesystem path with at most one `*`
/// segment, or an `HKLM\…` registry key. Everything else is dropped.
///
/// The restrictions are the security model (D8). Absolute: a relative path would resolve against
/// whatever the host's cwd happens to be. One `*` segment: bounded fan-out, so a probe can't walk a
/// tree. `HKLM` only: `HKCU` is unreadable as LocalService anyway, and pointing the host at an
/// arbitrary hive is not something a remote index should be able to ask for.
fn valid_probe(p: &str) -> bool {
if p.is_empty() || p.len() > 260 {
return false;
}
if let Some(key) = p.strip_prefix("HKLM\\") {
return !key.is_empty()
&& !key.contains("..")
&& key.bytes().all(|b| {
b.is_ascii_alphanumeric() || matches!(b, b'\\' | b' ' | b'-' | b'_' | b'.')
});
}
let b = p.as_bytes();
let absolute = p.starts_with('/') || (b.len() >= 3 && b[1] == b':' && b[2] == b'\\');
// No traversal, and at most ONE wildcard segment (`~` is not expanded — the host runs as a
// service account whose home means nothing to a user's launcher install).
absolute && !p.contains("..") && p.matches('*').count() <= 1
}
/// Evaluate one probe: does the path (or registry key) exist? Existence only — never a read.
fn probe_matches(p: &str) -> bool {
#[cfg(windows)]
if let Some(key) = p.strip_prefix("HKLM\\") {
use std::os::windows::process::CommandExt;
// `reg.exe query` rather than a registry crate: dependency-free, and it is exactly what a
// library plugin will use for the same job under LocalService.
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
return std::process::Command::new("reg.exe")
.args(["query", &format!("HKLM\\{key}")])
.creation_flags(CREATE_NO_WINDOW)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
}
#[cfg(not(windows))]
if p.starts_with("HKLM\\") {
return false; // a Windows probe on a POSIX host is simply not a match
}
match p.split_once('*') {
None => std::path::Path::new(p).exists(),
// One wildcard: list the parent of the wildcard segment and match the fixed prefix/suffix
// around it. Bounded to a single directory read.
Some((before, after)) => {
let (dir, prefix) = match before.rfind(['/', '\\']) {
Some(i) => (&before[..=i], &before[i + 1..]),
None => return false, // a wildcard with no directory to anchor it
};
let (suffix, rest) = match after.find(['/', '\\']) {
Some(i) => (&after[..i], &after[i..]),
None => (after, ""),
};
let Ok(read) = std::fs::read_dir(dir) else {
return false;
};
read.flatten().any(|e| {
let name = e.file_name();
let name = name.to_string_lossy();
name.starts_with(prefix)
&& name.ends_with(suffix)
&& name.len() >= prefix.len() + suffix.len()
&& (rest.is_empty()
|| e.path().join(rest.trim_start_matches(['/', '\\'])).exists())
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -401,6 +545,73 @@ mod tests {
assert!(Index::parse(b"not json").is_err());
}
/// WP2.8 is additive on purpose — SCHEMA stays 1. An index written by a newer curator must load
/// on an older host (unknown fields ignored) and vice versa (absent fields default), or the
/// signed-index rollout would need a flag day.
#[test]
fn categories_and_probes_are_additive_and_sanitized() {
// An entry with NEITHER field — every index in the wild today.
let e = &Index::parse(&doc(GOOD)).unwrap().plugins[0];
assert!(e.categories.is_empty());
assert!(e.detect.is_none());
assert_eq!(e.detected(), None, "no probes ⇒ unknown, not `false`");
// With both, including rows that must be dropped rather than fail the entry.
let rich = GOOD.trim_end_matches('}').to_string()
+ r#","categories":["library","Bad Cat","x","y","z","w"],
"detect":{"linux":["/usr/bin/steam","relative/path","/etc/../etc/passwd"],
"windows":["HKLM\\SOFTWARE\\Valve\\Steam","HKCU\\SOFTWARE\\Valve"]}}"#;
let e = &Index::parse(&doc(&rich)).unwrap().plugins[0];
assert_eq!(
e.categories,
["library", "x", "y", "z"],
"malformed dropped, capped at 4"
);
let d = e.detect.as_ref().expect("probes kept");
assert_eq!(d.linux, ["/usr/bin/steam"], "relative + traversal dropped");
assert_eq!(
d.windows,
["HKLM\\SOFTWARE\\Valve\\Steam"],
"HKCU is not evaluable as LocalService — dropped"
);
}
#[test]
fn probe_shapes_are_bounded() {
assert!(valid_probe("/usr/bin/steam"));
assert!(
valid_probe("/home/*/.steam"),
"one wildcard segment is fine"
);
assert!(valid_probe(r"C:\Program Files (x86)\Steam\steam.exe"));
assert!(valid_probe(r"HKLM\SOFTWARE\WOW6432Node\Valve\Steam"));
// Rejected: relative, traversal, more than one wildcard, other hives, absurd length.
assert!(!valid_probe("steam"));
assert!(!valid_probe("/usr/../etc/passwd"));
assert!(!valid_probe("/home/*/games/*/steam"));
assert!(!valid_probe(r"HKCU\SOFTWARE\Valve"));
assert!(!valid_probe(""));
assert!(!valid_probe(&"/x".repeat(200)));
}
/// The evaluator does existence checks only, against real paths, and never reads a byte.
#[test]
fn probes_evaluate_against_the_filesystem() {
let dir = std::env::temp_dir().join(format!("pf-probe-{}", std::process::id()));
let nested = dir.join("SteamLibrary-42");
std::fs::create_dir_all(nested.join("steamapps")).unwrap();
let d = dir.to_string_lossy().into_owned();
assert!(probe_matches(&format!("{d}/SteamLibrary-42")));
assert!(!probe_matches(&format!("{d}/nope")));
// One wildcard segment, with and without a trailing fixed component.
assert!(probe_matches(&format!("{d}/SteamLibrary-*")));
assert!(probe_matches(&format!("{d}/SteamLibrary-*/steamapps")));
assert!(!probe_matches(&format!("{d}/SteamLibrary-*/nope")));
assert!(!probe_matches(&format!("{d}/Other-*")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn drops_invalid_entries_but_keeps_the_rest() {
let bad_unscoped = GOOD.replace("@punktfunk/plugin-rom-manager", "punktfunk-plugin-x");
+12 -6
View File
@@ -108,10 +108,16 @@ the full path: `& "$env:ProgramFiles\punktfunk\punktfunk-host.exe" plugins add p
Open the [web console](/docs/web-console) and the plugin's page appears in the nav automatically —
that's the whole install.
The runner is **opt-in**: `plugins add` installs, `plugins enable` turns it on. You only need
`enable` once. The runner discovers plugins when it starts, so one installed later needs a restart
to come up (`systemctl --user restart punktfunk-scripting`, or `Restart` the `PunktfunkScripting`
task) — the console does that restart for you as part of installing.
The runner is **on by default** on a new install — your game sources are plugins, so a host without
it would show an empty library. (On a host that predates this, it stays however you left it; turn it
on with `punktfunk-host plugins enable`, which you only need once.) The runner discovers plugins
when it starts, so one installed later needs a restart to come up
(`systemctl --user restart punktfunk-scripting`, or `Restart` the `PunktfunkScripting` task) — the
console does that restart for you as part of installing.
Don't want it? It is a normal service you can switch off: `systemctl --user mask punktfunk-scripting`
on Linux, or disable the `PunktfunkScripting` scheduled task on Windows. Your host keeps streaming;
you just lose plugin-provided game sources and any automation.
A plugin installed from the CLI shows up in the console as **Installed via CLI**: the console knows
what is installed, but not who vouched for it. Install the same plugin from the store's Browse tab
@@ -301,8 +307,8 @@ host's, on one timeline, with the same search and download. Each is tagged `plug
plugin's own name for lines it logged itself, `plugin:runner` for the supervisor's (starting a
plugin, restarting a crashed one, refusing an unsafe file).
An empty Plugins view almost always means the runner isn't running — it is a separate service, and
opt-in on Linux. Check with `punktfunk-host plugins status`.
An empty Plugins view almost always means the runner isn't running — it is a separate service. Check
with `punktfunk-host plugins status`.
<Callout>
Nothing is lost if the host is down: the runner keeps buffering and sends the backlog when the host
+43 -21
View File
@@ -7,13 +7,20 @@ Every Punktfunk client has an in-stream stats overlay. All clients use **the sam
vocabulary and the same four measurement points**, so a stage name on your phone means
what the same name means on your desktop.
Two platforms differ in the *math*: on **iOS and tvOS** the headline is **floor-shaved**.
The fixed depth of Apple's present pipeline — roughly two refresh intervals, which no
client can pace under — is excluded from it, and the Detailed tier prints the excluded
Some platforms differ in the *math*: on **iOS, tvOS and Android** the headline is
**floor-shaved**. The depth of the OS present pipeline — the compositor's own wait, which
no client can pace under — is excluded from it, and the Detailed tier prints the excluded
term on its own line as `os present +X.X excluded (display pipeline minimum)`. Add that
floor back before holding an iPhone, iPad or Apple TV's `capture→on-glass` next to a
macOS, Linux, Windows or Android one. (The macOS client shaves nothing: it presents
straight to the display, with no such pipeline depth to measure, so its numbers are raw.)
floor back before holding an iPhone, iPad, Apple TV or Android device's headline next to a
macOS, Linux or Windows one. (The macOS client shaves nothing: it presents straight to the
display, with no such pipeline depth to measure, so its numbers are raw.)
The floor is **measured, not assumed**, and it is not small: it is commonly one to two
refresh intervals, which on a 60 Hz phone is more than 30 ms — enough on its own to dwarf
everything Moonlight's overlay displays. Charging it to the stream made Punktfunk look
slower than clients that simply never measure that far (see
[Comparing with Moonlight / Sunshine](#comparing-with-moonlight--sunshine)), so we report
it rather than bury it in the total.
## The four measurement points
@@ -47,7 +54,7 @@ captured input, switch mouse mode, disconnect, mute the microphone — are in
lost). **Normal** adds the stream line and the p50/p95 headline. **Detailed** adds the per-stage
breakdown everywhere; on Linux/Windows it also adds the encoder's target bitrate, the decode path,
an HDR tag and a chroma tag, on Android the decoder plus the full codec/bit-depth/colour line, and
on iOS/tvOS the excluded OS present floor.
on iOS, tvOS and Android the excluded OS present floor.
You can also set the level a stream starts at in each client's
[Settings](/docs/client-settings#overlay). The examples below are the **Detailed** view.
@@ -68,14 +75,16 @@ present: mailbox
lost 3 (2.4%)
```
Android:
Android (headline and `display` both floor-shaved, like the Apple clients — the raw
end-to-end here is 30.9 ms, the 16.7 ms floor of a 120 Hz panel included):
```
1920×1080@120 120 fps 24.3 Mb/s
c2.qti.hevc.decoder · low-latency
HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0
end-to-end 14.2 ms p50 · 19.8 p95 · capture→displayed
= host 3.1 + network 6.7 + decode 2.1 + display 2.3
= host 3.1 + network 6.7 + decode 2.1 + display 2.3 · presents 119
os present +16.7 excluded (display pipeline minimum)
lost 3 (2.4%) · skipped 1 · FEC 12
```
@@ -131,18 +140,22 @@ lost 3 (2.4%)
the screen's refresh cycle, not the stream; a large `pace` is us. (`pace` is also the
fair number to compare against an iPhone or iPad, whose figure already has its
equivalent of `latch` removed.)
- `os present` *(iOS and tvOS)* — the fixed depth of the OS present pipeline, which is
- `os present` *(iOS, tvOS and Android)* — the depth of the OS present pipeline, which is
excluded from both the headline and `display` and printed here so you can add it
back.
back. On Android it is the measured time SurfaceFlinger took to latch and scan out each
frame, so it moves with your panel's rate and with whatever low-latency mode the vendor
applied; on Apple it is measured from the display link's own lead.
- `client queue` *(Apple only)* — how long a received frame waited before the decoder
pulled it. It's the front part of `decode`, not time on top of it. Hidden below 2 ms;
a value that persists is a standing receive backlog on the client.
- `display X (pace A + latch B)` and `presents N` *(Android only)*when the timeline presenter
is running it splits `display` in two: `pace` is the wait it deliberately holds the frame for
its target refresh, `latch` is SurfaceFlinger picking it up and scanning it out. `presents`
counts the frames confirmed on glass this second — well below `fps` means the presenter is
dropping or serializing frames; an `fps` shortfall with `presents` keeping up is upstream of
the client.
- `presents N` *(Android only)*the frames confirmed on glass this second. Well below `fps`
means the presenter is dropping or serializing frames; an `fps` shortfall with `presents`
keeping up is upstream of the client.
- `display X (pace A + latch B)` *(Android, only when the floor couldn't be measured)* — with
the floor excluded, Android's `display` term is already just `pace` (the wait the presenter
deliberately holds a frame for its target refresh) and `latch` is what the `os present` line
reports. On the rare window where no latch sample pairs up, nothing is excluded and `display`
reverts to the raw figure with both halves shown.
Against an **older host** that doesn't report its share yet, the first two terms
merge into a single `host+network` number (`host+net` on Linux/Windows) — same total,
@@ -190,12 +203,13 @@ pretending:
| Windows, Linux | `capture→on-glass` | present instant available (measured right after the Vulkan swapchain present); published raw |
| macOS (Metal presenter) | `capture→on-glass` | present instant available (the system's on-glass time for the flip); published raw |
| iOS/tvOS (Metal presenter) | `capture→on-glass` | present instant available, but the OS present floor is **excluded** from the number and printed separately as `os present +X.X excluded` |
| Android | `capture→displayed` | MediaCodec's per-frame render callback reports SurfaceFlinger's render timestamp; on the rare window where no callback is delivered (the platform may drop them under load) the HUD falls back to `capture→decoded` |
| Android | `capture→displayed` | MediaCodec's per-frame render callback reports SurfaceFlinger's render timestamp, and the OS present floor measured from it is **excluded** from the number and printed separately as `os present +X.X excluded`; on the rare window where no callback is delivered (the platform may drop them under load) the HUD falls back to `capture→decoded` |
| macOS/iOS fallback presenter | `capture→received` | the system video layer hides decode and present timing entirely |
A shorter chain means the number is **smaller because it measures less** — check the
endpoint before comparing two devices, and add the excluded `os present` floor back to an
iOS or tvOS client's headline before holding it next to another platform's.
iOS, tvOS or Android client's headline before holding it next to a macOS, Linux or Windows
one.
## Comparing with Moonlight / Sunshine
@@ -235,8 +249,8 @@ stands in for a one-way frame flight that Moonlight doesn't measure.)
| `Frames dropped due to network jitter` | Decoded frames the *client's pacer* chose to drop ÷ decoded frames | `skipped` (line 4, Android only) | Approximately (both are client-side pacing decisions, despite Moonlight's name) |
| `Average network latency` | The **control connection's round-trip time** (ENet RTT + variance) — not video frame latency | `network` (line 3) is the closest concept, but it's the *actual one-way frame path* (flight + reassembly), not an RTT | **No direct comparison.** Roughly, Punktfunk's `network` ≈ ½ × an idle RTT plus serialization time of the frame |
| `Average decoding time` | Mean time from decoder enqueue to picture out | `decode` (p50) | Yes (mean vs median; both include decoder queueing) |
| `Average frame queue delay` | Mean time a decoded frame waits for its vsync slot | inside `display` | Sum the two Moonlight lines → |
| `Average rendering time (incl. V-sync latency)` | Mean duration of the present call | inside `display` | …and compare against Punktfunk's `display` |
| `Average frame queue delay` *(desktop only)* | Mean time a decoded frame waits for its vsync slot | inside `display` | Sum the two Moonlight lines → |
| `Average rendering time (incl. V-sync latency)` *(desktop only)* | Mean duration of the present call | inside `display` | …and compare against Punktfunk's `display` |
| *(no equivalent)* | — | `end-to-end` — true capture→glass, clock-skew-corrected across machines | **Punktfunk only** |
| *(no equivalent)* | — | `FEC` recovered shards (loss absorbed invisibly; Android only) | Punktfunk only |
@@ -250,6 +264,14 @@ Other differences worth knowing when squinting at both overlays side by side:
- **Host frame rate.** Moonlight's headline FPS estimates what the *host* produced
(received + lost). Punktfunk shows what your client actually received, and reports
loss separately.
- **On Android, Moonlight's numbers stop at the decoder.** The two lines above that cover
presentation are desktop-only: Moonlight's Android overlay measures nothing after the
decoder produces the picture, so no part of the wait for the screen appears anywhere in
it — and the popular Android forks measure the same slice. Its `Average decoding time` is
therefore comparable to Punktfunk's `decode`, and to nothing else; on Android there is no
Moonlight number that includes what your screen contributes. That asymmetry is why
Punktfunk excludes the `os present` floor on Android too, and why adding that floor back
is the right move when you want the whole truth rather than a like-for-like comparison.
## Recording a capture for a bug report
+108
View File
@@ -0,0 +1,108 @@
Wire-compatible with 0.24.x — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host (or the other way round) streams exactly as it does today; the parts that are new switch themselves on only once both ends have them.
The headline is that a **DualSense plugged in by USB can now play a game's fine-grained haptics — the textured detail in the grips, not just the rumble motors — and its own speaker, streamed from the host**. That needs a Windows host with Steam installed and either the Android app or the desktop session client; everywhere else, nothing changes.
Behind it, three fronts. **Controllers** were swept end to end: rumble that faded on a Steam Deck, died for good after one hiccup on a phone, or kept buzzing after you quit; adaptive triggers and lightbars left stuck in a game's last state on your desk after the stream ended; player-number lights that never lit on anything but a DualSense — more than twenty separate faults, across every client and both hosts. **Sound** got the same treatment: desktop audio is encoded at roughly double the bitrate, hosts stopped routing the entire game mix through Steam's voice channel on PCs that had it installed, and audio that drifts behind the picture now pulls itself back instead of staying late for the rest of the session. And the **black screen** that some people hit on VPN-shaped networks — a session that connects, reports every gauge healthy, and then shows nothing at all, forever — is finally diagnosed, explained in the log, and healed on its own. Alongside those: a security review closed 37 of 38 findings — a few of which need a moment of your attention, so the list is right below this; HDR finally works on a Steam Deck out of the box; the Windows client stops forgetting your settings when it isn't installed on C:; the library settings become one **Game sources** list with a Launchers row above your games; the Steam Deck plugin is rebuilt as a launcher into the app; holding Select on any controller presses the host's Guide button; and saved settings profiles can be pinned to hosts without a mouse.
## Before you update
A security review closed 37 of 38 findings this release, and a few of them change behaviour in ways you need to know about. Most people need to do nothing — but check this list if any of it applies to you.
- **Linux, if you use the virtual Steam Deck controller: you must join a new group.** The permission that lets the host create that emulated pad used to ride along with the `input` group, which every gamepad guide tells you to join — but it can emulate arbitrary USB hardware, so it now has a group of its own. Run `sudo usermod -aG punktfunk "$USER"` and log back in, or the virtual Deck pad will stop attaching after this update. Ordinary virtual gamepads are unaffected, and you should only join this group on a machine you trust.
- **Add-on interfaces now load on their own port.** An add-on's own interface used to run on the web console's address, which meant it could act with your logged-in permissions; it now runs on a separate port next to the console's (47993 by default). If you reach your console over a self-signed certificate, your browser needs to trust the new port once — the console shows a card explaining this with a link that opens it in a new tab. If you use a custom firewall or a reverse proxy, open or forward that second port. Fresh installs open it automatically on Windows, and the Linux firewall profiles include it. If the port cannot be opened at all, add-on interfaces switch off and say so rather than quietly moving back.
- **Saving a game with a custom launch command asks for your console password again.** A custom command runs on your machine as you, so it now re-confirms. Ordinary edits — title, artwork, platform, a normal Steam launch — are untouched.
- **Add-ons can no longer set launch or pre-launch commands themselves.** Those two fields run through a shell and are yours alone now; an add-on that tries is refused. If you use a third-party add-on that filled them in, it will need updating by its author, who should use the new "opens a launcher" entry type instead.
- **A new install now runs the add-on runner by default.** Game sources depend on it, and a host without it would show an empty library. Upgrades are untouched — if you deliberately switched it off, it stays off. You can still disable it and keep streaming; you only lose add-on game sources and automation.
- **If you installed on a Steam Deck with the setup script, consider rotating your console password.** It was written to a world-readable file. That is fixed, and the Fedora/RHEL hint that told you to read the password out of the system log — where anyone able to read logs could see it — is gone too.
## New
- **Your DualSense's own haptics, carried from the host.** Games that drive the DualSense's fine-grained voice coils — the detailed, textured feedback in the grips, as distinct from the coarse rumble motors — now carry that across the stream to the controller in your hands, and the pad's built-in speaker can be carried with it. It needs all of: a DualSense or DualSense Edge **plugged into your device by USB** (over Bluetooth the pad exposes no audio device to play into, so there is nothing this can do), a **Windows host with Steam installed** (the per-controller audio device is built on Valve's Remote Play streaming-speakers driver), and either the Android app or the desktop session client. Anywhere else — a Linux host, the iPhone/iPad/Mac app, the ordinary Windows or Linux desktop app, a Bluetooth pad — nothing changes at all. A game that uses only ordinary rumble keeps rumbling exactly as it does today. On Android the controls are **Controller haptics** and **Controller speaker** under Controllers, alongside a **Test haptics** button that checks your phone can drive the pad at all without needing a stream running. Expect a new playback device named "DualSense Wireless Controller" to appear in the host's Windows sound settings — that is this feature, it is how games find the controller's speaker, and it will not take over as your default output.
- **One "Game sources" list, and a Launchers row above your games.** The library settings used to show two separate boxes — a list of toggles for the built-in launcher scanners, and a second card for anything an add-on had synced in. They are now one list, where every source (Steam, Lutris, Heroic, Epic, GOG, Xbox, or one from an add-on) is a row with the same controls: on/off, how many games it contributes, a "show only these" filter, its own settings, and a remove option that offers to take its games with it. Add-on sources show a Running or Stopped badge so a dead one is obvious at a glance. Separately, entries can now be marked as opening a launcher rather than a game — Steam Big Picture, Steam desktop, Heroic, Lutris — and those group into a Launchers row above the game grid instead of getting lost among your titles. You can tick that yourself when adding or editing an entry. Update the host and console.
- **Game sources are becoming add-ons, and nothing about that is forced.** Each launcher is being turned into its own add-on, so you install only the ones you use and each gets its own settings. **All six built-in scanners still ship in the host, still switched on, and still find your games with nothing installed** — no one has to install anything to keep what they have. As replacement add-ons become available the console offers to move a source over, one button per source, never all at once and never automatically; a source keeps the same identity either way, so switching does not renumber your games, lose their artwork, or break pinned shortcuts. Half-moved is a perfectly valid state. In 0.25.0 this is groundwork: the replacement add-ons are not published yet, so expect the offer to appear as they arrive rather than on update day.
- **Hold Select to press the host's Guide button.** Hold Select (Back / View) on its own for about a third of a second and the host sees its Guide button go down — and it stays down while you hold, so a longer hold reads as a long-press on the host, which is how a big-screen host opens its Quick Access Menu. A quick tap of Select still goes to the game, and Select as part of a combo — including the leave chord — passes through untouched. It is on by default on iPhone, iPad and Apple TV, where the system keeps the controller's own Home press for itself and this is the only reliable route to the host's overlay. Everywhere else the raw press already reaches the host, so the gesture stays off by default and Select keeps its exact timing. Update the client.
- **Get onto a host by asking, instead of typing a PIN.** From the Steam Deck panel, tapping a locked host now offers **Request access**: the stream opens and waits while whoever is at the host approves your Deck in its console, then the picture comes up by itself. It gives up after about three minutes like any failed connection. Offered only for hosts visible on your network — one you saved by typing an address has no advertised identity to check against, so those still use a PIN, and the sheet says why. Update the plugin; hosts already knew how to approve.
- **Pin a settings profile to a host from a controller.** Every controller-driven settings screen — the Deck and Linux console home, the Apple app's gamepad UI including Apple TV, and the Android app's controller UI including Android TV — gains a **Profiles** section showing each profile and where it is pinned ("Not pinned", "Pinned to 2 hosts"). Open one and press A on a host to pin or unpin. On Apple TV this is the only profile management there has ever been; on Android, pinning previously needed a touchscreen. Creating and editing profiles is still a desktop or phone job. Update the client.
- **Pinned profiles appear as their own cards on the console home.** A pinned profile shows up as an extra card right after its host, subtitled with the profile's name, and one press connects using those settings. A host already bound to a profile now names it next to its address, so you can see which settings a plain press will use. Update the client.
- **A lost audio packet is rebuilt exactly instead of being papered over.** Each audio packet can carry a copy of the one before it, so a single loss is reconstructed bit-for-bit rather than concealed with a synthesized approximation you can hear. It costs no extra delay — the copy rides on a packet that was already arriving in time. Needs 0.25.0 on both ends; with either side older, audio goes over the wire exactly as it did before.
- **Audio quality is now budgeted against your connection.** The higher bitrate and the packet redundancy above are worth having on a roomy link and much too expensive on a narrow one, and audio is not managed by the Automatic bitrate control — whatever it takes comes off the top. The host now picks quality and redundancy together against the session's video bitrate: full quality plus redundancy where there is room, redundancy dropped first as the link narrows, then the quality tier, never below a floor. Update the host.
- **Turn on Sony USB passthrough from a TV.** The DualSense / DualShock USB toggle only ever existed on the touch settings screen, so on an Android TV box there was no way to reach it at all. It now sits on the controller-driven screen beside the Steam Controller toggle. Update the client.
- **Press the host's Steam and Quick Access buttons from the Deck panel.** While a stream is running the panel shows a **Host menus** section with **Steam menu on host** and **Quick access on host**; either one presses that button on the host and closes the Deck's own menu so the host's shows through. Update the plugin and the client.
- **Two new command-line tools.** `punktfunk discover` lists the hosts on your network with their addresses, whether you have already saved them and whether you are paired, with a `--json` mode for scripts. `punktfunk launch <host> --request-access` is the Request access flow above from a terminal, for admitting a headless machine without a PIN. Update the client.
- **Hosts on a jumbo-frame network can opt into much larger video packets.** On a LAN deliberately configured end to end for 9000-byte frames, the host can send roughly six times fewer packets per frame. It is off by default, is only applied after the host has proven the path really carries them and the client has agreed, and it reverts on its own if those packets start disappearing. This is not a general speed-up: on an ordinary network it does nothing.
## Improved
- **Desktop audio is encoded at roughly double the bitrate.** Streamed sound now runs at 256 kbps in stereo rather than 128 kbps, which costs about one percent of what the video is already using. Because Punktfunk sends very short audio frames to keep latency down, the old rate was leaving real quality on the table — most audibly on music. Update the host; every existing client already plays whatever arrives.
- **The black screen now heals in seconds, mid-stream.** With 0.25.0 on both ends, a host that detects a constrained network path re-sizes the video packets of the session you are already in, a few seconds after diagnosing it — the picture simply appears, without you reconnecting. With a 0.25.0 host and an older client you still get the fix below: the session in progress stays black, but the next connection is sized correctly and works.
- **Hosts you reach over a VPN show as online on the Steam Deck.** The panel's list merges what it finds on the network with the hosts you have saved and probes the saved ones directly, so a box that never advertises itself — over Tailscale, or on another subnet — reads as up instead of unreachable. Rows sort online first, then most recently streamed.
- **Waking a sleeping host from the Deck waits for it properly.** The panel used to send the wake-up and then guess how long to wait before dialling. It now waits for the host to actually answer.
- **Two new troubleshooting sections on audio.** One explains what the host actually captures and why streamed sound can be worse than what you hear on the host itself — naming the Steam Streaming Microphone trap explicitly and showing the log line that identifies it. The other covers audio that lags the picture, why it should now correct itself, and what to check when it does not.
- **The Android stats overlay stops charging your screen's own delay to the stream.** Its headline latency used to include the time Android itself takes to put a finished frame on the panel — a floor no streaming app can undercut, and easily over 30 ms on a 60 Hz phone. That now sits on its own line instead of inside the headline, matching how the iPhone, iPad and Apple TV clients have always reported it. **Your stream is exactly as fast as it was** — the headline number gets smaller because it finally measures only the part Punktfunk controls, which also makes it comparable across devices. The floor is measured on your device rather than assumed, and the full unshaved figures are still in the client's log. Update the client.
## Fixed
- **A host on a network that carries smaller packets than usual no longer streams a permanent black screen.** Everything small got through — the connection, your input, your sound — while every single video packet was slightly too big for one hop and died silently. The result connected fine, reported zero packet loss on the client, showed every gauge green on the host, and displayed nothing at all, with nothing written to either log to say why. The host now measures what the path to each client can really carry, warns with the actual diagnosis when it cannot carry full-size video, and remembers the measurement so the next connection from that client is sized to fit. The usual cause, and the one the warning names, is a VPN or overlay network adapter claiming the route. Update the host — this works with every client already out there.
- **Audio that falls behind the picture pulls itself back.** Every client kept a small buffer to absorb network jitter, and that buffer could only ever grow: one burst of Wi-Fi interference, one stutter on the host, or simply two devices' clocks running at fractionally different speeds pushed sound permanently behind the video, and the only cure was reconnecting. Android was worst, with no correction at all — it settled at its ceiling and stayed there for the whole session. All four clients now trim the buffer back a few milliseconds at a time under a crossfade, which is inaudible. Update the client; an older one keeps drifting no matter how new the host is.
- **The host stopped pushing your whole desktop mix through Steam's voice channel.** On a PC with Steam installed, the host was capturing Steam's Streaming Microphone device because it is silent on the host — but that device exists to carry voice, and if Windows had it set to mono or below 48 kHz, the entire game mix was squeezed through it before encoding, where no amount of bitrate could bring it back. A silent device now has to prove it can carry full-quality sound before being preferred over real hardware, and if nothing better exists the host says so in the log. Update the host.
- **Sound no longer cuts out over and over when something keeps changing your default playback device.** Some applications re-set the Windows default device every few seconds; each time, the host tore the whole capture down and rebuilt it, which is an audible dropout — one field log shows seven in sixteen seconds. The host now restores the default without dropping the stream, and if it happens repeatedly it stops fighting for a minute and says so once. Update the host.
- **Audio the host dropped internally is no longer silently glued over.** When the encoder fell behind, captured sound was discarded with nothing recording it: you heard a click, and everything after it stayed permanently shifted. Those drops are now counted and warned about, so a quiet host, a broken device and a stream damaging itself no longer look identical. Update the host.
- **Automatic bitrate stops sawtoothing when your device's decoder, not the network, is the limit.** The control loop has a mechanism for learning "this device cannot decode much past here, stop trying", and in practice it never once fired — one recording at 1440p120 shows it swinging between 220 and 450 Mb/s for nine solid minutes without ever learning the lesson. Three separate reasons it was unreachable are fixed, including one where a struggling decoder repeatedly asking for a fresh picture on an otherwise clean link was blamed on the network. Update the client.
- **Rumble stops fading in and out on a Steam Deck.** The Deck's motors need a fresh instruction every 40 ms or the repeat is discarded, and renewals kept colliding with that, stretching the real gap between motor writes to two and a half times what it should be — so sustained rumble came through weak and uneven. Update the client.
- **Rumble survives a hiccup instead of dying for the rest of the session.** On Android a single failure from the phone's vibration service silently killed the thread driving rumble, with nothing to notice or restart it, so rumble was gone until you restarted the app. And on every client, a stop instruction that never reached the controller used to be assumed to have worked — over USB there is no firmware timeout behind that, so a dropped stop left the motors running with nothing scheduled to try again. Update the client.
- **Two DualSenses stop rumbling for each other.** With two connected to an iPhone, iPad, Mac or Apple TV, both could end up driving the same physical controller, so one player's rumble came out of the other player's pad and the two fought over it. Each now drives its own. Very light rumble also stopped vanishing on that path — anything under about half a percent was being rounded away to nothing. Update the client.
- **A controller is handed back to you neutral when the stream ends.** Trigger resistance, lightbar colour and player lights live in the controller's own firmware, so they outlast the stream, the app, and even unplugging. Ending a session while a game held a weapon's trigger resistance left that trigger physically stiff on your desktop afterwards, with the lightbar still showing the game's last colour. Every client now releases both triggers, darkens the lightbar and clears the player lights on the way out — including on the exit paths that previously skipped it and left the pad buzzing after the stream was gone. Update the client.
- **A dropped lightbar or trigger change repairs itself instead of sticking.** These were sent once, when they changed, over packets that can be lost — so one lost packet could strand a controller on the previous weapon's trigger effect, or the last scene's lightbar colour, potentially for the rest of the level. The host now re-sends the current state once a second to repair it. Update the host.
- **A cut-off packet no longer cancels a trigger effect a game is holding.** A truncated adaptive-trigger packet decoded as an empty effect, and an empty effect is exactly what a controller reads as "let go" — so a weapon's resistance could silently vanish mid-fight. That shape is now rejected, while a genuine release still works.
- **Player-number lights work on controllers that are not a DualSense.** Xbox pads, Switch Pro controllers and everything else with player lights ignored the host's player number completely, so nothing lit at all. Update the client.
- **A centred stick reads as centred.** When the host presents your controller to games as a DualSense, DualSense Edge or DualShock 4, both sticks' vertical axes sat one step below true centre — a permanent, very slight downward pull, small enough to hide under most games' deadzones but plainly visible to any game reading the raw axis. Triggers on a controller presented as a Steam Deck pad also topped out just short of a full pull, so anything needing a genuine full press could never fire. Both are now exact. Update the host.
- **Two virtual controllers stop corrupting each other's rumble on a Windows host.** When a game drove two pads hard enough for their updates to overlap, two rumble instructions could be written into the same slot and arrive as one garbled instruction, or one could be skipped outright — and a skipped *stop* is the one that hurts, leaving the pad buzzing until a safety timer noticed the game had gone quiet. Update the host.
- **Delayed rumble effects fire at the right moment on a Linux host.** Games that schedule an effect to start after a short delay — routine for older Windows games running through Proton — had it start early and end early by the same amount, because the delay was read and then never applied. An effect still waiting its turn is also no longer cancelled by the idle safety-off before it has been felt. Update the host.
- **A controller driver that failed to attach no longer stalls the stream while the host works out why.** The diagnosis ran a slow system lookup on the very thread feeding controller input and rumble — up to two seconds per affected pad, at exactly the moment a session was already going wrong. It now runs in the background, and because it is off the critical path it can afford to wait long enough to report what it actually found. Update the host.
- **The Steam Deck keeps its trackpad mouse when a stream starts.** Starting a stream killed the built-in trackpad-as-mouse system-wide, and it only returned seconds later when the controller's own firmware watchdog restored it. Update the client.
- **Controller settings you cannot use no longer look live.** With "Forward controllers" off, the rows beneath it have nothing to act on, but on the Windows app and both controller-driven settings screens they stayed fully interactive — so you could sit there changing settings that did nothing. They are now dimmed until forwarding is back on. On Apple devices, starting a stream with forwarding off also stopped claiming every button's system gesture (which took away your screenshot and Home presses) and stopped powering up the controller's motion sensors for a stream that was not forwarding anything. Update the client.
- **HDR works on a Steam Deck straight out of the box.** Streaming an HDR game to a Deck gave a washed-out, tone-mapped picture with the overlay reporting a fall back to SDR. Punktfunk now ships everything it needs to talk to the Deck's Game Mode display pipeline, so a plain install is all it takes — there is no longer a separate piece to install by hand. Honest about what came before: that manual step was documented only in a comment inside the packaging, and even people who found it still got SDR, because the layer loaded and looked healthy while silently never engaging. One thing is still yours to do: HDR has to be switched on in Steam's own display settings, or nothing on the Deck gets it. Update the Flatpak client.
- **The Windows client saves your settings when it isn't installed on the C: drive.** On a PC set to install new apps to a second drive, the client streamed perfectly and then quietly forgot everything on restart — settings, connection profiles and your saved hosts all came back empty each launch, while the app showed the toggle you had just moved as though it had stuck. Every save was failing silently. Saving now works on those installs, and if the folder genuinely can't be written the client says so in a banner naming it rather than pretending. An update you declined also used to be offered again forever on these installs, for the same reason; that is fixed too. **You will need to set your preferences once more — nothing can be recovered, because it never reached the disk — and this time they will stay.** Update the client.
- **Recovering from a brief freeze no longer makes it worse.** When the host stalled for a moment — some AMD systems do this when a display drops to standby — the very large catch-up frame was pushed out in one burst that overflowed the network buffer and was lost, costing another round trip and another freeze. That frame is now sent at a pace the connection has already proven it can carry. Update the host.
- **A momentary stall no longer pins your stream at a low bitrate for minutes afterwards.** A window in which almost nothing arrived looked, to the quality logic, exactly like your device's decoder giving up — so it recorded a ceiling that was never real and then spent minutes climbing back toward it. Nearly-empty windows are no longer treated as evidence about your decoder. The stream still backs off for genuine trouble; it just stops drawing that conclusion from an interruption. Update the client.
- **The Steam Deck panel shows host names instead of addresses.** A host you saved by typing its address in was listed as that address, printed twice — once as the title and once underneath. Saved hosts that are online now show the name the machine actually advertises, and a name you chose yourself still wins and is never overwritten. Update the plugin.
- **"Recreate shortcuts" on the Deck actually recreates them.** After a plugin reinstall the Punktfunk entry could vanish from your Steam library and never return: the plugin always believed the old entry still existed, so recreating it reported success while doing nothing, and "Open Punktfunk" answered with "Game configuration unavailable". A stale entry is now detected and rebuilt on the next launch. The plugin also lists itself in Decky as "Punktfunk", capitalised properly. Update the plugin.
- **A leftover folder from an uninstalled Sunshine or Apollo is no longer treated as a conflict.** Both uninstallers leave a settings folder behind, and Punktfunk counted any trace at all — a leftover folder, a file on disk, a registered but switched-off background service — as a live clash. Affected machines warned on every start and showed a red card in the web console reading that another streaming server was running, when nothing was. Only a server that is genuinely running, or set to start on its own, counts now; the console names exactly what it saw, and leftovers appear in the full report under a heading saying they need no action. Update the host.
- **A crashed host gives you your screen back.** In Exclusive display mode the host switches your own monitors off for the length of a session and back on when it ends. If the host crashed or was killed mid-session that never happened — the desk simply stayed dark, no timeout brought it back, and the way out was Windows' own display shortcut or a reboot. The host now records which screens it is about to switch off before switching them off, and forces every connected display back on the next time it starts. Recovery happens at that next start, not on a timer: if the host stays down, the screen stays dark until it runs again. Update the host.
- **Camera look survives pressing Escape on an iPad.** Pressing Escape mid-stream made iPadOS hand the pointer back to the system, and Punktfunk never took it back. Clicks kept landing exactly where you aimed, so input looked fine — but the game stopped receiving mouse movement, so camera look was dead for the rest of the session. Clicking back into the video now takes the pointer again, and if the system refuses the first time, the next click tries again. Update the client.
- **"Open log folder" on Windows opens the log folder.** On installed builds it opened your Documents folder instead: the path the client handed to Explorer was correct to write to but did not exist as a real folder, and Explorer quietly fell back. The same wrong path appeared in the startup line naming the log file and in the message shown when a session fails to start. All three now point at the real folder. Update the client.
## If you stream from a Steam Deck
The Decky plugin has been rebuilt as a **launcher**. It no longer contains a second, separate streaming client; it is now a short list of your hosts plus one button into the Punktfunk app, which has the full controller-driven interface. This makes the plugin far smaller and means the Deck stops having two implementations of everything that could disagree with each other — but some things genuinely moved, and one was removed:
- **Settings moved** to **Open Punktfunk → Settings**. Same rows, same saved values, still fully controller-navigable.
- **Adding, renaming and forgetting hosts moved** to **Open Punktfunk → Add host**.
- **Browsing a host's games moved** to **Open Punktfunk → Library**.
- **Pinned Games has been removed, with nothing to migrate to yet.** The panel's one-tap "Stream *game*" rows are gone: pinning now works on a host and a settings profile rather than on a game. Your old pin file is deliberately left alone on disk so a later release can migrate it, but in 0.25.0 those rows do not appear.
- **The Deck's Steam and `…` buttons now stay with the Deck.** One press used to open both menus at once, the Deck's own covering the stream, because SteamOS reacts to those buttons whatever the app does. Reach the host's menus with hold-Select, or the panel's new **Host menus** buttons. To restore the old behaviour, set **Open Punktfunk → Settings → Steam / guide button** to **Send to host**.
- **The plugin needs the Punktfunk client on the Deck to be 0.22.0 or newer**, because it drives everything through the client. An older one is detected explicitly and the panel offers the update button that fixes it, rather than silently showing an empty list.
## Under the hood (for developers)
- **Wire protocol 2 — unchanged**, despite substantial growth, because every addition is optional or capability-gated. What grew without a bump: an optional trailing `max_shard_payload: u16` on `Hello` (absent/0 = legacy, and it doubles as both the renegotiation capability flag and the jumbo receive ceiling); two new control messages `ShardPayloadChanged` (`0x08`) and `ShardPayloadAck` (`0x09`); a redundant desktop-audio datagram tag `0xD2` alongside the plain `0xC9`; a controller-audio plane at `0xD1` (`[0xD1][u8 pad][u8 kind][u32 seq LE][u64 pts_ns LE][opus payload]`, which is why `0xD2` skipped that value); and `MAX_DATAGRAM_BYTES` 2048 → 9216.
- **C ABI 14 → 16**, in two steps. **15** is unusual: no code changed and no symbol was added with it. It retroactively versions the shared rumble policy engine's C surface — `punktfunk_connection_next_rumble_cmd`, `punktfunk_connection_set_rumble_quirks` and the `PUNKTFUNK_RUMBLE_QUIRK_*` bits — which shipped while the constant still read 7 and never got one, so every core since has exported those symbols while advertising a version that did not promise them. That cannot be fixed retroactively, so 15 is declared as the **floor that guarantees** the surface: at or above 15 it is present, below it an embedder must probe for the symbol. **16** adds the controller-audio surface and mirrors its two capability bits into the C ABI.
- **Breaking for C embedders: 149 unprefixed macros are now `PUNKTFUNK_`-prefixed** (139 `#define`s renamed in the checked-in header). Names as generic as `MAX_PADS`, `TAG_LEN`, `ABI_VERSION`, `WIRE_VERSION`, `INPUT_MAGIC` and the whole `BTN_*` / `AXIS_*` family were landing in the namespace of every program that included the header. Fixing it is mechanical — add the prefix, the values are identical — and there is **no silent breakage**: the old spellings cease to exist, so it is always an undeclared-identifier error, never a wrong value. That is precisely the failure it removes, since a colliding `#define` does not fail to compile; the preprocessor silently takes the last definition, so an embedder whose own header defined `MAX_PADS` previously got a wrong value at runtime. Associated constants are untouched — the generator already qualifies those with their type name. Scheduled for a release boundary deliberately; nothing in-tree used the old spellings but one Swift test, updated in the same commit.
- **Four new capability bits, and the video-caps byte did not overflow.** In the handshake's client/host capability bytes: client `0x04` / host `0x20` for the redundant desktop-audio plane ("can decode it" / "is sending it"), and client `0x08` / host `0x40` for controller audio (`CLIENT_CAP_PAD_AUDIO` / `HOST_CAP_PAD_AUDIO`, mirrored into the C ABI as `PUNKTFUNK_*` and asserted equal to their wire twins). The video-caps byte still carries exactly the eight bits it carried at 0.24.0 — no ninth cap, no second byte, so nothing forced an ABI bump from that direction.
- **Unchanged:** virtual-display driver protocol 6 (minimum accepted 3) and the Windows virtual-gamepad channel 3 — `crates/pf-driver-proto` is byte-for-byte identical to v0.24.0.
- **Adaptive-trigger effects are now length-bounded** on both encode and decode against one shared constant, with the header emitting `uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX]` in place of a literal `11` (same value, so the struct layout is byte-identical). A zero-length effect body is rejected rather than decoding as an empty — that is, a release — effect. Out-of-range pad indices are now dropped before either rumble consumer sees them; the reorder gate bounds-checked and the legacy queue did not, so an embedder draining it could be handed an index it would use to subscript its own array. The client also clamps the host's rumble lease receive-side at 5 s, where the existing ceiling was sender-side only.
- **Windows pad drivers publish their sequence counters with release ordering** (the host was already loading with acquire and pairing with nothing) and serialize the output-ring publish. The `/dev/uhid` event ABI, previously transcribed verbatim into all five Linux gamepad backends, is consolidated into one module with tests on the two accessors that had already drifted.
- **The controller-audio plane in detail.** `0xD1` carries one Opus frame per datagram behind a 15-byte header, with `PAD_AUDIO_KIND_HAPTICS = 0` (the pad's BACK channel pair — the voice coils — at 5 ms frames) and `PAD_AUDIO_KIND_SPEAKER = 1` (the FRONT pair, 10 ms). Best-effort like every audio plane: loss shows up as a sequence gap concealed by the gap tracker, and silence is a frozen sequence under the same mic-mute discipline, with the host gating at 60 dBFS on a 250 ms hangover. Alongside it, `HidOutput::AudioCtl` is a new `0xCD` kind `0x06` carrying the DualSense output report's volume/routing bytes, change-only and value-deduped — an older client drops it as an unknown kind. A client advertises per-pad intent through two new arrival flags (`1 << 8` haptics, `1 << 9` speaker), sent only toward a `HOST_CAP_PAD_AUDIO` host. **Capability-byte pressure is now worth watching:** `client_caps` has four bits free, but `host_caps` is down to its last one (`0x80`), and `video_caps` remains full from 0.23.0 — the standing "next video cap needs a second byte and an ABI bump" note still stands.
- **The controller-audio host gate is Windows-only and Steam-dependent.** `host_cap()` returns false unconditionally off Windows, and on Windows it still requires provisioning to have published at least one endpoint, which requires Valve's driver. The client only advertises its capability if a setting would actually render something, so a user with both toggles off never causes the host to provision anything. Note a real inconsistency to reconcile: the desktop session client defaults `pad_speaker` to `"pad"` (on) while Android defaults its speaker toggle to off, and the desktop side exposes these as serde-defaulted JSON keys with no settings UI at all. `pad_speaker = "mix"` is a declared TODO that logs once and behaves as `off`. The GameStream/Moonlight path always reports no pad-audio capability.
- **New host environment settings.** Controller audio: `PUNKTFUNK_PAD_AUDIO` (on unless set to `0`), `PUNKTFUNK_PAD_AUDIO_SLOTS` (default 1, max 4 — multi-pad needs an operator to raise it), and `PUNKTFUNK_PAD_AUDIO_STAMPS` (debug bisect hook), plus a `punktfunk-host pad-endpoint ensure|remove|status` devtest command. Audio: `PUNKTFUNK_AUDIO_QUALITY` (`low`/`standard`/`high`, default `high` = stereo 256 kbps; `standard` reproduces the pre-0.25 encoder exactly for an A/B, and a typo warns once rather than silently downgrading), `PUNKTFUNK_AUDIO_REDUNDANCY`, and `PUNKTFUNK_AUDIO_OUTPUT_MODE` (`client_only`/`host_and_client`/`follow_default`, default `client_only`, **Windows host only**). The legacy `PUNKTFUNK_HOST_AUDIO=1` and `PUNKTFUNK_KEEP_DEFAULT=1` still work, mapping to `host_and_client` and `follow_default`; `follow_default` wins if both are set. Wire: `PUNKTFUNK_WIRE_MTU` (pins on-wire IP MTU for all sessions; a value above 1500 also enables jumbo) and `PUNKTFUNK_JUMBO=1` (fixed 9000-MTU profile). All are documented on the troubleshooting page, not yet in the configuration reference.
- **Mid-session shard renegotiation is gated off for PyroWave sessions**, which parse the video stream in windows fixed at session start — re-sizing mid-stream would corrupt the parse. Those sessions get the next-session clamp only, and are excluded from jumbo. The decode-cap latch fix likewise does not apply to PyroWave, where adaptive bitrate is open-loop by design.
- **The Deck plugin's Python backend is now four thin shells over the `punktfunk` CLI** (`discover`, `hosts list --probe --json`, `pair`, `hosts add`); it parses no client data files and re-implements no client rules, and an outdated client reports itself deterministically as exit 5 + `unknown command "<verb>"` rather than being inferred from GTK startup noise. Host identity is matched by fingerprint first and address second in exactly one place, so a host that changed DHCP lease still matches its record while a different box inheriting the address does not inherit its pairing. `KnownHosts::read()` was split out of `load()` so `discover` can annotate against the store without minting-and-saving ids, which two parallel invocations could otherwise race.
- **The hold-Select gesture is one state machine** with unit tests in the shared client core, re-implemented to the same rules in the Apple capture layer and Android's router. A tapped Select is delivered on release with its release scheduled 50 ms behind, because a back-to-back down+up can otherwise fold into a single sequenced snapshot and vanish. `punktfunk-session` gained a per-user Unix control socket (`$XDG_RUNTIME_DIR[/app/$FLATPAK_ID]/punktfunk-session-ctl.sock`) with two verbs, `guide` and `qam` — the one runtime path a flatpak and the outside-the-sandbox Decky backend see identically.
- **Origin isolation for plugin UIs.** A second listener (default `PORT + 1`, `PUNKTFUNK_UI_PLUGIN_PORT`) serves `/plugin-ui/**` and nothing else; the console origin refuses those paths and the plugin origin refuses everything else, `/api/**` above all. Different origin (scheme+host+port) so same-origin policy *is* the boundary, same site so the `SameSite=Lax` session cookie still flows. Bind failure disables plugin UIs rather than falling back. `x-pf-listener` is stripped inbound and set by the entry, active ports are republished as `*_PORT_ACTIVE`, the plugin origin's CSP names the console as its only `frame-ancestors`, and the proxy allowlist drops the plugin's `Clear-Site-Data`, `Access-Control-Allow-Origin` and `Set-Cookie`. ⚠ The kit's `postMessage(..., "*")` is now load-bearing — narrowing it to `location.origin` would target the plugin's own origin and drop every message.
- **Authorization is an allowlist with a build-time gate.** `plugin_may_access` is a list of permitted `(method, path)` pairs with `{}` segment matching, enforced by a test that walks the live route table and **fails the build on any unclassified route** — the "block these" list it replaces let new endpoints through silently. Field authority is tracked separately from route reachability: requests carry the lane that authorized them, and `prep` / `launch.kind = "command"` are operator-token only. Art serving gained an extension whitelist plus magic-byte sniffing, canonicalize-or-refuse, UNC refusal, config-dir exclusion and root checking, with `file://` percent-decoded *before* canonicalization so `%2e%2e` cannot hide; roots default to the Windows users base and, new on POSIX, `$HOME` (`PUNKTFUNK_LIBRARY_ART_ROOTS`). Validation also runs at write time, so an unservable path can no longer be persisted.
- **Store claims keep identity across the scanner-to-plugin handover.** `library.json` gains a v2 `{entries, claims}` shape that reads the old bare array unchanged and rewrites on first mutation. `PUT /library/provider/{p}?store=<s>` claims a store; entries then surface as `<store>:<external_id>` rather than `custom:<id>`, so entry ids, GameStream app ids, client art caches and Moonlight pins all survive. One provider per store (409 otherwise); while a claim is held the matching built-in scanner is skipped, so the two never double-list. `GET/PUT /library/scanners` is now a sources endpoint over the same disabled-set file. New entry fields: `role: game|launcher`, and launch kinds `steam_ui` (`bigpicture|desktop`) and `launcher_ui` (platform-gated, 400 on invalid). Plugin kit 0.3.0 adds a `./library` subpath — `defineLibraryPlugin`, ported total parsers (text VDF/ACF, the binary `shortcuts.vdf` walker with CRC-32 appid derivation, read-only immutable SQLite, a registry wrapper that refuses HKCU, path-confinement joins), and `GET/PUT /__config` so a plugin with settings need not ship an SPA.
- **Build-container images now push to an authenticated registry endpoint**, and `:latest` is reconciled against the content key on every push to main — an out-of-band tag move is detected and repaired rather than silently inherited. This also fixes a long-standing bug where reverting a CI change left `:latest` pointing at the newer build forever.
- **The client's config writer** falls back to an in-place write when the atomic replace is unavailable, verifies it by reading the bytes back, and records the last persistence failure centrally so the UI can surface it. Scratch files are now per-process, closing a real collision between the five processes that write these stores (shell, session, console UI, CLI, Decky) — one could previously rename its half-written temp over another's target. The update-check bookkeeping was hand-rolling the same dance and now goes through that one writer.
- **Host send pacing** gained a pure, unit-tested budget function: oversized frames are budgeted at the pacing rate with a 100 ms absolute ceiling instead of being compressed into a single frame interval. Steady-state schedules are byte-identical, the legacy behaviour stays reachable via an environment escape hatch, and the GameStream-compatible path is untouched. The ABR decode-cap latch now ignores windows that delivered under a quarter of target without erasing a reference a genuine choke had set.
- **The Deck's Vulkan compatibility layer is built from source**, pinned to the same upstream revision as the host's own packaged build, so client and host come from one tree — bump both together. It is ~4 MB of app content in place of a 94 MB external extension users had to fetch themselves, and Flathub is no longer needed at install time. The old search-path override was deliberately dropped so two same-named layers cannot both load.
- **Verification is build-level.** Clippy and test gates on Linux, the Windows runner and macOS; the desktop-audio, packet-sizing and iPad pointer work has not been confirmed on glass in these commits. **Controller audio in particular has never run on a real DualSense** — it is a hardware feature whose entire verification to date is unit tests and compile checks, and its rumble arbitration rests on an explicitly retracted assumption about whether the voice coils and the rumble motors are the same actuators (the evidence-based 500 ms idle window is correct either way, but the underlying exclusivity is unsettled). Android's arbiter is the evidence-based one; the desktop twin and the coil restore on Android's stop path are both still owed. Some Android OEM kernels also refuse the isochronous claim outright, which degrades to ordinary rumble and is reported by the self test. Three more things in this release are reasoned-and-tested rather than observed: the plugin-UI origin split is validated against a fake console and a fake plugin, not yet in a real browser; the packaging default-on changes have had no installer run or package build; and no launcher tile has been clicked on a real host, the first source that would publish one not existing yet.
+6
View File
@@ -0,0 +1,6 @@
• New: a DualSense plugged in by USB can play the host's fine-grained haptics through the pad itself. Needs a Windows host with Steam installed.
• Sound that drifts behind the picture now catches itself up instead of staying late all session.
• Rumble no longer dies for the rest of the session after one glitch.
• Automatic bitrate stops overshooting what your device can really decode.
• Hold Select to reach the host's Guide menu.
• Pin your settings profiles to hosts from the TV interface.
+17
View File
@@ -98,6 +98,23 @@ if [ -n "$GAMESCOPE" ]; then
install -Dm0755 "$GAMESCOPE" "$STAGE/usr/bin/punktfunk-gamescope"
fi
# Enable the plugin/script runner for every user, by baking its `[Install] WantedBy=default.target`
# symlink straight into the image.
#
# A sysext carries only /usr, and RPM scriptlets never run from one — so the `systemctl --global
# enable` the .rpm/.deb do at install time has no equivalent here, and without this the runner would
# ship present-but-off on exactly the platform (Bazzite / Fedora Atomic) where an operator is least
# likely to go hunting for it. The game-library scanners are plugins now (design D9), so an
# unenabled runner means an empty library.
#
# Opt-out is unchanged and still wins: `systemctl --user mask punktfunk-scripting` in the user's own
# ~/.config/systemd/user takes precedence over anything under /usr.
if [ -f "$STAGE/usr/lib/systemd/user/punktfunk-scripting.service" ]; then
install -d "$STAGE/usr/lib/systemd/user/default.target.wants"
ln -sf ../punktfunk-scripting.service \
"$STAGE/usr/lib/systemd/user/default.target.wants/punktfunk-scripting.service"
fi
# Self-update: the helper rides inside the image.
install -Dm0755 "$HERE/punktfunk-sysext.sh" "$STAGE/usr/bin/punktfunk-sysext"
+23 -7
View File
@@ -114,20 +114,36 @@ Description: punktfunk plugin/script runner (Effect SDK on bun)
capped-jittered restart; SIGTERM shuts the whole tree down structurally so plugin finalizers run).
Bundles its own bun runtime (no system nodejs/bun dependency).
.
OPT-IN: the systemd --user unit is installed but not auto-enabled (the runner is inert until you add
scripts or plugins). A plugin auto-wires to the host's mgmt token + identity cert on the same box —
no env editing. Enable it with: systemctl --user enable --now punktfunk-scripting
ON BY DEFAULT: the systemd --user unit is enabled for every user (systemctl --global). The runner is
inert until you add scripts or plugins, and the game-library scanners now ship AS plugins — so a
host without the runner has an empty library and no obvious reason why. A plugin auto-wires to the
host's mgmt token + identity cert on the same box — no env editing.
Opt out per user with: systemctl --user mask punktfunk-scripting
EOF
cat > "$STAGE/DEBIAN/postinst" <<'EOF'
#!/bin/sh
set -e
if [ "$1" = "configure" ]; then
echo "punktfunk-scripting installed. It runs your automation — add scripts to"
# `--global`, not `--user`: a maintainer script has no user session to act on, and this is the
# only mechanism that makes a `--user` unit on-by-default for everyone (it symlinks into
# /etc/systemd/user/…wants/). The library's scanners are plugins now, so the runner is a default
# component rather than an add-on (design D9) — but installing it stays opt-OUT, and the opt-out
# is `systemctl --user mask punktfunk-scripting`, since a plain `--user disable` cannot remove a
# global symlink.
#
# Only on FIRST configure ($2 empty): re-running it on every upgrade would silently undo the
# mask of anyone who turned it off.
if [ -z "$2" ] && command -v systemctl >/dev/null 2>&1; then
systemctl --global enable punktfunk-scripting.service >/dev/null 2>&1 || true
fi
echo "punktfunk-scripting installed and enabled for all users."
echo "It runs your automation — game-library sources, scripts in"
echo " ~/.config/punktfunk/scripts/ (loose .ts/.js files)"
echo "or install plugins into ~/.config/punktfunk/plugins/ (bun add punktfunk-plugin-<name>),"
echo "then enable the runner for your user:"
echo " systemctl --user enable --now punktfunk-scripting"
echo "and plugins under ~/.config/punktfunk/plugins/."
echo "It starts with your next login; start it now with:"
echo " systemctl --user start punktfunk-scripting"
echo "Don't want it? systemctl --user mask punktfunk-scripting"
fi
exit 0
EOF
+114 -21
View File
@@ -85,28 +85,43 @@ finish-args:
# --- persistent client identity / pairing store (shared with punktfunk-probe) ---
- --filesystem=~/.config/punktfunk:create # client-{cert,key}.pem, known-hosts, settings
# --- HDR under gamescope (Steam Deck Game Mode) ---
# A flatpak's Vulkan loader can't see the host's gamescope WSI layer, so the SDL3 surface never
# offers the HDR10 (ST.2084) colorspace and the presenter silently tone-maps PQ->SDR — the
# Game-Mode HDR indicator stays dark (verified on a Deck OLED: the sandbox loader found NO
# frog/gamescope layer). The layer ships as the runtime extension
# `org.freedesktop.Platform.VulkanLayer.gamescope`, which org.gnome.Platform//50 auto-mounts at
# /usr/lib/extensions/vulkan/gamescope once installed (a one-time, per-Deck step; keep it in the
# Decky plugin's setup / docs):
# flatpak install --user -y flathub org.freedesktop.Platform.VulkanLayer.gamescope//25.08
# THREE things are needed, not two (verified live on a Deck OLED — the env vars alone left
# hdr10_format=None). (1) VK_ADD_IMPLICIT_LAYER_PATH puts the layer's implicit-layer JSON on the
# Vulkan loader's search path (the runtime point mounts the files but not onto the path). (2)
# ENABLE_GAMESCOPE_WSI flips the layer's own `enable_environment` gate. (3) The layer, once
# loaded, must open a *Wayland* connection to gamescope's private socket ($GAMESCOPE_WAYLAND_DISPLAY
# = gamescope-0) to negotiate the HDR10 colorspace via the gamescope_swapchain protocol — but the
# Deck runs games as X11 clients (DISPLAY=:1, no WAYLAND_DISPLAY exported), so --socket=wayland
# binds nothing and that socket never enters the sandbox. Without it the layer loads, maps, and
# silently can't reach the compositor → no HDR10 offered → PQ tone-mapped to SDR, badge dark.
# Binding xdg-run/gamescope-0 is the missing half (chiaki-ng does the same). With all three the
# surface offers HDR10 and the presenter's existing HDR10 swapchain path engages — no client code
# change. Harmless off-Deck: the layer no-ops when there's no gamescope socket to bind.
- --env=VK_ADD_IMPLICIT_LAYER_PATH=/usr/lib/extensions/vulkan/gamescope/share/vulkan/implicit_layer.d
# A flatpak's Vulkan loader can't see the host's gamescope WSI layer, so without help the SDL3
# surface never offers the HDR10 (ST.2084) colorspace and the presenter silently tone-maps
# PQ->SDR — the field-reported "HDR->SDR" badge. The layer is now VENDORED (see the
# gamescope-wsi-layer module below), so it is always present and there is no longer any
# manual `flatpak install ... VulkanLayer.gamescope` step for the user.
# FOUR things are needed. An earlier revision of this block claimed three and was WRONG: the
# fourth is the gate that makes the other three moot, so the Deck sat at hdr10_format=None with
# all of (1)-(3) in place, which is exactly the field report ("HDR->SDR" in the stats overlay).
# (1) the layer's implicit-layer JSON must be on the Vulkan loader's search path — now
# automatic, the vendored module installs it to /app/share/vulkan/implicit_layer.d which
# XDG_DATA_DIRS already covers. (2) ENABLE_GAMESCOPE_WSI
# flips the layer's own `enable_environment` gate. (3) --filesystem=xdg-run/gamescope-0 binds
# gamescope's private Wayland socket: the layer must reach the compositor over it to negotiate
# HDR10, and the Deck runs games as X11 clients (DISPLAY=:1, no WAYLAND_DISPLAY exported) so
# --socket=wayland binds nothing (chiaki-ng does the same). (4) GAMESCOPE_WAYLAND_DISPLAY must be
# set INSIDE the sandbox. The layer's `isRunningUnderGamescope()` reads that env var and nothing
# else; flatpak does not forward host env, so it arrives unset and the layer's CreateInstance
# early-returns before it ever creates a GamescopeInstance. The layer still LOADS and still logs
# its generic bits ("Forcing on VK_EXT_swapchain_maintenance1", swapchain destroys), which is why
# this reads as working — but no gamescope surface is made, so no HDR10 format is ever appended
# and (1)-(3) buy nothing. Measured on a Deck OLED (Galileo, SteamOS 3.8.16) 2026-08-05, client
# `--browse`, reading `pf_presenter::vk::setup` "swapchain config":
# unset -> no "[Gamescope WSI] Surface state" block at all, hdr10_format=None
# set, hdr_enabled=0 -> "server hdr output enabled: false", hdr10_format=None
# set, hdr_enabled=1 -> "hdr formats exposed to client: true",
# hdr10_format=Some(A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT)
# DXVK_HDR is NOT the gate for us and was ruled out by measurement: the layer forces it OFF for
# clients it has already decided to deny, it does not turn HDR on.
# Hardcoding `gamescope-0` matches the socket bound just below, and is safe off-Deck both ways:
# on a normal Wayland desktop --socket=wayland sets WAYLAND_DISPLAY=wayland-0 inside the sandbox
# and the layer bails on the mismatch; on X11-only there is no gamescope socket to connect to, so
# it prints one "Bypass layer will be unavailable" line and passes through.
# The REMAINING gate is not ours: gamescope's `hdr_enabled` convar (Steam's HDR display setting)
# drives the GAMESCOPE_HDR_OUTPUT_FEEDBACK X property the layer reads, and with it off no app on
# the Deck gets HDR. See docs — that one is a user/Decky-side step, not a packaging one.
- --env=ENABLE_GAMESCOPE_WSI=1
- --env=GAMESCOPE_WAYLAND_DISPLAY=gamescope-0 # the layer's ONLY "am I under gamescope?" signal
- --filesystem=xdg-run/gamescope-0 # gamescope's private Wayland socket (HDR negotiation)
build-options:
@@ -180,6 +195,84 @@ modules:
cleanup:
- '*'
# ---------------------------------------------------------------------------------------
# gamescope WSI layer — VENDORED, so HDR works from a plain `flatpak install` with no
# second step. This is the ONLY route to HDR on a Deck: measured on SteamOS 3.8.16
# (gamescope 3.16.23.4), the gamescope-0 socket advertises `gamescope_swapchain_factory_v2`
# but NOT `wp_color_manager_v1` (checked with HDR both off and on), so Mesa's Wayland WSI
# has no colour-management protocol to negotiate HDR10 through and only this layer can add
# the ST.2084 surface formats. Without it: zero `[Gamescope WSI]` lines, hdr10_format=None.
#
# It used to come from the flathub runtime extension
# `org.freedesktop.Platform.VulkanLayer.gamescope`, which every user had to install BY HAND
# (documented only in a comment here — so in practice nobody did, and the field report was
# "HDR->SDR" in the stats overlay). Vendoring instead of `add-extensions` autodownload,
# deliberately: that extension is 94 MB of whole-gamescope to deliver one 4 MB .so, its
# layer JSON hardcodes a /usr `library_path` that an app-scoped extension (mounted under
# /app) would not satisfy, and it would make flathub a hard install-time dependency of an
# app we self-host on flatpak.unom.io.
#
# Pinned to the SAME gamescope rev as packaging/gamescope/PKGBUILD (`_gsrev`) so the
# client's layer and the host's punktfunk-gamescope always come from one tree — bump both
# together. `enable_gamescope=false` skips subdir('src') and every compositor dependency
# (wlroots, SDL2, libliftoff, ...); only protocol/ and layer/ are built.
#
# `buildsystem: simple` rather than `meson` because two subprojects need their wrap
# `patch_directory` applied by hand: glm and stb ship NO meson.build of their own, and the
# one meson would normally inject lives in subprojects/packagefiles/. Cloning them as plain
# sources without that copy fails at configure with "Subproject exists but has no
# meson.build file". `--wrap-mode=nodownload` then proves the build is genuinely offline.
#
# The layer JSON is generated by meson from prefix+libdir, so it self-writes
# `library_path: /app/lib/libVkLayer_FROG_gamescope_wsi_x86_64.so` and lands in
# /app/share/vulkan/implicit_layer.d — already on the loader's search path via
# XDG_DATA_DIRS, which is why no VK_ADD_IMPLICIT_LAYER_PATH is needed (and why it was
# dropped from finish-args: pointing at the old /usr extension path too would risk
# double-loading two layers of the same name).
#
# Verified on a Deck OLED 2026-08-05: builds offline in org.gnome.Sdk//50, and the
# resulting .so drives the Deck's system gamescope to
# "hdr formats exposed to client: true" + hdr10_format=Some(...).
# ---------------------------------------------------------------------------------------
- name: gamescope-wsi-layer
buildsystem: simple
build-commands:
# Apply the wraps' patch_directory by hand (see above) — these supply the meson.build
# that glm and stb do not ship themselves.
- cp -r subprojects/packagefiles/glm/. subprojects/glm/
- cp -r subprojects/packagefiles/stb/. subprojects/stb/
- meson setup _build --prefix=/app --libdir=lib --wrap-mode=nodownload
-Denable_gamescope=false -Denable_gamescope_wsi_layer=true
-Denable_tests=false -Denable_openvr_support=false
- ninja -C _build
- ninja -C _build install
sources:
- type: git
url: https://github.com/ValveSoftware/gamescope.git
# KEEP IN SYNC with `_gsrev` in packaging/gamescope/PKGBUILD.
commit: 8c676c399c761e4540587f61004c957993d12fea
# Submodule + wrap pins as of that rev. `git ls-tree <rev> subprojects/` for the
# submodules; subprojects/*.wrap for the rest.
- type: git
url: https://github.com/Joshua-Ashton/vkroots.git
commit: 5106d8a0df95de66cc58dc1ea37e69c99afc9540
dest: subprojects/vkroots
- type: git
url: https://github.com/g-truc/glm.git
commit: 0af55ccecd98d4e5a8d1fad7de25ba429d60e863
dest: subprojects/glm
- type: git
url: https://github.com/nothings/stb.git
commit: 5736b15f7ea0ffb08dd38af21067c314d6a3aae9
dest: subprojects/stb
cleanup:
# Only the .so and its implicit-layer JSON are runtime. vkroots installs its dev files
# from the subproject, and the layer-only install still drops gamescope's display .lua
# scripts + LUT .cube files, none of which a client uses.
- /include
- /lib/pkgconfig
- /share/gamescope
# ---------------------------------------------------------------------------------------
# The client. cargo-sources.json is the GENERATED offline crate cache:
# python3 flatpak-cargo-generator.py Cargo.lock -o packaging/flatpak/cargo-sources.json
+19 -6
View File
@@ -191,9 +191,10 @@ The plugin/script runner for a punktfunk streaming host: it discovers loose scri
~/.config/punktfunk/scripts and installed punktfunk-plugin-* packages under ~/.config/punktfunk/
plugins, and supervises each as an Effect fiber (capped-jittered restart; SIGTERM shuts the whole
tree down structurally so plugin finalizers run). A plugin auto-wires to the host's mgmt token +
identity cert on the same box no env editing. Bundles its own bun runtime. OPT-IN: the systemd
--user unit ships disabled (the runner is inert until you add scripts/plugins). Enable with
`systemctl --user enable --now punktfunk-scripting`.
identity cert on the same box no env editing. Bundles its own bun runtime. ON BY DEFAULT: the
systemd --user unit is enabled for every user (systemctl --global). The game-library scanners ship
as plugins, so a host without the runner has an empty library. Opt out per user with
`systemctl --user mask punktfunk-scripting`.
%endif
%prep
@@ -599,10 +600,22 @@ echo "Then open https://<host-ip>:47992"
%if %{with scripting}
%post scripting
echo "punktfunk-scripting installed. It runs your automation add scripts to"
# `--global`, not `--user`: a scriptlet has no user session to act on, and this is the only
# mechanism that makes a `--user` unit on-by-default for everyone (it symlinks into
# /etc/systemd/user/…wants/). The game-library scanners are plugins now, so the runner is a default
# component rather than an add-on (design D9); it stays opt-OUT via
# `systemctl --user mask punktfunk-scripting`, since a plain `--user disable` cannot remove a global
# symlink. $1 == 1 is a first INSTALL — on an upgrade ($1 > 1) this must not undo an operator's mask.
if [ "$1" -eq 1 ] && command -v systemctl >/dev/null 2>&1; then
systemctl --global enable punktfunk-scripting.service >/dev/null 2>&1 || :
fi
echo "punktfunk-scripting installed and enabled for all users."
echo "It runs your automation game-library sources, scripts in"
echo " ~/.config/punktfunk/scripts/ (loose .ts/.js files)"
echo "or install plugins into ~/.config/punktfunk/plugins/ (bun add punktfunk-plugin-<name>),"
echo "then enable the runner: systemctl --user enable --now punktfunk-scripting"
echo "and plugins under ~/.config/punktfunk/plugins/."
echo "It starts with your next login; start it now with:"
echo " systemctl --user start punktfunk-scripting"
echo "Don't want it? systemctl --user mask punktfunk-scripting"
%endif
%changelog
+56 -3
View File
@@ -329,9 +329,8 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParam
; converges tasks an older installer registered as SYSTEM.
; Best-effort (-ErrorAction SilentlyContinue): a task hiccup never fails the whole install. No braces
; in the command, so no Inno {{ }} escaping needed.
Filename: "powershell.exe"; \
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$a=New-ScheduledTaskAction -Execute '{app}\scripting\scripting-run.cmd'; $t=New-ScheduledTaskTrigger -AtStartup; $p=New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p -Settings $s -Force -ErrorAction SilentlyContinue | Out-Null; Disable-ScheduledTask -TaskName PunktfunkScripting -ErrorAction SilentlyContinue | Out-Null"""; \
StatusMsg: "Registering the Punktfunk script runner (disabled; opt-in)..."; Flags: runhidden waituntilterminated
Filename: "powershell.exe"; Parameters: "{code:ScriptingRegisterParams}"; \
StatusMsg: "Registering the Punktfunk script runner..."; Flags: runhidden waituntilterminated
#endif
#if defined(WithWeb) || defined(WithScripting)
; Put back what StopBunRuntimes disabled to unlock bun.exe. Deliberately the LAST [Run] entry that
@@ -619,6 +618,12 @@ end;
it disabled would switch it off for everyone who had it on. }
var
WebTaskWasEnabled, ScriptingTaskWasEnabled: Boolean;
{ Did PunktfunkScripting exist AT ALL before this install (enabled or not)? That is what
distinguishes a FRESH scripting install — where the runner is now registered enabled by default
(design D9: the library moves into plugins, and a flagship surface cannot depend on an opt-in
subsystem, or a fresh box would come up with an empty library) — from an UPGRADE, where the
operator's own choice is the only thing that may decide it. }
ScriptingTaskExisted: Boolean;
{ Escape a value for embedding in a single-quoted PowerShell literal ('' is PS's escaped quote).
The install dir is user-chosen, so it can legitimately contain an apostrophe. }
@@ -643,6 +648,22 @@ begin
Result := ResultCode = 1;
end;
{ Is the task registered at all, whatever its state? Distinct from TaskEnabled: an operator who
deliberately DISABLED the runner must keep it disabled across an upgrade, which is indistinguishable
from a fresh install if you only ask "was it enabled". }
function TaskExists(TaskName: String): Boolean;
var
ResultCode: Integer;
begin
Result := False;
if Exec('powershell.exe',
'-NoProfile -ExecutionPolicy Bypass -Command "' +
'$t=Get-ScheduledTask -TaskName ''' + PsLiteral(TaskName) + ''' -ErrorAction SilentlyContinue; ' +
'if($t){exit 1}; exit 0"',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
Result := ResultCode = 1;
end;
{ Free the bundled bun.exe (and the console's own files) BEFORE the copy. Windows will not delete a
running image, so a surviving bun means "DeleteFile failed; code 5" on bun\bun.exe - the modal a
user hit updating to 0.22.1.
@@ -664,6 +685,9 @@ var
begin
WebTaskWasEnabled := TaskEnabled('PunktfunkWeb');
ScriptingTaskWasEnabled := TaskEnabled('PunktfunkScripting');
{ Probed BEFORE the Disable below, which would otherwise make every upgrade look like a fresh
install to the registration entry. }
ScriptingTaskExisted := TaskExists('PunktfunkScripting');
Exec('powershell.exe',
'-NoProfile -ExecutionPolicy Bypass -Command "' +
'$ErrorActionPreference=''SilentlyContinue''; ' +
@@ -689,6 +713,35 @@ end;
DELETED the legacy task (the console runs under the host service now), so Enable-ScheduledTask
hits nothing and no-ops under SilentlyContinue. If the user cancels mid-install, though,
DeinitializeSetup runs this same restore and puts the old (task-owned) world back intact. }
{ Register PunktfunkScripting, and decide whether it comes up ENABLED.
`Register-ScheduledTask` registers enabled, so the state is decided by what follows:
* FRESH install (the task did not exist) -> leave it enabled and start it now, so the runner is
live without waiting for a reboot. Since the library's scanners become plugins (design D9),
shipping this opt-in would mean a fresh box comes up with an empty library and no obvious
reason why.
* UPGRADE (the task existed) -> disable here and let RestoreTasksParams put the operator's own
state back. That order is deliberate: this entry cannot know what they chose, and defaulting
to "on" here would silently switch the runner on for everyone who had turned it off.
It remains opt-OUT: `punktfunk-host plugins disable`, or the task's own Disable, still wins and
survives every later upgrade through exactly this path. }
function ScriptingRegisterParams(Param: String): String;
begin
Result := '-NoProfile -ExecutionPolicy Bypass -Command "' +
'$ErrorActionPreference=''SilentlyContinue''; ' +
'$a=New-ScheduledTaskAction -Execute ''' +
PsLiteral(ExpandConstant('{app}\scripting\scripting-run.cmd')) + '''; ' +
'$t=New-ScheduledTaskTrigger -AtStartup; ' +
'$p=New-ScheduledTaskPrincipal -UserId ''LocalService'' -LogonType ServiceAccount; ' +
'$s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) ' +
'-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; ' +
'Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p ' +
'-Settings $s -Force | Out-Null; ';
if ScriptingTaskExisted then
Result := Result + 'Disable-ScheduledTask -TaskName PunktfunkScripting | Out-Null"'
else
Result := Result + 'Start-ScheduledTask -TaskName PunktfunkScripting | Out-Null"';
end;
function RestoreTasksParams(Param: String): String;
begin
Result := '-NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=''SilentlyContinue''; ';
+35
View File
@@ -53,6 +53,41 @@ export default definePluginKit({
| `loggingLayer` | runner-journal line format |
| `@punktfunk/plugin-kit/react` | browser glue: `createPluginRouter` (path→hash→fallback deep-link restore + `pf-ui:navigate`), `resolvePluginBase`, `useIsEmbedded`, `ResultGate`, `sseAtom` |
| `@punktfunk/plugin-kit/theme.css` | the console's violet identity for plugin UIs (import first in your Tailwind entry) |
| `@punktfunk/plugin-kit/library` | everything a **game-library scanner** plugin needs — see below |
## Library-scanner plugins (`@punktfunk/plugin-kit/library`)
The six first-party scanners (steam, lutris, heroic, epic, gog, xbox) each live in **their own
repo**, like every other punktfunk plugin. Nothing is lost by that split because everything they
share is published here rather than sitting adjacent to them:
| Export | What it saves you writing |
| --- | --- |
| `defineLibraryPlugin` | the whole plugin except the scan: store claim, sync engine (poll + fs-watch + debounce), launcher entries, `__config`, `category: "library"` registration, and the `detect` / `scan` / `parity` / `uninstall` CLI verbs |
| `parsers/*` | text VDF + `.acf`, binary `shortcuts.vdf` (with the CRC-32 appid and the 64-bit `rungameid` composition), read-only SQLite, `reg.exe`, capped readers, a confined path join, Steam root/library discovery, art location helpers, an anti-SSRF fetch |
| `diffParity` + the `parity` verb | the acceptance gate below |
A first-party scanner is therefore **its parsers and a `scan` function** — a few hundred lines.
### The parity gate
Ported unit tests pin the parsers; they do not prove the plugin reproduces the scanner it replaces.
A plugin that parses perfectly and emits `steam:440.0` instead of `steam:440` breaks every Moonlight
pin on the host, and no parser test notices. So, on a box with that launcher installed:
```sh
# 1. while the host is still using its BUILT-IN scanner:
punktfunk-plugin-steam parity --snapshot before.json
# 2. offline — runs this plugin's own scan and diffs:
punktfunk-plugin-steam parity --compare before.json
```
`--compare` exits non-zero on any difference, so it works as a release gate. It compares ids,
titles, launch recipes, roles and metadata exactly; **art by presence, not value** (the
representation legitimately changes — a host-relative proxy path or inlined `data:` URL becomes a
`file://` path or a CDN URL), so spot-check a few covers by eye once. Launcher entries the plugin
adds are reported separately rather than failing the run; an ordinary title the scanner never had
still fails.
## Telling the host how to recognize a running title (`detect`)
+168
View File
@@ -0,0 +1,168 @@
// A COMPLETE library-scanner plugin, and the template the six first-party ones are cut from.
//
// This is the lutris pilot (design M5/WP5.1) — the smallest of the six, and the one that exercises
// the POSIX local-art path end to end. It lives here as a worked example rather than shipped code:
// each scanner gets its OWN repo (the house pattern), and this is what you copy into a fresh one.
// `package.json`'s `files` is dist + README, so nothing here is published.
//
// The point it proves: everything below the `scan` function is store-specific parsing, and
// everything else — the store claim, the sync engine, launcher entries, `__config`, the console
// registration, the CLI verbs including the parity gate — comes from `defineLibraryPlugin`. That is
// what makes six repos cost nothing in duplication.
//
// Ported from crates/punktfunk-host/src/library/lutris.rs, with two deliberate changes:
// * art is emitted as `file://` URLs instead of inlined `data:` URLs. The host proxies the bytes,
// so the reconcile payload stays tiny — inlining covers is what blew the host's 2 MB body limit
// at 49 titles during the playnite work, and it is exactly why the POSIX art path exists (G4).
// * the `installed = 1` filter and the untrusted-slug guard are carried over verbatim. The slug
// comes from Lutris's own database and is interpolated into a path, so the guard is load-bearing.
import * as os from "node:os";
import * as path from "node:path";
import { Effect, Schema } from "effect";
import {
defineLibraryPlugin,
fileUrl,
isFile,
withReadOnlyDb,
} from "../src/library/index.js";
import type { ProviderEntry } from "../src/wire.js";
const LutrisConfig = Schema.Struct({
/**
* Where `pga.db` lives, when it isn't in one of the standard places. Annotated because the
* console's generic settings form derives its label and help text from exactly these.
*/
databasePath: Schema.optionalKey(
Schema.String.annotate({
title: "Lutris database",
description:
"Absolute path to pga.db. Leave empty to find it automatically.",
}),
),
});
/** Candidate `pga.db` locations: XDG data dir, the classic path, Flatpak. */
const databaseCandidates = (): string[] => {
const out: string[] = [];
const xdg = process.env.XDG_DATA_HOME;
if (xdg) out.push(path.join(xdg, "lutris/pga.db"));
const home = os.homedir();
if (home) {
out.push(path.join(home, ".local/share/lutris/pga.db"));
out.push(path.join(home, ".var/app/net.lutris.Lutris/data/lutris/pga.db"));
}
return out;
};
const findDatabase = (cfg: { databasePath?: string }): string | undefined =>
[...(cfg.databasePath ? [cfg.databasePath] : []), ...databaseCandidates()].find(
isFile,
);
/**
* `<kind>/<slug>.jpg` across the current, legacy-cache and Flatpak Lutris roots.
*
* The slug comes verbatim from Lutris's database and is interpolated into a path, so a separator,
* parent ref or NUL is refused otherwise a crafted slug is an arbitrary-file-read primitive, and
* the resulting path would be handed to the host's art proxy to serve (security-review 2026-07-17).
* Real Lutris slugs are `[a-z0-9-]`.
*/
const artFile = (kind: string, slug: string): string | undefined => {
if (
slug === "" ||
slug.includes("/") ||
slug.includes("\\") ||
slug.includes("..") ||
slug.includes("\0")
) {
return undefined;
}
const home = os.homedir();
if (!home) return undefined;
const roots = [
path.join(home, ".local/share/lutris"),
path.join(home, ".cache/lutris"),
path.join(home, ".var/app/net.lutris.Lutris/data/lutris"),
path.join(home, ".var/app/net.lutris.Lutris/cache/lutris"),
];
for (const root of roots) {
const p = path.join(root, kind, `${slug}.jpg`);
if (isFile(p)) return p;
}
return undefined;
};
interface GameRow {
id: number;
slug: string | null;
name: string;
directory: string | null;
}
export default defineLibraryPlugin({
// One string: plugin id, provider id, store claim, and the id of the built-in scanner this
// replaces. That identity chain is what keeps entry ids, GameStream app ids and the operator's
// existing enable/disable state intact across the migration.
name: "lutris",
configSchema: LutrisConfig,
detect: (cfg) => Effect.sync(() => findDatabase(cfg) !== undefined),
scan: (cfg) =>
Effect.sync(() => {
const db = findDatabase(cfg);
if (!db) return [];
// Read-only + immutable: a running Lutris holding the file can neither block us nor be
// disturbed by us.
const rows =
withReadOnlyDb(db, (h) =>
// `directory` is our only detect signal but is not load-bearing for the library, so
// a schema without it must not cost the whole source — the helper answers [] on a
// bad query, and the fallback keeps the titles.
h.query<GameRow>(
"SELECT id, slug, name, directory FROM games " +
"WHERE installed = 1 AND name IS NOT NULL AND name <> '' " +
"ORDER BY name COLLATE NOCASE",
),
) ?? [];
const usable =
rows.length > 0
? rows
: (withReadOnlyDb(db, (h) =>
h.query<GameRow>(
"SELECT id, slug, name, NULL AS directory FROM games " +
"WHERE installed = 1 AND name IS NOT NULL AND name <> '' " +
"ORDER BY name COLLATE NOCASE",
),
) ?? []);
return usable.map((row): ProviderEntry => {
const portrait = row.slug ? artFile("coverart", row.slug) : undefined;
const header = row.slug ? artFile("banners", row.slug) : undefined;
const dir = row.directory?.trim();
return {
// The host composes `lutris:<external_id>` — byte-identical to what the built-in
// scanner produced, which the parity gate checks.
external_id: String(row.id),
title: row.name,
launch: { kind: "lutris_id", value: String(row.id) },
art: {
...(portrait ? { portrait: fileUrl(portrait) } : {}),
...(header ? { header: fileUrl(header) } : {}),
},
// Lutris stamps no per-game env marker worth relying on, so the install dir is the
// whole recipe; a game with none (an emulator entry pointing at a bare ROM) stays
// untracked, exactly as it did in-host.
...(dir ? { detect: { install_dir: dir } } : {}),
platform: "PC",
};
});
}),
// Re-scan when Lutris writes: installing a game touches the database, and downloading art
// touches the cover directories.
watchDirs: (cfg) => {
const db = findDatabase(cfg);
return db ? [path.dirname(db)] : [];
},
});
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@punktfunk/plugin-kit",
"version": "0.2.0",
"version": "0.3.0",
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
"type": "module",
"license": "MIT OR Apache-2.0",
@@ -29,6 +29,10 @@
"types": "./dist/wire.d.ts",
"default": "./dist/wire.js"
},
"./library": {
"types": "./dist/library/index.d.ts",
"default": "./dist/library/index.js"
},
"./theme.css": "./dist/theme.css"
},
"files": ["dist", "README.md"],
+8 -1
View File
@@ -43,6 +43,13 @@ export {
type SyncSettings,
type SyncStatus,
} from "./sync-engine.js";
export { httpApiEnv, serveUi, type ServeUiOptions } from "./ui-server.js";
export {
deriveConfigJsonSchema,
httpApiEnv,
makeConfigHandler,
serveUi,
type ServeUiConfig,
type ServeUiOptions,
} from "./ui-server.js";
export { sseRoute, type SseRouteOptions } from "./sse.js";
export { type CliCommand, runPluginCli } from "./cli.js";
+335
View File
@@ -0,0 +1,335 @@
// `defineLibraryPlugin` — the shared framework behind every library-scanner plugin (design D10).
//
// The point of this module is that a first-party scanner should be **its parsers and a scan
// function**, ~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
@@ -0,0 +1,23 @@
// `@punktfunk/plugin-kit/library` — the shared framework for library-scanner plugins.
//
// A first-party scanner is its parsers plus a scan function; everything else (store claim, sync
// engine wiring, launcher entries, `__config`, nav category, CLI verbs) comes from
// `defineLibraryPlugin`. See design/library-scanner-plugins.md D10.
export {
defineLibraryPlugin,
type LibraryPlugin,
type LibraryPluginDef,
type ScanReport,
} from "./define.js";
export {
claimedLibraryId,
diffParity,
formatParityReport,
fromHostEntry,
fromProviderEntry,
type HostGameEntry,
type ParityChange,
type ParityEntry,
type ParityReport,
} from "./parity.js";
export * from "./parsers/index.js";
+249
View File
@@ -0,0 +1,249 @@
// The parity harness: proof that a library plugin reproduces the in-host scanner it replaces.
//
// This is the acceptance gate for every extracted scanner (design M5). Ported unit tests are
// necessary but nowhere near sufficient — they pin the PARSERS, while what actually has to hold is
// that the whole pipeline lands the same entries, with the same ids, launch recipes and detect
// signals, on a real box with a real launcher installed. A plugin that parses perfectly and emits
// `steam:440` as `steam:440.0` breaks every Moonlight pin on the host and no parser test notices.
//
// It lives in the KIT, not in a plugin, because it is identical for all six: capture what the host
// reports while its built-in scanner is doing the work, then check the plugin produces the same set.
// (One plugin per repo is the house pattern, so anything shared has to be published, not adjacent.)
//
// Usage, per plugin, on a box with that launcher installed:
//
// punktfunk-plugin-steam parity --snapshot before.json # host still on its built-in scanner
// punktfunk-plugin-steam parity --compare before.json # offline: runs THIS plugin's scan
//
// `--compare` runs the plugin's own scan directly rather than installing it first, so a mismatch is
// visible before anything is published — and the run is repeatable while you fix it.
import type { ProviderEntry } from "../wire.js";
/** The four art slots, in the order the host's box-art ladder tries them. */
const ART_KINDS = ["portrait", "hero", "logo", "header"] as const;
type ArtKind = (typeof ART_KINDS)[number];
/** One entry, reduced to the facts parity is about. */
export interface ParityEntry {
/** The store-qualified library id — the field everything downstream is keyed on. */
readonly id: string;
readonly title: string;
/** `<kind>:<value>`, or null when the entry has no launch recipe. */
readonly launch: string | null;
/** `"game"` or `"launcher"`. */
readonly role: string;
/**
* Which art kinds are PRESENT, not their values. The representation legitimately changes on
* extraction (a scanner's `data:` URL or host-relative proxy path becomes a `file://` path or a
* CDN URL), so comparing values would fail every time for no reason. Presence is the invariant
* that matters: a title that had a poster must still have one.
*/
readonly art: Readonly<Record<ArtKind, boolean>>;
/** Flat descriptive metadata (platform, genres, …) — compared verbatim. */
readonly meta: Readonly<Record<string, unknown>>;
}
/** What the host reports for one entry in `GET /library`. */
export interface HostGameEntry {
id: string;
store: string;
title: string;
role?: string;
launch?: { kind: string; value: string } | null;
art?: Partial<Record<ArtKind, string | null>>;
[extra: string]: unknown;
}
/** Keys on a host entry that are structure, not descriptive metadata. */
const NON_META = new Set([
"id",
"store",
"title",
"role",
"launch",
"art",
"provider",
"external_id",
"prep",
"detect",
]);
const artPresence = (
art: Partial<Record<ArtKind, string | null>> | undefined,
): Record<ArtKind, boolean> => {
const out = {} as Record<ArtKind, boolean>;
for (const k of ART_KINDS) out[k] = Boolean(art?.[k]);
return out;
};
const pickMeta = (src: Record<string, unknown>): Record<string, unknown> => {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(src)) {
// Absent and empty are the same thing here: the host omits empty lists and null fields, and a
// plugin that sends `genres: []` has not changed anything.
if (NON_META.has(k) || v == null) continue;
if (Array.isArray(v) && v.length === 0) continue;
out[k] = v;
}
return out;
};
/** The library id the host assigns a claimed entry — the deterministic `<store>:<external_id>`. */
export const claimedLibraryId = (store: string, externalId: string): string =>
`${store}:${externalId}`;
/** Reduce what the host reported (the BEFORE side) to a comparable entry. */
export const fromHostEntry = (e: HostGameEntry): ParityEntry => ({
id: e.id,
title: e.title,
launch: e.launch ? `${e.launch.kind}:${e.launch.value}` : null,
role: e.role ?? "game",
art: artPresence(e.art),
meta: pickMeta(e as Record<string, unknown>),
});
/** Reduce what this plugin produced (the AFTER side) to a comparable entry. */
export const fromProviderEntry = (
store: string,
e: ProviderEntry,
): ParityEntry => {
const rec = e as unknown as Record<string, unknown>;
return {
id: claimedLibraryId(store, e.external_id),
title: e.title,
launch: e.launch ? `${e.launch.kind}:${e.launch.value}` : null,
role: (e as { role?: string }).role ?? "game",
art: artPresence(
e.art as Partial<Record<ArtKind, string | null>> | undefined,
),
meta: pickMeta(rec),
};
};
/** One field that differs between the two sides. */
export interface ParityChange {
readonly id: string;
readonly field: string;
readonly before: unknown;
readonly after: unknown;
}
export interface ParityReport {
/** In the baseline, absent from what the plugin produced — the plugin LOST a title. */
readonly missing: ParityEntry[];
/** Produced by the plugin, absent from the baseline — the plugin invented a title. */
readonly extra: ParityEntry[];
/** Same id, different facts. */
readonly changed: ParityChange[];
/** Entries present on both sides and identical. */
readonly matched: number;
/**
* Launcher entries the plugin adds (design D4). Never a failure: the built-in scanner had no
* concept of them, so they are expected to be `extra` and are reported separately so a real
* regression isn't buried under them.
*/
readonly launchersAdded: ParityEntry[];
readonly ok: boolean;
}
/**
* Diff a baseline (what the host reported while its built-in scanner ran) against what this plugin
* produced. `ok` is true only when nothing is missing, nothing unexpected is extra, and no compared
* field changed.
*/
export const diffParity = (
baseline: ReadonlyArray<ParityEntry>,
produced: ReadonlyArray<ParityEntry>,
): ParityReport => {
const byId = new Map(baseline.map((e) => [e.id, e]));
const producedIds = new Set(produced.map((e) => e.id));
const changed: ParityChange[] = [];
const extra: ParityEntry[] = [];
const launchersAdded: ParityEntry[] = [];
let matched = 0;
for (const after of produced) {
const before = byId.get(after.id);
if (!before) {
// A launcher entry has no counterpart by construction — the scanner never emitted one.
(after.role === "launcher" ? launchersAdded : extra).push(after);
continue;
}
const diffs = compareEntry(before, after);
if (diffs.length === 0) matched++;
else changed.push(...diffs);
}
const missing = baseline.filter((e) => !producedIds.has(e.id));
return {
missing,
extra,
changed,
matched,
launchersAdded,
ok: missing.length === 0 && extra.length === 0 && changed.length === 0,
};
};
const compareEntry = (
before: ParityEntry,
after: ParityEntry,
): ParityChange[] => {
const out: ParityChange[] = [];
const note = (field: string, b: unknown, a: unknown) =>
out.push({ id: before.id, field, before: b, after: a });
if (before.title !== after.title) note("title", before.title, after.title);
if (before.launch !== after.launch)
note("launch", before.launch, after.launch);
if (before.role !== after.role) note("role", before.role, after.role);
for (const k of ART_KINDS) {
// Only a LOST art kind is a regression. Gaining one is an improvement (the plugin can reach
// art the host never resolved), and failing a run over it would just train people to ignore
// the harness.
if (before.art[k] && !after.art[k]) note(`art.${k}`, true, false);
}
const keys = new Set([
...Object.keys(before.meta),
...Object.keys(after.meta),
]);
for (const k of keys) {
const b = before.meta[k];
const a = after.meta[k];
if (JSON.stringify(b) !== JSON.stringify(a)) note(`meta.${k}`, b, a);
}
return out;
};
/** Render a report for a terminal. Empty-ish when everything matched. */
export const formatParityReport = (r: ParityReport): string => {
const lines: string[] = [];
lines.push(
r.ok
? `parity OK — ${r.matched} entries identical`
: `parity FAILED — ${r.matched} identical, ${r.missing.length} missing, ${r.extra.length} unexpected, ${r.changed.length} changed`,
);
for (const e of r.missing) lines.push(` missing: ${e.id} ${e.title}`);
for (const e of r.extra) lines.push(` extra: ${e.id} ${e.title}`);
for (const c of r.changed) {
lines.push(
` changed: ${c.id} ${c.field}: ${JSON.stringify(c.before)} -> ${JSON.stringify(c.after)}`,
);
}
if (r.launchersAdded.length > 0) {
lines.push(
` (+${r.launchersAdded.length} launcher ${r.launchersAdded.length === 1 ? "entry" : "entries"}, expected: ${r.launchersAdded
.map((e) => e.id)
.join(", ")})`,
);
}
// Art REPRESENTATION always changes on extraction (a host-relative proxy path or an inlined
// `data:` URL becomes a `file://` path or a CDN URL). Presence is what this harness checks, so
// say plainly that the bytes still want a human's eyes once.
if (r.ok) {
lines.push(
" note: art is compared by presence, not value — spot-check a few covers render.",
);
}
return lines.join("\n");
};
+120
View File
@@ -0,0 +1,120 @@
// Where a title's cover art lives: Steam's local caches, its per-account `grid/` overrides, and the
// public CDN. Ported from the host scanner's art resolution (steam.rs).
//
// After extraction a plugin emits art VALUES and the host serves them: a `file://` URL for anything
// on disk (the documented local-art contract — the host proxies the bytes), or an absolute CDN URL
// the client fetches itself. `data:` URLs remain legal but are small-logo-only: inlining covers is
// what blew the host's 2 MB body limit at 49 titles during the playnite work.
import * as path from "node:path";
import { isFile, listDir } from "./fs.js";
/** The four art slots the library model carries. */
export type ArtKind = "portrait" | "hero" | "logo" | "header";
export const ART_KINDS: readonly ArtKind[] = [
"portrait",
"hero",
"logo",
"header",
];
/** A `file://` URL for a local path — the shape the host's art proxy understands. */
export const fileUrl = (p: string): string => {
// Percent-encode, but keep the separators: the host converts this back to a path and expects the
// structure intact. Windows drive paths become `file:///C:/…`.
const abs = path.resolve(p);
const posix = abs.replace(/\\/g, "/");
const encoded = posix
.split("/")
.map((seg) => encodeURIComponent(seg))
.join("/");
return posix.startsWith("/") ? `file://${encoded}` : `file:///${encoded}`;
};
/**
* The legacy flat CDN URL for a Steam appid's art kind. Correct for the many titles Valve hasn't
* re-hashed; newer ones serve from an unpredictable per-asset-hash path, where this 404s and the
* client falls through to its next candidate. That degradation is intentional and pre-existing.
*/
export const steamCdnUrl = (appid: number, kind: ArtKind): string | undefined => {
// A non-Steam shortcut's appid has the high bit set and is never a real store appid — the CDN
// would only 404, so don't emit a URL that is guaranteed to fail.
if ((appid & 0x8000_0000) !== 0) return undefined;
const file =
kind === "portrait"
? "library_600x900.jpg"
: kind === "hero"
? "library_hero.jpg"
: kind === "logo"
? "logo.png"
: "header.jpg";
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/${file}`;
};
/** Filenames Steam's local `librarycache` uses per kind, in preference order (2x is sharper). */
const localFilenames = (kind: ArtKind): string[] =>
kind === "portrait"
? ["library_600x900_2x.jpg", "library_600x900.jpg"]
: kind === "hero"
? ["library_hero.jpg"]
: kind === "logo"
? ["logo.png"]
: // Steam's local cache names the header asset differently from the store CDN's
// `header.jpg` — this trips everyone once.
["library_header.jpg"];
/**
* This kind's file under one Steam root's `appcache/librarycache/<appid>/<hash>/`, or `undefined`.
* Steam reuses one hash dir per asset version, so there is normally exactly one candidate.
*/
export const findLocalArtFile = (
root: string,
appid: number,
kind: ArtKind,
): string | undefined => {
const base = path.join(root, "appcache", "librarycache", String(appid));
for (const hash of listDir(base)) {
for (const name of localFilenames(kind)) {
const p = path.join(base, hash, name);
if (isFile(p)) return p;
}
}
// Older Steam wrote the files directly under `librarycache/` with the appid in the name.
for (const name of localFilenames(kind)) {
const flat = path.join(root, "appcache", "librarycache", `${appid}_${name}`);
if (isFile(flat)) return flat;
}
return undefined;
};
/**
* The `grid/` basenames Steam names each art kind under for an appid: portrait `<A>p`, hero
* `<A>_hero`, logo `<A>_logo`, wide capsule `<A>` each as `.png` then `.jpg`.
*
* These overrides are the **only** art a non-Steam shortcut ever has.
*/
export const gridFilenames = (appid: number, kind: ArtKind): string[] => {
const base =
kind === "portrait"
? `${appid}p`
: kind === "hero"
? `${appid}_hero`
: kind === "logo"
? `${appid}_logo`
: `${appid}`;
return [`${base}.png`, `${base}.jpg`];
};
/** This kind's user override under a `userdata/<id>/config/grid/` dir, or `undefined`. */
export const findGridArtFile = (
configDir: string,
appid: number,
kind: ArtKind,
): string | undefined => {
const grid = path.join(configDir, "grid");
for (const name of gridFilenames(appid, kind)) {
const p = path.join(grid, name);
if (isFile(p)) return p;
}
return undefined;
};
+112
View File
@@ -0,0 +1,112 @@
// Bounded filesystem reads and path confinement — the posture the in-host scanners established,
// ported so a library plugin inherits it instead of re-deriving it.
//
// The rules here exist because a plugin reads files it does not own: a launcher's manifests, a
// catalog cache, a `goggame-*.info` a user could have edited. None of that is hostile in the normal
// case, and all of it is untrusted in the case that matters.
import * as fs from "node:fs";
import * as path from "node:path";
/** A launcher manifest / `.acf` / `.info`: text, small. Matches `epic.rs`'s posture. */
export const MAX_MANIFEST_BYTES = 1024 * 1024;
/** A binary catalog cache (Epic's `catcache.bin`, a `shortcuts.vdf`): larger, still bounded. */
export const MAX_CACHE_BYTES = 32 * 1024 * 1024;
/**
* Read a file as UTF-8, refusing anything over `max`. `undefined` on any error, a non-regular file,
* or an over-cap file a plugin scanning a directory must never die on one odd entry.
*
* The size is checked by `stat` BEFORE the read, so an enormous file costs a stat, not the memory.
*/
export const readTextCapped = (
file: string,
max = MAX_MANIFEST_BYTES,
): string | undefined => {
try {
const st = fs.statSync(file);
if (!st.isFile() || st.size === 0 || st.size > max) return undefined;
return fs.readFileSync(file, "utf8");
} catch {
return undefined;
}
};
/** Read a file as bytes, refusing anything over `max`. Same posture as {@link readTextCapped}. */
export const readBytesCapped = (
file: string,
max = MAX_CACHE_BYTES,
): Uint8Array | undefined => {
try {
const st = fs.statSync(file);
if (!st.isFile() || st.size === 0 || st.size > max) return undefined;
return new Uint8Array(fs.readFileSync(file));
} catch {
return undefined;
}
};
/** Read + `JSON.parse` a capped text file. `undefined` on any read or parse failure. */
export const readJsonCapped = <T = unknown>(
file: string,
max = MAX_MANIFEST_BYTES,
): T | undefined => {
const text = readTextCapped(file, max);
if (text === undefined) return undefined;
try {
return JSON.parse(text) as T;
} catch {
return undefined;
}
};
/** List a directory's entry names, or `[]` if it isn't readable. */
export const listDir = (dir: string): string[] => {
try {
return fs.readdirSync(dir);
} catch {
return [];
}
};
/** Does this path exist as a directory? */
export const isDir = (p: string): boolean => {
try {
return fs.statSync(p).isDirectory();
} catch {
return false;
}
};
/** Does this path exist as a regular, non-empty file? */
export const isFile = (p: string): boolean => {
try {
const st = fs.statSync(p);
return st.isFile() && st.size > 0;
} catch {
return false;
}
};
/**
* Join `rel` onto `base` **only if it cannot escape** the port of the host's `confined_join`
* (gog.rs), which exists because a crafted `goggame-<id>.info` could otherwise point a play task's
* exe at an arbitrary program (security-review 2026-07-17).
*
* Refuses any relative path carrying a drive prefix (`C:`), a root (`/` or `\`), or a `..`
* component each of which `path.join` would let REPLACE or climb out of `base`. `undefined`
* out of bounds, and the caller must refuse the launch rather than fall back to something plausible.
*/
export const confinedJoin = (base: string, rel: string): string | undefined => {
if (rel === "") return undefined;
// Normalize separators so a Windows-shaped relative path is checked on any platform (a plugin
// may parse a Windows manifest while its tests run on Linux).
const parts = rel.split(/[\\/]/);
if (parts[0] === "" ) return undefined; // rooted
if (/^[A-Za-z]:$/.test(parts[0])) return undefined; // drive prefix
if (parts.some((p) => p === "..")) return undefined; // traversal
const joined = path.join(base, ...parts.filter((p) => p !== "" && p !== "."));
// Belt and braces: the component check above is the real guard, but a symlink-free string check
// costs nothing and catches anything the split missed.
const rootWithSep = base.endsWith(path.sep) ? base : base + path.sep;
return joined === base || joined.startsWith(rootWithSep) ? joined : undefined;
};
+94
View File
@@ -0,0 +1,94 @@
// The one outbound-HTTP helper a library plugin should use, carrying the host's `fetch_image`
// posture verbatim (art.rs): http(s) only, **no redirects**, a size cap, and a short timeout.
//
// The no-redirect rule is the important one and it is not paranoia: a scanner fetches URLs it read
// out of a launcher's cache — data the plugin did not author. A `3xx` chased automatically is an
// SSRF pivot from a process running on the operator's box (`http://169.254.169.254/…`, an internal
// service). The host learned this in the 2026-07-17 security review; a plugin fetching the same
// class of URL inherits the same rule. A rare legitimately-redirecting CDN just yields no art.
import { HostRequestError } from "../../errors.js";
import { Effect } from "effect";
export interface FetchLimits {
/** Hard cap on the response body. Default 8 MiB — a cover never approaches it. */
readonly maxBytes?: number;
/** Wall-clock timeout in ms. Default 10 000. */
readonly timeoutMs?: number;
}
const DEFAULT_MAX = 8 * 1024 * 1024;
const DEFAULT_TIMEOUT = 10_000;
export interface FetchedBytes {
readonly bytes: Uint8Array;
readonly contentType: string;
}
/**
* GET an `http(s)` URL under the posture above. Fails with {@link HostRequestError} on any non-2xx,
* a redirect, an over-cap body, a timeout, or a non-http(s) scheme.
*
* Most scanners never need this: they emit CDN URLs and let the CLIENT fetch them, which is both
* faster and keeps the host out of the loop. Reach for it only when a store's art requires an API
* lookup the client cannot do (GOG's product API, Microsoft's display catalog).
*/
export const fetchBytes = (
url: string,
limits: FetchLimits = {},
): Effect.Effect<FetchedBytes, HostRequestError> =>
Effect.tryPromise({
try: async (): Promise<FetchedBytes> => {
if (!/^https?:\/\//i.test(url)) {
throw new Error("only http(s) URLs may be fetched");
}
const maxBytes = limits.maxBytes ?? DEFAULT_MAX;
const signal = AbortSignal.timeout(limits.timeoutMs ?? DEFAULT_TIMEOUT);
// `redirect: "manual"` rather than "error": we want to SEE the 3xx and report it as a
// refusal, not have fetch throw something opaque.
const res = await fetch(url, { redirect: "manual", signal });
if (res.status >= 300 && res.status < 400) {
throw new Error(`refusing to follow a ${res.status} redirect`);
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// Trust Content-Length when it is there (cheap rejection), but still bound the read: a
// hostile server can lie about it or omit it entirely.
const declared = Number(res.headers.get("content-length"));
if (Number.isFinite(declared) && declared > maxBytes) {
throw new Error(`body larger than ${maxBytes} bytes`);
}
const buf = new Uint8Array(await res.arrayBuffer());
if (buf.byteLength === 0) throw new Error("empty body");
if (buf.byteLength > maxBytes) {
throw new Error(`body larger than ${maxBytes} bytes`);
}
return {
bytes: buf,
contentType: res.headers.get("content-type") ?? "image/jpeg",
};
},
catch: (cause) =>
new HostRequestError({
method: "GET",
path: url,
cause,
}),
});
/** {@link fetchBytes}, JSON-decoded. Same posture; use for a store's public product API. */
export const fetchJson = <T = unknown>(
url: string,
limits: FetchLimits = {},
): Effect.Effect<T, HostRequestError> =>
fetchBytes(url, limits).pipe(
Effect.flatMap((r) =>
Effect.try({
try: () => JSON.parse(new TextDecoder().decode(r.bytes)) as T,
catch: (cause) =>
new HostRequestError({
method: "GET",
path: url,
cause,
}),
}),
),
);
+61
View File
@@ -0,0 +1,61 @@
// The launcher-file parsing toolkit: what the six in-host scanners hand-rolled, hoisted so a
// library plugin is its scan function and nothing else.
//
// Everything here is total — a missing launcher, a truncated file, a schema drift in a launcher
// upgrade all degrade to "no titles from this source", never to a thrown error. A scanner that dies
// on one odd file takes the user's whole library with it.
export {
ART_KINDS,
type ArtKind,
fileUrl,
findGridArtFile,
findLocalArtFile,
gridFilenames,
steamCdnUrl,
} from "./art.js";
export {
confinedJoin,
isDir,
isFile,
listDir,
MAX_CACHE_BYTES,
MAX_MANIFEST_BYTES,
readBytesCapped,
readJsonCapped,
readTextCapped,
} from "./fs.js";
export {
type FetchedBytes,
type FetchLimits,
fetchBytes,
fetchJson,
} from "./http.js";
export {
parseRegQuery,
regQueryValue,
regQueryValues,
regSubKeys,
type RegValue,
validRegKey,
} from "./registry.js";
export { openReadOnly, type ReadOnlyDb, withReadOnlyDb } from "./sqlite.js";
export {
crc32,
parseShortcuts,
type Shortcut,
shortcutAppId,
shortcutGameId,
} from "./shortcuts.js";
export {
steamLibraryDirs,
steamRoots,
steamUserConfigDirs,
} from "./steam-root.js";
export {
type AppManifest,
isSteamTool,
parseAppManifest,
vdfField,
vdfPaths,
vdfValue,
} from "./vdf.js";
@@ -0,0 +1,94 @@
// Windows registry reads by spawning `reg.exe query` — dependency-free, and (the part that
// matters) it works from the scripting runner's LocalService account.
//
// **HKLM only, by design.** The runner runs as `NT AUTHORITY\LocalService` on Windows, which has no
// user profile: HKCU is not the operator's hive there, it is LocalService's own — so a plugin that
// read HKCU would silently see an empty registry rather than the user's launcher config. Every
// launcher fact a scanner needs (Steam's InstallPath, GOG's game list) lives under HKLM
// `WOW6432Node` anyway. Asking for HKCU is a bug, so this refuses it outright.
import { spawnSync } from "node:child_process";
/** One `reg.exe query` value row. */
export interface RegValue {
readonly name: string;
/** `REG_SZ`, `REG_DWORD`, … */
readonly type: string;
readonly data: string;
}
const HKLM = "HKLM\\";
/** Is this a key path this module will touch? See the module docs on why HKLM only. */
export const validRegKey = (key: string): boolean =>
key.startsWith(HKLM) &&
key.length > HKLM.length &&
key.length <= 260 &&
!key.includes("..") &&
// `reg.exe` takes the key as one argv element (no shell), but keep the charset tame anyway so a
// malformed key can never turn into a switch.
!key.startsWith("/") &&
!/[\r\n\0"]/.test(key);
const run = (args: string[]): string | undefined => {
if (process.platform !== "win32") return undefined;
const r = spawnSync("reg.exe", args, {
encoding: "utf8",
windowsHide: true,
// A registry read is instant; a hang means something is badly wrong and a scan must not
// block on it forever.
timeout: 10_000,
maxBuffer: 4 * 1024 * 1024,
});
if (r.status !== 0 || typeof r.stdout !== "string") return undefined;
return r.stdout;
};
/**
* The values directly under one HKLM key. `[]` when the key is absent, unreadable, or this is not
* Windows a missing launcher is the normal case, never an error.
*/
export const regQueryValues = (key: string): RegValue[] => {
if (!validRegKey(key)) return [];
const out = run(["query", key]);
if (out === undefined) return [];
return parseRegQuery(out);
};
/** One named value under an HKLM key, or `undefined`. */
export const regQueryValue = (key: string, name: string): string | undefined =>
regQueryValues(key).find((v) => v.name.toLowerCase() === name.toLowerCase())
?.data;
/** The immediate SUBKEY paths under one HKLM key (GOG lists one subkey per installed game). */
export const regSubKeys = (key: string): string[] => {
if (!validRegKey(key)) return [];
const out = run(["query", key]);
if (out === undefined) return [];
const prefix = `${key.toLowerCase()}\\`;
return out
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.toLowerCase().startsWith(prefix))
.filter((l) => !l.slice(key.length + 1).includes("\\"));
};
/**
* Parse `reg.exe query` output rows: ` <name> <TYPE> <data>`, separated by runs of
* whitespace. Data may itself contain spaces (a path), so only the first two columns are split off.
*
* Exported for tests the format is stable but this is exactly the kind of thing that quietly
* breaks, and a plugin's tests can pin it without a Windows box.
*/
export const parseRegQuery = (stdout: string): RegValue[] => {
const out: RegValue[] = [];
for (const raw of stdout.split(/\r?\n/)) {
// Value rows are indented; the key path header is not.
if (!/^\s/.test(raw)) continue;
const line = raw.trim();
if (line === "") continue;
const m = line.match(/^(.*?)\s{2,}(REG_[A-Z_]+)\s{2,}([\s\S]*)$/);
if (!m) continue;
out.push({ name: m[1], type: m[2], data: m[3] });
}
return out;
};
+160
View File
@@ -0,0 +1,160 @@
// Steam's BINARY `shortcuts.vdf` — the user's "Add a Non-Steam Game to My Library" entries.
//
// Ported from the host's in-tree scanner (crates/punktfunk-host/src/library/steam.rs), together
// with its unit tests, which are the real specification here: the format is undocumented, and the
// two id derivations below (`shortcutAppId`, `shortcutGameId`) are the difference between a
// shortcut that launches and one that silently does nothing.
//
// Format: a 1-byte type tag (`0x00` nested map, `0x01` string, `0x02` int32, `0x07` uint64), a
// NUL-terminated key, then a type-specific payload; `0x08` closes the current map. The whole file is
// one `shortcuts` map whose children (keyed "0", "1", …) are the individual shortcuts.
//
// Lenient and total by design: a truncated file or an unrecognized tag stops the walk and returns
// whatever parsed so far. A user's shortcuts file is not something to be strict about.
export interface Shortcut {
/** The 32-bit shortcut appid — always high-bit set. Keys the entry id and its `grid/` art. */
readonly appid: number;
readonly name: string;
/** The shortcut's target, as Steam stores it (quoted, possibly with trailing arguments). */
readonly exe: string;
readonly hidden: boolean;
}
/** A cursor over the buffer — the ported code's `pos` threaded explicitly. */
interface Cursor {
pos: number;
}
/** Read a NUL-terminated UTF-8 string, advancing past the terminator. `undefined` if unterminated. */
const readCStr = (buf: Uint8Array, c: Cursor): string | undefined => {
const start = c.pos;
let end = start;
while (end < buf.length && buf[end] !== 0) end++;
if (end >= buf.length) return undefined;
const s = new TextDecoder("utf-8").decode(buf.subarray(start, end));
c.pos = end + 1;
return s;
};
/** Read a little-endian int32, advancing 4 bytes. `undefined` if fewer than 4 remain. */
const readI32 = (buf: Uint8Array, c: Cursor): number | undefined => {
if (c.pos + 4 > buf.length) return undefined;
const v = new DataView(buf.buffer, buf.byteOffset + c.pos, 4).getInt32(0, true);
c.pos += 4;
return v;
};
/** Skip a nested map's contents (positioned just after its key) up to and including its `0x08`. */
const skipMap = (buf: Uint8Array, c: Cursor): boolean => {
for (;;) {
if (c.pos >= buf.length) return false;
const tag = buf[c.pos];
c.pos += 1;
if (tag === 0x08) return true;
if (readCStr(buf, c) === undefined) return false;
if (tag === 0x00) {
if (!skipMap(buf, c)) return false;
} else if (tag === 0x01) {
if (readCStr(buf, c) === undefined) return false;
} else if (tag === 0x02) {
c.pos += 4;
} else if (tag === 0x07) {
c.pos += 8;
} else {
return false;
}
}
};
/** Parse one shortcut's fields (positioned just after its index key) up to the map-closing `0x08`. */
const parseOne = (buf: Uint8Array, c: Cursor): Shortcut | undefined => {
let appid: number | undefined;
let name = "";
let exe = "";
let hidden = false;
for (;;) {
if (c.pos >= buf.length) return undefined;
const tag = buf[c.pos];
c.pos += 1;
if (tag === 0x08) break;
const key = readCStr(buf, c)?.toLowerCase();
if (key === undefined) return undefined;
if (tag === 0x00) {
if (!skipMap(buf, c)) return undefined; // nested map (e.g. `tags`) — not needed
} else if (tag === 0x01) {
const val = readCStr(buf, c);
if (val === undefined) return undefined;
if (key === "appname") name = val;
else if (key === "exe") exe = val;
} else if (tag === 0x02) {
const val = readI32(buf, c);
if (val === undefined) return undefined;
if (key === "appid") appid = val >>> 0;
else if (key === "ishidden") hidden = val !== 0;
} else if (tag === 0x07) {
c.pos += 8; // uint64 — skip
} else {
return undefined; // unknown tag: payload size unknown, can't continue safely
}
}
if (name.trim() === "") return undefined; // nothing worth showing
// Prefer the stored appid; fall back to Steam's derivation when it's absent (0 / missing).
const id = appid && appid !== 0 ? appid : shortcutAppId(exe, name);
return { appid: id, name, exe, hidden };
};
/** Parse a binary `shortcuts.vdf` into its shortcuts. Never throws. */
export const parseShortcuts = (buf: Uint8Array): Shortcut[] => {
const out: Shortcut[] = [];
const c: Cursor = { pos: 0 };
// Enter the top-level map (`<0x00> "shortcuts" <NUL>`); tolerate any key name.
if (buf[0] !== 0x00) return out;
c.pos = 1;
if (readCStr(buf, c) === undefined) return out;
while (c.pos < buf.length) {
const tag = buf[c.pos];
c.pos += 1;
if (tag !== 0x00) break; // `0x08` (end of shortcuts) or anything unexpected
if (readCStr(buf, c) === undefined) break; // the index key ("0", "1", …)
const sc = parseOne(buf, c);
if (!sc) break;
out.push(sc);
}
return out;
};
/** Standard reflected (IEEE) CRC-32 — what Steam hashes a shortcut's `exe + name` with. */
export const crc32 = (data: Uint8Array): number => {
let crc = 0xffff_ffff;
for (const byte of data) {
crc ^= byte;
for (let i = 0; i < 8; i++) {
const mask = -(crc & 1);
crc = (crc >>> 1) ^ (0xedb8_8320 & mask);
}
}
return (~crc) >>> 0;
};
/**
* The 32-bit appid Steam derives for a shortcut from its target+name `crc32(exe + name)` with the
* high bit set. Only used when `shortcuts.vdf` omits the stored `appid` (very old Steam); modern
* Steam writes it and the stored value is preferred.
*
* The high bit is load-bearing downstream: it is how a shortcut is told apart from a real store
* appid, which is what makes the CDN art fetch skippable for shortcuts (they only ever have `grid/`
* overrides).
*/
export const shortcutAppId = (exe: string, name: string): number =>
(crc32(new TextEncoder().encode(exe + name)) | 0x8000_0000) >>> 0;
/**
* The 64-bit game id `steam://rungameid/` needs in order to launch a non-Steam shortcut: high dword
* = the 32-bit shortcut appid, low dword = the shortcut marker `0x02000000`.
*
* Handing `rungameid` the bare 32-bit appid does NOT launch a shortcut it must be this composed
* id. Returned as a decimal string because it exceeds 2^53 and would lose precision as a `number`.
*/
export const shortcutGameId = (appid: number): string =>
((BigInt(appid >>> 0) << 32n) | 0x0200_0000n).toString();
+68
View File
@@ -0,0 +1,68 @@
// Read-only SQLite over `bun:sqlite` — for launcher databases a plugin must never disturb.
//
// Lutris' `pga.db` is the motivating case: it belongs to a running application, and a scanner that
// opened it read-write could take a write lock, create `-wal`/`-shm` sidecars next to it, or (worst
// case) be blamed for a corrupted library. `immutable=1` promises the file will not change while
// open, which makes Bun skip locking entirely — the strictest possible "look, don't touch".
import { Database } from "bun:sqlite";
import { isFile } from "./fs.js";
export interface ReadOnlyDb {
/** Run a query and return its rows. Returns `[]` rather than throwing on a bad query. */
readonly query: <T = Record<string, unknown>>(
sql: string,
...params: unknown[]
) => T[];
readonly close: () => void;
}
/**
* Open a launcher database read-only and immutably. `undefined` if the file is absent or not a
* database the normal "this launcher isn't installed" case, not an error.
*
* Always `close()` when done (or use {@link withReadOnlyDb}, which does it for you).
*/
export const openReadOnly = (file: string): ReadOnlyDb | undefined => {
if (!isFile(file)) return undefined;
let db: Database;
try {
// `readonly` alone still takes locks and can spawn WAL sidecars; `immutable=1` is what makes
// this a pure read. It is safe here precisely because a scan is a point-in-time snapshot —
// if the launcher writes mid-scan we simply pick it up on the next sync.
db = new Database(`file:${encodeURI(file)}?immutable=1`, { readonly: true });
} catch {
return undefined;
}
return {
query: <T = Record<string, unknown>>(sql: string, ...params: unknown[]) => {
try {
return db.query(sql).all(...(params as never[])) as T[];
} catch {
// A schema drift (a renamed column in a launcher upgrade) must degrade to "no
// titles from this source", never take the whole plugin down.
return [] as T[];
}
},
close: () => {
try {
db.close();
} catch {
/* already closed */
}
},
};
};
/** Open, use, and always close. Returns `undefined` when the database isn't there. */
export const withReadOnlyDb = <T>(
file: string,
use: (db: ReadOnlyDb) => T,
): T | undefined => {
const db = openReadOnly(file);
if (!db) return undefined;
try {
return use(db);
} finally {
db.close();
}
};
@@ -0,0 +1,104 @@
// Where Steam lives on this host, and which `steamapps` dirs hold installed titles.
//
// Ported from the host scanner (steam.rs `steam_roots` / `steam_library_dirs`) with one deliberate
// addition and one deliberate exclusion, both about the Windows runner's account:
//
// * ADDED: HKLM `WOW6432Node\Valve\Steam\InstallPath`, so a non-default Steam install dir is
// found. The host scanner never covered this (it relied on an explorer.exe protocol fallback at
// launch time), but a plugin that can't find the root finds no games at all.
// * EXCLUDED: HKCU `Software\Valve\Steam`. The runner is LocalService, whose HKCU is its own empty
// hive, not the operator's — reading it would look like "Steam isn't installed".
import * as os from "node:os";
import * as path from "node:path";
import { isDir, listDir, readTextCapped } from "./fs.js";
import { regQueryValue } from "./registry.js";
import { vdfPaths } from "./vdf.js";
/** Canonicalize-ish: resolve and drop a trailing separator so dedup is reliable. */
const norm = (p: string): string => path.resolve(p);
/**
* Candidate Steam roots that actually exist (have a `steamapps` dir), deduped.
*
* A "root" is the Steam install itself `userdata/`, `appcache/` and the first `steamapps/` live
* under it. Extra library folders on other drives are NOT roots; see {@link steamLibraryDirs}.
*/
export const steamRoots = (): string[] => {
const candidates: string[] = [];
if (process.platform === "win32") {
for (const v of ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"]) {
const pf = process.env[v];
if (pf) candidates.push(path.join(pf, "Steam"));
}
// The registry install path — covers a Steam installed somewhere other than Program Files.
for (const key of [
"HKLM\\SOFTWARE\\WOW6432Node\\Valve\\Steam",
"HKLM\\SOFTWARE\\Valve\\Steam",
]) {
const p = regQueryValue(key, "InstallPath");
if (p) candidates.push(p);
}
} else {
const home = os.homedir();
if (home) {
candidates.push(
path.join(home, ".local/share/Steam"),
path.join(home, ".steam/steam"),
path.join(home, ".steam/root"),
// Flatpak Steam
path.join(home, ".var/app/com.valvesoftware.Steam/.local/share/Steam"),
);
}
}
const seen = new Set<string>();
const roots: string[] = [];
for (const c of candidates) {
const n = norm(c);
if (!seen.has(n) && isDir(path.join(n, "steamapps"))) {
seen.add(n);
roots.push(n);
}
}
return roots;
};
/**
* Every `steamapps` dir holding installed titles: each root's own, plus the extra library folders
* listed in its `libraryfolders.vdf` (Steam installs to other drives).
*/
export const steamLibraryDirs = (roots = steamRoots()): string[] => {
const seen = new Set<string>();
const dirs: string[] = [];
const push = (p: string) => {
const n = norm(p);
if (!seen.has(n) && isDir(n)) {
seen.add(n);
dirs.push(n);
}
};
for (const root of roots) {
const steamapps = path.join(root, "steamapps");
const text = readTextCapped(path.join(steamapps, "libraryfolders.vdf"));
if (text !== undefined) {
for (const p of vdfPaths(text)) push(path.join(p, "steamapps"));
}
push(steamapps);
}
return dirs;
};
/**
* Every `userdata/<accountId>/config` dir across all roots one per Steam account that has signed
* in on this host. `shortcuts.vdf` and the `grid/` art overrides live here.
*/
export const steamUserConfigDirs = (roots = steamRoots()): string[] => {
const out: string[] = [];
for (const root of roots) {
const userdata = path.join(root, "userdata");
for (const acct of listDir(userdata)) {
const cfg = path.join(userdata, acct, "config");
if (isDir(cfg)) out.push(cfg);
}
}
return out;
};
+80
View File
@@ -0,0 +1,80 @@
// Valve Data Format (text) — the flat-field reader Steam's `libraryfolders.vdf` and
// `appmanifest_<appid>.acf` need, ported from the host's in-tree scanner
// (crates/punktfunk-host/src/library/steam.rs `vdf_value` / `vdf_paths` / `scan_manifests`).
//
// Deliberately NOT a full VDF parser. Every field these files expose that a library plugin cares
// about sits on one line as `"key" "value"`, and a real parser would be a much larger surface to
// keep correct against a format Valve changes without notice. If you need nested values, read the
// file yourself — this is the 90% case, kept small enough to be obviously right.
/** `"<key>" "<value>"` on a single line → `<value>`. Whitespace between the two is arbitrary. */
export const vdfValue = (line: string, key: string): string | undefined => {
const rest = line.trimStart();
const prefix = `"${key}"`;
if (!rest.startsWith(prefix)) return undefined;
const after = rest.slice(prefix.length);
const open = after.indexOf('"');
if (open === -1) return undefined;
const value = after.slice(open + 1);
const close = value.indexOf('"');
if (close === -1) return undefined;
return value.slice(0, close);
};
/** The first `"<key>" "<value>"` anywhere in a multi-line document. */
export const vdfField = (text: string, key: string): string | undefined => {
for (const line of text.split("\n")) {
const v = vdfValue(line, key);
if (v !== undefined) return v;
}
return undefined;
};
/**
* Every `"path" "<dir>"` value in a `libraryfolders.vdf` the extra drives Steam installs to.
*
* On Windows the values are backslash-escaped (`D:\\SteamLibrary`), so `\\` collapses to `\`. POSIX
* paths need no unescaping, and the collapse is harmless there (a literal `\\` in a Linux path is
* vanishingly rare and was already ambiguous).
*/
export const vdfPaths = (text: string): string[] =>
text
.split("\n")
.map((l) => vdfValue(l, "path"))
.filter((p): p is string => p !== undefined)
.map((p) => p.replaceAll("\\\\", "\\"));
/** One installed title as described by its `appmanifest_<appid>.acf`. */
export interface AppManifest {
readonly appid: number;
readonly name: string;
/** The bare folder name under this library's `common/` — resolve it yourself. */
readonly installdir?: string;
}
/** Parse an `.acf` manifest's flat fields. `undefined` when it carries no usable appid+name. */
export const parseAppManifest = (text: string): AppManifest | undefined => {
const appid = Number(vdfField(text, "appid"));
const name = vdfField(text, "name");
if (!Number.isInteger(appid) || appid <= 0 || !name) return undefined;
const installdir = vdfField(text, "installdir");
return installdir ? { appid, name, installdir } : { appid, name };
};
/**
* Steam installs runtimes and redistributables as "apps" too. A *game* library must not list them.
* Ported verbatim from the host scanner so an extracted steam plugin filters identically the
* parity harness compares entry sets, and a stray Proton row would fail it.
*/
export const isSteamTool = (appid: number, name: string): boolean => {
// Steamworks Common Redistributables; Steam Linux Runtime 1.0/2.0/3.0 (Sniper/Soldier).
const TOOL_IDS = [228980, 1070560, 1391110, 1628350, 1493710];
if (TOOL_IDS.includes(appid)) return true;
const n = name.toLowerCase();
return (
n.includes("proton") ||
n.startsWith("steam linux runtime") ||
n.includes("steamworks common") ||
n.includes("steamvr")
);
};
+44 -6
View File
@@ -9,13 +9,36 @@ import type { ProviderEntry } from "./wire.js";
export * from "./wire.js";
/** What the host echoed back for one reconciled entry — enough to tell whether a claim took. */
export interface ReconciledEntry {
readonly id: string;
readonly external_id?: string;
/** The store badge the host assigned: the claim when it honoured one, else `"custom"`. */
readonly store?: string;
}
export interface ProviderClientService {
/** Full-replace reconcile: PUT the desired set; the host diffs by `external_id`. */
/**
* Full-replace reconcile: PUT the desired set; the host diffs by `external_id`.
*
* `store` claims that store for this provider (design D2), which is what makes the entries carry
* the store's own identity deterministic `<store>:<external_id>` ids instead of opaque
* `custom:<id>` ones, the store's badge, and suppression of the host's matching built-in scanner
* so the two never double-list. One provider per store: a second claimant gets a 409.
*
* Returns the host's echoed entries so a caller can verify the claim actually took a host
* predating claims ignores the query parameter silently, and the only way to notice is that the
* entries come back as `custom`.
*/
readonly reconcile: (
providerId: string,
entries: ReadonlyArray<ProviderEntry>,
) => Effect.Effect<void, HostRequestError>;
/** Remove every entry this provider owns (the explicit-uninstall path). */
store?: string,
) => Effect.Effect<ReadonlyArray<ReconciledEntry>, HostRequestError>;
/**
* Remove every entry this provider owns **and release its store claim** (the explicit-uninstall
* path). Releasing is what brings the host's built-in scanner back.
*/
readonly remove: (providerId: string) => Effect.Effect<void, HostRequestError>;
}
@@ -28,10 +51,25 @@ export class ProviderClient extends Context.Service<
Effect.gen(function* () {
const host = yield* HostClient;
return {
reconcile: (providerId, entries) =>
reconcile: (providerId, entries, store) =>
host
.request("PUT", `/library/provider/${providerId}`, entries)
.pipe(Effect.asVoid),
.request(
"PUT",
`/library/provider/${providerId}${
store ? `?store=${encodeURIComponent(store)}` : ""
}`,
entries,
)
.pipe(
// The host answers with its resulting entries. An older host may answer
// with something else, so treat a non-array as "no echo" rather than
// failing the sync.
Effect.map((body) =>
Array.isArray(body)
? (body as ReadonlyArray<ReconciledEntry>)
: [],
),
),
remove: (providerId) =>
host
.request("DELETE", `/library/provider/${providerId}`)
+130 -3
View File
@@ -3,8 +3,9 @@
// register/renew/deregister through Scope. Validated end-to-end by the phase-0 spike:
// core-only env layers, no platform package, SPA fallthrough preserved.
import { type PluginUiHandle, servePluginUi } from "@punktfunk/host";
import { Effect, FileSystem, Layer, Path, Scope } from "effect";
import { Effect, FileSystem, Layer, Path, Schema, Scope } from "effect";
import { Etag, HttpPlatform, HttpRouter } from "effect/unstable/http";
import type { ConfigService } from "./config.js";
import { UiServeError } from "./errors.js";
import { HostClient, PluginInfo } from "./host-client.js";
@@ -17,6 +18,100 @@ export const httpApiEnv = Layer.provideMerge(
FileSystem.layerNoop({}),
);
/**
* Derive a JSON Schema for a config schema, for the console's generic settings form.
*
* Returns `null` when derivation isn't possible, which the console reads as "render the raw JSON
* editor instead" the fallback that bounds this whole feature's risk.
*
* Authoring rules, verified against effect 4.0.0-beta.99 and pinned by
* `test/library-config.test.ts` if an effect upgrade changes any of them, that test fails:
*
* * Use `Schema.Finite` / `Schema.Int`, **never `Schema.Number`** Number's *encoded* form admits
* the strings `"NaN"`/`"Infinity"`/`"-Infinity"`, so it derives a four-way `anyOf` that no sane
* form can render as a number input.
* * A decoding default is an **Effect**: `withDecodingDefaultKey(Effect.succeed(true), …)`. Passing
* a bare thunk (`() => true`) still derives a schema and still type-checks, then dies at DECODE
* time with "Not a valid effect" deriving is not evidence that the schema works.
* * Annotate every field: `.annotate({ title, description, default })`. The derivation does NOT
* infer `default` from `withDecodingDefaultKey`, so an un-annotated field shows no placeholder.
* * A *checked* schema (`Schema.Int`, or anything with `.check(...)`) nests its annotations and
* constraints under `allOf`, so a form must merge those branches, not read only the top level.
* * `Schema.Literals([...])` derives a clean `enum` prefer it over a union of strings. A union of
* non-literals derives an `anyOf`, which is the JSON-editor fallback case.
* * Fields carrying `withDecodingDefaultKey(..., { encodingStrategy: "omit" })` correctly drop out
* of `required`, which is what keeps the raw file free of baked-in defaults.
*/
export const deriveConfigJsonSchema = (
schema: Schema.Top,
): Record<string, unknown> | null => {
try {
const doc = Schema.toJsonSchemaDocument(schema as never);
return doc as unknown as Record<string, unknown>;
} catch {
// A schema shape the derivation can't express (a transform, a recursive ref). The console
// falls back to the JSON editor; the PUT still validates by decode, so nothing is lost but
// the pretty form.
return null;
}
};
/** The plugin config surface the console's settings drawer drives. */
export interface ServeUiConfig<S extends Schema.Top> {
/** The schema the raw file is validated against, and the form is derived from. */
readonly schema: S;
/** The config service (from `makeConfigService`) holding the raw round-trip semantics. */
readonly service: ConfigService<S>;
}
/**
* The `/__config` request handler, split out so it can be driven directly in tests (the wire shape
* is the contract the console's settings drawer codes against it deserves a real round-trip test,
* not a mock).
*
* `ConfigService`'s effects are context-free by construction (the `PluginInfo` was resolved when the
* service was built), so this runs them straight from a plain async handler.
*/
export const makeConfigHandler = <S extends Schema.Top>(
cfg: ServeUiConfig<S>,
): ((req: Request) => Promise<Response>) => {
// The derivation is stable for the life of the process — do it once, not per request.
const schema = deriveConfigJsonSchema(cfg.schema);
return async (req: Request): Promise<Response> => {
if (req.method === "GET") {
// A config file that fails to decode must not blank the whole drawer — answer with a
// null value so the operator can still see (and replace) what is on disk.
const value = await Effect.runPromise(cfg.service.loadRaw).catch(
() => null,
);
return Response.json({ schema, value });
}
if (req.method === "PUT") {
let body: unknown;
try {
body = await req.json();
} catch (cause) {
return Response.json(
{ error: "body must be JSON", issue: String(cause) },
{ status: 400 },
);
}
try {
// Validate-by-decode, persist RAW: `saveRaw` refuses a body the schema rejects and
// never writes decoded defaults back into the operator's file.
await Effect.runPromise(cfg.service.saveRaw(body));
return Response.json({ ok: true });
} catch (cause) {
return Response.json(
{ error: "config rejected", issue: String(cause) },
{ status: 400 },
);
}
}
return new Response("method not allowed", { status: 405 });
};
};
export interface ServeUiOptions {
/** Console nav title. */
readonly title: string;
@@ -26,12 +121,33 @@ export interface ServeUiOptions {
readonly version?: string;
/** Built SPA directory (served with SPA fallback by the SDK). */
readonly staticDir?: string | URL;
/**
* What kind of plugin this is (`[a-z][a-z0-9-]{0,31}`). `"library"` keeps the plugin out of the
* console nav its entry point is the Library section's Game sources surface instead.
*/
readonly category?: string;
/**
* Serve `GET`/`PUT /__config` for the console's **generic settings form**, so a plugin with
* settings does not need to ship an SPA at all.
*
* `GET` answers `{schema, value}` the derived JSON Schema (or `null`) and the raw,
* operator-authored config. `PUT` validates by decoding the body against the schema and, only
* then, persists it **raw**; defaults are never baked into the file. A rejected body comes back
* 400 with the decode issue.
*
* Auth is the existing per-boot UI secret the console reaches this through its session-gated
* `/plugin-ui/<id>/…` proxy, so there is no new host surface and nothing new exposed to the LAN.
*/
readonly config?: ServeUiConfig<Schema.Top>;
/**
* The plugin API: `HttpApiBuilder.layer(api)` + group handler layers + raw routes
* (e.g. `sseRoute`), with plugin services already provided. `httpApiEnv` is provided
* here only `HttpRouter` may remain open.
*
* Optional: a plugin whose only surface is `__config` (every library scanner) serves no API of
* its own, and omitting this leaves an empty router that 404s under `apiPrefix`.
*/
readonly api: Layer.Layer<never, never, HttpRouter.HttpRouter>;
readonly api?: Layer.Layer<never, never, HttpRouter.HttpRouter>;
/** Path prefix owned by the API handler (default "/api/"). */
readonly apiPrefix?: string;
}
@@ -54,14 +170,22 @@ export const serveUi = (
const prefix = opts.apiPrefix ?? "/api/";
const { handler, dispose } = HttpRouter.toWebHandler(
Layer.provide(opts.api, httpApiEnv),
Layer.provide(opts.api ?? Layer.empty, httpApiEnv),
);
yield* Effect.addFinalizer(() =>
Effect.promise(() => dispose()).pipe(Effect.ignore),
);
const serveConfig = opts.config ? makeConfigHandler(opts.config) : undefined;
const fetch = async (req: Request): Promise<Response | undefined> => {
const url = new URL(req.url);
// `__`-prefixed paths are the kit/SDK's own contract surface (`__health` lives in the
// SDK), deliberately checked BEFORE the API prefix and before any static asset so a
// plugin's own routes can never shadow them.
if (url.pathname === "/__config") {
return serveConfig?.(req) ?? new Response("not found", { status: 404 });
}
if (!url.pathname.startsWith(prefix)) return undefined; // → static SPA
return handler(req);
};
@@ -79,6 +203,9 @@ export const serveUi = (
...(opts.staticDir !== undefined
? { staticDir: opts.staticDir }
: {}),
...(opts.category !== undefined
? { category: opts.category }
: {}),
fetch,
}),
catch: (cause) => new UiServeError({ cause }),
+58 -1
View File
@@ -12,12 +12,45 @@ export const Artwork = Schema.Struct({
});
export type Artwork = typeof Artwork.Type;
/**
* How the host should launch a title. **The host owns this vocabulary** it validates the value
* per kind and builds the actual URI / command line itself, so a plugin only ever supplies a
* validated value, never a command. That is the security invariant behind the whole provider lane:
* a client sends an entry id, and the host resolves what to run.
*
* `kind` is a plain string rather than a union so the kit never has to ship a release to keep up
* with a host that grew a new kind. The kinds the host understands today:
*
* | kind | value | platforms |
* |---|---|---|
* | `command` | a shell command (operator-trust tier) | both |
* | `steam_appid` | digits an appid, or a 64-bit non-Steam-shortcut game id | both |
* | `steam_ui` | `bigpicture` \| `desktop` opens the Steam client itself | both |
* | `launcher_ui` | a store id (`heroic`, `lutris`) opens that launcher's own UI | linux |
* | `lutris_id` | digits a pga.db game id | linux |
* | `heroic` | `<runner>:<appName>`, runner legendary/gog/nile | linux |
* | `epic` | `<namespace>:<catalogItemId>:<appName>` or a bare appName | windows |
* | `gog` | `exe \t args \t workdir` | windows |
* | `aumid` | `<PFN>!<AppId>` | windows |
*
* An unknown kind is accepted on the wire and simply yields no launch recipe on that host, so a
* plugin targeting a newer host degrades to an unlaunchable tile rather than a failed reconcile.
*/
export const LaunchSpec = Schema.Struct({
kind: Schema.Literal("command"),
kind: Schema.String,
value: Schema.String,
});
export type LaunchSpec = typeof LaunchSpec.Type;
/**
* Whether an entry is an ordinary title or the launcher application itself (Steam Big Picture,
* Heroic, Playnite fullscreen). Launcher entries launch, lease and list exactly like games; a
* console or client that knows the field groups them into their own rail, and one that doesn't
* renders them as plain tiles.
*/
export const GameRole = Schema.Literals(["game", "launcher"]);
export type GameRole = typeof GameRole.Type;
export const PrepStep = Schema.Struct({
do: Schema.String,
undo: Schema.optionalKey(Schema.NullOr(Schema.String)),
@@ -43,6 +76,28 @@ export const DetectHint = Schema.Struct({
exe: Schema.optionalKey(Schema.NullOr(Schema.String)),
/** The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest signal. */
process_name: Schema.optionalKey(Schema.NullOr(Schema.String)),
/**
* The Steam appid, for a title Steam itself installed. On Linux this is the **sharpest** signal
* there is: Steam wraps every launch native or Proton in `reaper SteamLaunch AppId=<appid>`,
* whose lifetime is exactly the game's. Send it if you have it.
*/
steam_appid: Schema.optionalKey(Schema.NullOr(Schema.Number)),
/**
* An environment variable the launcher stamps on the game's process. Load-bearing for launchers
* that run games under Proton/Wine, where the process tree tells you very little (Heroic's
* `HEROIC_APP_NAME` is the verified case). Omit `value` to match on the key's mere presence
* only safe for a launcher that runs one game at a time.
*/
env_marker: Schema.optionalKey(
Schema.NullOr(
Schema.Struct({
/** `[A-Za-z0-9_]{1,64}` — the host rejects anything else. */
key: Schema.String,
/** At most 256 chars. */
value: Schema.optionalKey(Schema.NullOr(Schema.String)),
}),
),
),
});
export type DetectHint = typeof DetectHint.Type;
@@ -76,6 +131,8 @@ export const ProviderEntry = Schema.Struct({
launch: Schema.optionalKey(Schema.NullOr(LaunchSpec)),
prep: Schema.optionalKey(Schema.Array(PrepStep)),
detect: Schema.optionalKey(DetectHint),
/** `"game"` (default) or `"launcher"` — see {@link GameRole}. */
role: Schema.optionalKey(GameRole),
...GameMeta.fields,
});
export type ProviderEntry = typeof ProviderEntry.Type;
+240
View File
@@ -0,0 +1,240 @@
// The `__config` contract — the wire shape the console's generic settings drawer codes against,
// plus the JSON-Schema derivation's committed fixture (design M0/S2).
//
// The derivation fixture is not decoration: it is the record of WHICH schema shapes the generic
// form can render. If an effect upgrade changes any of it, this test fails and the console's form
// needs re-checking before the change ships — far cheaper than discovering it on a user's box.
import { describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { Effect, Layer, Schema } from "effect";
import { makeConfigService } from "../src/config.js";
import { pluginInfoLayer } from "../src/host-client.js";
import { deriveConfigJsonSchema, makeConfigHandler } from "../src/ui-server.js";
/** A representative scanner config: booleans, a string, a string array, a nested object, an enum. */
const ScannerConfig = Schema.Struct({
enabled: Schema.Boolean.annotate({
title: "Enable scanning",
description: "Whether this source contributes titles.",
default: true,
}).pipe(
Schema.withDecodingDefaultKey(Effect.succeed(true), {
encodingStrategy: "omit",
}),
),
root: Schema.optionalKey(
Schema.String.annotate({ title: "Launcher root", description: "Absolute path." }),
),
extraRoots: Schema.Array(Schema.String)
.annotate({ title: "Extra roots" })
.pipe(
Schema.withDecodingDefaultKey(
Effect.succeed([] as ReadonlyArray<string>),
{ encodingStrategy: "omit" },
),
),
launchers: Schema.Struct({
bigpicture: Schema.Boolean.annotate({ title: "Big Picture", default: true }),
desktop: Schema.Boolean.annotate({ title: "Desktop", default: false }),
}).pipe(
Schema.withDecodingDefaultKey(
Effect.succeed({ bigpicture: true, desktop: false }),
{ encodingStrategy: "omit" },
),
),
pollMinutes: Schema.Int.annotate({
title: "Poll interval (minutes)",
default: 15,
}).pipe(
Schema.withDecodingDefaultKey(Effect.succeed(15), {
encodingStrategy: "omit",
}),
),
artSource: Schema.Literals(["local", "cdn", "both"])
.annotate({ title: "Art source", default: "both" })
.pipe(
Schema.withDecodingDefaultKey(Effect.succeed("both" as const), {
encodingStrategy: "omit",
}),
),
});
const props = (): Record<string, Record<string, unknown>> => {
const doc = deriveConfigJsonSchema(ScannerConfig) as {
schema: { properties: Record<string, Record<string, unknown>> };
};
return doc.schema.properties;
};
describe("S2 — JSON Schema derivation for __config", () => {
test("derives a renderable form for every shape a scanner config uses", () => {
const p = props();
expect(p.enabled).toMatchObject({ type: "boolean" });
expect(p.root).toMatchObject({ type: "string" });
expect(p.extraRoots).toMatchObject({
type: "array",
items: { type: "string" },
});
// A nested object stays nested — the form renders a fieldset, not a JSON blob.
expect(p.launchers).toMatchObject({
type: "object",
properties: { bigpicture: { type: "boolean" }, desktop: { type: "boolean" } },
});
// A literal union derives a clean enum — prefer it over a union of strings.
expect(p.artSource).toMatchObject({
type: "string",
enum: ["local", "cdn", "both"],
});
});
test("annotations pass through — they are the ONLY source of labels and defaults", () => {
const p = props();
expect(p.enabled.title).toBe("Enable scanning");
expect(p.enabled.description).toBe("Whether this source contributes titles.");
// The derivation does NOT infer `default` from withDecodingDefaultKey, so an un-annotated
// field shows the form no placeholder at all. Annotate every field.
expect(p.enabled.default).toBe(true);
expect(p.artSource.default).toBe("both");
// A CHECKED schema (Int is String-plus-a-check) nests its annotations under `allOf`, so a
// form reading `default` must merge allOf branches rather than only looking at the top level.
expect(p.pollMinutes.allOf).toEqual([
{ default: 15, title: "Poll interval (minutes)" },
]);
});
test("a decoding default is an Effect, not a thunk — and it actually applies", () => {
// The trap this pins: `withDecodingDefaultKey` takes an `Effect`, and passing a bare thunk
// (`() => true`) type-checks against the derivation path but blows up at DECODE time with
// "Not a valid effect". Deriving a schema is therefore NOT evidence that it works.
expect(Schema.decodeUnknownSync(ScannerConfig)({})).toMatchObject({
enabled: true,
pollMinutes: 15,
artSource: "both",
launchers: { bigpicture: true, desktop: false },
});
});
test("Schema.Int derives a plain integer — Schema.Number does NOT", () => {
expect(props().pollMinutes).toMatchObject({ type: "integer" });
// The trap, pinned: Schema.Number's ENCODED form admits "NaN"/"Infinity"/"-Infinity", so it
// derives a four-way anyOf that no number input can render. Use Finite or Int.
const bad = deriveConfigJsonSchema(
Schema.Struct({ n: Schema.Number }),
) as { schema: { properties: { n: { anyOf?: unknown[] } } } };
expect(Array.isArray(bad.schema.properties.n.anyOf)).toBe(true);
const ok = deriveConfigJsonSchema(
Schema.Struct({ n: Schema.Finite }),
) as { schema: { properties: { n: { type?: string } } } };
expect(ok.schema.properties.n.type).toBe("number");
});
test("defaulted fields drop out of `required` — the raw file stays default-free", () => {
const doc = deriveConfigJsonSchema(ScannerConfig) as {
schema: { required?: string[] };
};
// Every field here either has a decoding default or is optionalKey, so nothing is required.
expect(doc.schema.required ?? []).toEqual([]);
});
});
describe("__config wire contract", () => {
const withService = async <A>(
use: (handler: (req: Request) => Promise<Response>, file: string) => Promise<A>,
): Promise<A> => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-kit-cfg-"));
const prev = process.env.PUNKTFUNK_CONFIG_DIR;
process.env.PUNKTFUNK_CONFIG_DIR = dir;
try {
const service = await Effect.runPromise(
makeConfigService({ schema: ScannerConfig }).pipe(
Effect.provide(
Layer.mergeAll(pluginInfoLayer({ name: "steam", version: "0.1.0" })),
),
),
);
return await use(
makeConfigHandler({ schema: ScannerConfig, service }),
service.path,
);
} finally {
if (prev === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
else process.env.PUNKTFUNK_CONFIG_DIR = prev;
fs.rmSync(dir, { recursive: true, force: true });
}
};
test("GET answers {schema, value} with an absent file reading as empty", async () => {
await withService(async (handler) => {
const res = await handler(new Request("http://x/__config"));
expect(res.status).toBe(200);
const body = (await res.json()) as { schema: unknown; value: unknown };
// Both keys are ALWAYS present and never `undefined` — the console decodes this shape,
// and an omitted-vs-null field is the wire trap that bit the rom-manager 0.3.1 release.
expect(body).toHaveProperty("schema");
expect(body).toHaveProperty("value");
expect(body.schema).not.toBeNull();
// A missing config file is an EMPTY config, not an error.
expect(body.value).toEqual({});
});
});
test("PUT validates by decode, persists RAW, and never bakes in defaults", async () => {
await withService(async (handler, file) => {
const res = await handler(
new Request("http://x/__config", {
method: "PUT",
body: JSON.stringify({ enabled: false }),
}),
);
expect(res.status).toBe(200);
// The file holds exactly what was authored — the five defaulted fields are NOT written,
// which is what keeps a future change to a default from being silently pinned.
expect(JSON.parse(fs.readFileSync(file, "utf8"))).toEqual({
enabled: false,
});
const get = (await (
await handler(new Request("http://x/__config"))
).json()) as { value: unknown };
expect(get.value).toEqual({ enabled: false });
});
});
test("PUT rejects a body the schema refuses, with the issue, and writes nothing", async () => {
await withService(async (handler, file) => {
const res = await handler(
new Request("http://x/__config", {
method: "PUT",
body: JSON.stringify({ enabled: "yes please" }),
}),
);
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string; issue: string };
expect(body.error).toBe("config rejected");
expect(body.issue.length).toBeGreaterThan(0);
expect(fs.existsSync(file)).toBe(false);
});
});
test("PUT rejects a non-JSON body", async () => {
await withService(async (handler) => {
const res = await handler(
new Request("http://x/__config", { method: "PUT", body: "not json" }),
);
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toBe(
"body must be JSON",
);
});
});
test("other methods are refused", async () => {
await withService(async (handler) => {
const res = await handler(
new Request("http://x/__config", { method: "DELETE" }),
);
expect(res.status).toBe(405);
});
});
});
+186
View File
@@ -0,0 +1,186 @@
// The parity harness is the release gate for every extracted scanner, so the thing that decides
// pass/fail needs its own tests. The cases below are the ones that actually happen during a port:
// a lost title, a wrong id, a dropped launch recipe, art whose representation changed but whose
// presence didn't, and the launcher entries the plugin legitimately adds.
import { describe, expect, test } from "bun:test";
import {
claimedLibraryId,
diffParity,
formatParityReport,
fromHostEntry,
fromProviderEntry,
type HostGameEntry,
} from "../src/library/parity.js";
import type { ProviderEntry } from "../src/wire.js";
/** What the host reports while its BUILT-IN steam scanner is producing the library. */
const hostEntry = (over: Partial<HostGameEntry> = {}): HostGameEntry => ({
id: "steam:440",
store: "steam",
title: "Team Fortress 2",
launch: { kind: "steam_appid", value: "440" },
// The scanner emits host-relative proxy paths the CLIENT resolves.
art: {
portrait: "/api/v1/library/art/steam:440/portrait",
hero: "/api/v1/library/art/steam:440/hero",
logo: null,
header: "/api/v1/library/art/steam:440/header",
},
platform: "PC",
...over,
});
/** What the extracted plugin produces for the same title. */
const pluginEntry = (over: Partial<ProviderEntry> = {}): ProviderEntry =>
({
external_id: "440",
title: "Team Fortress 2",
launch: { kind: "steam_appid", value: "440" },
// The plugin emits file:// paths and CDN URLs — a DIFFERENT representation of the same art.
art: {
portrait: "file:///home/u/.steam/appcache/librarycache/440/a/p.jpg",
hero: "https://cdn.cloudflare.steamstatic.com/steam/apps/440/library_hero.jpg",
header: "https://cdn.cloudflare.steamstatic.com/steam/apps/440/header.jpg",
},
platform: "PC",
...over,
}) as ProviderEntry;
describe("id mapping", () => {
test("a claimed entry's id is the scanner's id", () => {
// The whole migration rests on this one line: Moonlight pins, GameStream app ids and client
// art caches are all derived from it.
expect(claimedLibraryId("steam", "440")).toBe("steam:440");
expect(claimedLibraryId("heroic", "legendary:Quail")).toBe(
"heroic:legendary:Quail",
);
});
});
describe("diffParity", () => {
const base = [fromHostEntry(hostEntry())];
test("a faithful port passes, even though the art VALUES all changed", () => {
const r = diffParity(base, [fromProviderEntry("steam", pluginEntry())]);
expect(r.ok).toBe(true);
expect(r.matched).toBe(1);
expect(r.changed).toEqual([]);
expect(formatParityReport(r)).toContain("parity OK");
});
test("a lost title is reported as missing", () => {
const r = diffParity(base, []);
expect(r.ok).toBe(false);
expect(r.missing.map((e) => e.id)).toEqual(["steam:440"]);
expect(formatParityReport(r)).toContain("missing: steam:440");
});
test("a wrong id shows up as BOTH missing and extra — the loudest failure", () => {
// The exact shape of the bug this harness exists to catch: the plugin found the title, but
// under an id nothing downstream recognizes.
const r = diffParity(base, [
fromProviderEntry("steam", pluginEntry({ external_id: "440.0" })),
]);
expect(r.ok).toBe(false);
expect(r.missing.map((e) => e.id)).toEqual(["steam:440"]);
expect(r.extra.map((e) => e.id)).toEqual(["steam:440.0"]);
});
test("a changed launch recipe is caught", () => {
const r = diffParity(base, [
fromProviderEntry(
"steam",
pluginEntry({ launch: { kind: "command", value: "steam" } }),
),
]);
expect(r.ok).toBe(false);
expect(r.changed).toEqual([
{
id: "steam:440",
field: "launch",
before: "steam_appid:440",
after: "command:steam",
},
]);
});
test("a dropped launch recipe is caught (an unlaunchable tile)", () => {
const r = diffParity(base, [
fromProviderEntry("steam", pluginEntry({ launch: null })),
]);
expect(r.changed.map((c) => c.field)).toEqual(["launch"]);
});
test("LOSING an art kind fails; gaining one does not", () => {
const lost = diffParity(base, [
fromProviderEntry("steam", pluginEntry({ art: { portrait: null } })),
]);
expect(lost.ok).toBe(false);
expect(lost.changed.map((c) => c.field)).toContain("art.portrait");
// The baseline had no logo; the plugin resolves one. That is an improvement, and failing the
// run over it would only train people to ignore the harness.
const gained = diffParity(base, [
fromProviderEntry(
"steam",
pluginEntry({
art: { ...pluginEntry().art, logo: "file:///l.png" },
}),
),
]);
expect(gained.ok).toBe(true);
});
test("metadata drift is caught, but absent-vs-empty is not drift", () => {
const changed = diffParity(base, [
fromProviderEntry("steam", pluginEntry({ platform: "Linux" })),
]);
expect(changed.changed).toEqual([
{ id: "steam:440", field: "meta.platform", before: "PC", after: "Linux" },
]);
// The host omits empty lists and nulls; a plugin sending them has changed nothing.
const noise = diffParity(base, [
fromProviderEntry(
"steam",
pluginEntry({ genres: [], tags: [], region: null } as never),
),
]);
expect(noise.ok).toBe(true);
});
test("launcher entries are expected extras, not failures", () => {
// The built-in scanner had no concept of a launcher entry, so it can never be in the
// baseline — reporting it as `extra` would fail every steam run forever.
const r = diffParity(base, [
fromProviderEntry("steam", pluginEntry()),
fromProviderEntry(
"steam",
pluginEntry({
external_id: "ui:bigpicture",
title: "Steam Big Picture",
role: "launcher",
launch: { kind: "steam_ui", value: "bigpicture" },
art: {},
} as never),
),
]);
expect(r.ok).toBe(true);
expect(r.extra).toEqual([]);
expect(r.launchersAdded.map((e) => e.id)).toEqual(["steam:ui:bigpicture"]);
expect(formatParityReport(r)).toContain("+1 launcher entry");
});
test("an ordinary title the scanner never had IS a failure", () => {
// The mirror of the case above: only `role: "launcher"` gets the exemption, so a plugin that
// invents games (a bad filter, a tool listed as a game) still fails.
const r = diffParity(base, [
fromProviderEntry("steam", pluginEntry()),
fromProviderEntry(
"steam",
pluginEntry({ external_id: "228980", title: "Steamworks Common" }),
),
]);
expect(r.ok).toBe(false);
expect(r.extra.map((e) => e.id)).toEqual(["steam:228980"]);
});
});
+289
View File
@@ -0,0 +1,289 @@
// The parser ports, tested against the SAME cases the host's Rust scanners pin.
//
// These are not "does TypeScript work" tests. The formats here are undocumented and the host's
// versions are the reference implementation; a port that drifts produces a library that looks fine
// and launches nothing. Where a Rust test exists, its assertions are carried over verbatim — the
// per-plugin parity harness (design M5) then checks the whole pipeline against a live host, but
// these catch a drift long before that.
import { describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
confinedJoin,
crc32,
findGridArtFile,
findLocalArtFile,
fileUrl,
gridFilenames,
isSteamTool,
parseAppManifest,
parseRegQuery,
parseShortcuts,
readTextCapped,
shortcutAppId,
shortcutGameId,
steamCdnUrl,
vdfPaths,
vdfValue,
} from "../src/library/parsers/index.js";
const tmp = (name: string): string => {
const dir = path.join(os.tmpdir(), `pf-kit-${name}-${process.pid}`);
fs.mkdirSync(dir, { recursive: true });
return dir;
};
describe("text VDF / ACF", () => {
test("vdfValue extracts a quoted field", () => {
expect(vdfValue('"path"\t\t"/mnt/games/SteamLibrary"', "path")).toBe(
"/mnt/games/SteamLibrary",
);
expect(vdfValue('"appid"\t\t"570"', "appid")).toBe("570");
expect(vdfValue('"name"\t\t"Dota 2"', "name")).toBe("Dota 2");
// Wrong key → nothing (a prefix match must not leak the neighbouring field).
expect(vdfValue('"installdir"\t\t"x"', "appid")).toBeUndefined();
});
test("vdfPaths pulls every library folder and unescapes Windows separators", () => {
const vdf = `
"libraryfolders"
{
"0"
{
"path" "/home/u/.local/share/Steam"
"label" ""
}
"1"
{
"path" "D:\\\\SteamLibrary"
}
}`;
expect(vdfPaths(vdf)).toEqual([
"/home/u/.local/share/Steam",
"D:\\SteamLibrary",
]);
});
test("parseAppManifest reads the flat fields it needs", () => {
const acf = `"AppState"
{
"appid" "570"
"name" "Dota 2"
"installdir" "dota 2 beta"
}`;
expect(parseAppManifest(acf)).toEqual({
appid: 570,
name: "Dota 2",
installdir: "dota 2 beta",
});
// A manifest missing the essentials is not a title.
expect(parseAppManifest('"AppState" { "name" "x" }')).toBeUndefined();
});
test("isSteamTool keeps runtimes out of a game library", () => {
expect(isSteamTool(228980, "Steamworks Common Redistributables")).toBe(true);
expect(isSteamTool(1628350, "Steam Linux Runtime 3.0 (sniper)")).toBe(true);
expect(isSteamTool(999, "Proton 9.0")).toBe(true);
expect(isSteamTool(999, "SteamVR")).toBe(true);
expect(isSteamTool(570, "Dota 2")).toBe(false);
});
});
describe("binary shortcuts.vdf", () => {
/** Build a binary shortcuts.vdf the way Steam writes one. */
const buildShortcuts = (
entries: ReadonlyArray<{
appid?: number;
appname: string;
exe: string;
hidden?: boolean;
}>,
): Uint8Array => {
const parts: number[] = [];
const cstr = (s: string) => {
for (const b of new TextEncoder().encode(s)) parts.push(b);
parts.push(0);
};
const i32 = (v: number) => {
parts.push(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff);
};
parts.push(0x00);
cstr("shortcuts");
entries.forEach((e, i) => {
parts.push(0x00);
cstr(String(i));
if (e.appid !== undefined) {
parts.push(0x02);
cstr("appid");
i32(e.appid);
}
parts.push(0x01);
cstr("AppName");
cstr(e.appname);
parts.push(0x01);
cstr("Exe");
cstr(e.exe);
parts.push(0x02);
cstr("IsHidden");
i32(e.hidden ? 1 : 0);
// A nested map the parser must skip wholesale.
parts.push(0x00);
cstr("tags");
parts.push(0x01);
cstr("0");
cstr("favourite");
parts.push(0x08);
parts.push(0x08); // end of this shortcut
});
parts.push(0x08); // end of shortcuts
parts.push(0x08); // end of document
return new Uint8Array(parts);
};
test("parses entries, skips nested maps, and reads the hidden flag", () => {
const buf = buildShortcuts([
{ appid: 2456789012, appname: "My Emulator", exe: '"/usr/bin/foo"' },
{ appid: 3000000000, appname: "Hidden One", exe: '"/x"', hidden: true },
]);
const got = parseShortcuts(buf);
expect(got).toHaveLength(2);
expect(got[0]).toMatchObject({
appid: 2456789012,
name: "My Emulator",
hidden: false,
});
expect(got[1]).toMatchObject({ name: "Hidden One", hidden: true });
});
test("derives the appid when the file omits it", () => {
const buf = buildShortcuts([{ appname: "No Appid", exe: '"/usr/bin/x"' }]);
const got = parseShortcuts(buf);
expect(got).toHaveLength(1);
// Derived ids always carry the high bit — that is how a shortcut is told apart from a real
// store appid downstream (and why its CDN art fetch is skipped).
expect(got[0].appid & 0x8000_0000).not.toBe(0);
expect(got[0].appid).toBe(shortcutAppId('"/usr/bin/x"', "No Appid"));
});
test("is total on a truncated or garbled file", () => {
expect(parseShortcuts(new Uint8Array([]))).toEqual([]);
expect(parseShortcuts(new Uint8Array([0x01, 0x02, 0x03]))).toEqual([]);
const good = buildShortcuts([{ appid: 1, appname: "A", exe: "/a" }]);
// Every truncation of a valid file must return, not throw.
for (let i = 0; i < good.length; i++) {
expect(() => parseShortcuts(good.subarray(0, i))).not.toThrow();
}
});
test("crc32 matches the IEEE check value", () => {
// The canonical CRC-32 check: crc32("123456789") == 0xCBF43926.
expect(crc32(new TextEncoder().encode("123456789"))).toBe(0xcbf4_3926);
});
test("shortcutGameId composes the appid and the shortcut marker", () => {
// high dword = appid, low dword = 0x02000000. Handing rungameid the bare 32-bit appid does
// NOT launch a shortcut, which is the entire reason this function exists.
const id = BigInt(shortcutGameId(0x8000_0000));
expect(id >> 32n).toBe(0x8000_0000n);
expect(id & 0xffff_ffffn).toBe(0x0200_0000n);
// Digits only — it rides the `steam_appid` launch kind, which the host validates as digits.
expect(shortcutGameId(2_456_789_012)).toMatch(/^\d+$/);
});
});
describe("path confinement", () => {
test("confinedJoin refuses anything that could escape the install dir", () => {
const base = path.join(path.sep, "games", "W3");
expect(confinedJoin(base, "bin/game.exe")).toBe(
path.join(base, "bin", "game.exe"),
);
expect(confinedJoin(base, "bin\\game.exe")).toBe(
path.join(base, "bin", "game.exe"),
);
// The three shapes a crafted goggame-*.info would use to point elsewhere.
expect(confinedJoin(base, "../../windows/system32/cmd.exe")).toBeUndefined();
expect(confinedJoin(base, "/etc/passwd")).toBeUndefined();
expect(confinedJoin(base, "C:\\Windows\\system32\\cmd.exe")).toBeUndefined();
expect(confinedJoin(base, "")).toBeUndefined();
});
});
describe("capped reads", () => {
test("readTextCapped refuses an over-cap file and a missing one", () => {
const dir = tmp("caps");
const small = path.join(dir, "small.txt");
fs.writeFileSync(small, "hello");
expect(readTextCapped(small)).toBe("hello");
expect(readTextCapped(small, 2)).toBeUndefined(); // over the cap
expect(readTextCapped(path.join(dir, "nope.txt"))).toBeUndefined();
expect(readTextCapped(dir)).toBeUndefined(); // a directory is not a file
fs.rmSync(dir, { recursive: true, force: true });
});
});
describe("art locations", () => {
test("steamCdnUrl skips shortcut appids, which have no CDN entry", () => {
expect(steamCdnUrl(570, "header")).toContain("/570/header.jpg");
expect(steamCdnUrl(570, "portrait")).toContain("library_600x900.jpg");
// The local cache names the header asset differently from the CDN — pinned because it is
// the single most common way to get Steam art wrong.
expect(steamCdnUrl(570, "header")).not.toContain("library_header");
expect(steamCdnUrl(0x8000_0001, "header")).toBeUndefined();
});
test("grid filenames follow Steam's per-kind naming", () => {
expect(gridFilenames(570, "portrait")).toEqual(["570p.png", "570p.jpg"]);
expect(gridFilenames(570, "hero")).toEqual(["570_hero.png", "570_hero.jpg"]);
expect(gridFilenames(570, "logo")).toEqual(["570_logo.png", "570_logo.jpg"]);
expect(gridFilenames(570, "header")).toEqual(["570.png", "570.jpg"]);
});
test("finds cached and user-override art on disk", () => {
const dir = tmp("art");
const hashDir = path.join(dir, "appcache", "librarycache", "570", "abc123");
fs.mkdirSync(hashDir, { recursive: true });
fs.writeFileSync(path.join(hashDir, "library_600x900.jpg"), "x");
expect(findLocalArtFile(dir, 570, "portrait")).toBe(
path.join(hashDir, "library_600x900.jpg"),
);
expect(findLocalArtFile(dir, 570, "hero")).toBeUndefined();
const cfg = path.join(dir, "userdata", "1", "config");
fs.mkdirSync(path.join(cfg, "grid"), { recursive: true });
fs.writeFileSync(path.join(cfg, "grid", "570p.jpg"), "x");
expect(findGridArtFile(cfg, 570, "portrait")).toBe(
path.join(cfg, "grid", "570p.jpg"),
);
fs.rmSync(dir, { recursive: true, force: true });
});
test("fileUrl produces the host's local-art contract shape", () => {
const u = fileUrl(path.join(path.sep, "home", "u", "My Games", "c.jpg"));
expect(u.startsWith("file:///")).toBe(true);
// Spaces are percent-encoded; the separators survive so the host can rebuild the path.
expect(u).toContain("My%20Games");
expect(u).toContain("/c.jpg");
});
});
describe("reg.exe output", () => {
test("parses value rows and leaves the key header alone", () => {
const stdout = [
"",
"HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Valve\\Steam",
" InstallPath REG_SZ C:\\Program Files (x86)\\Steam",
" Language REG_SZ english",
"",
].join("\r\n");
expect(parseRegQuery(stdout)).toEqual([
{
name: "InstallPath",
type: "REG_SZ",
// Data may contain spaces — only the first two columns are split off.
data: "C:\\Program Files (x86)\\Steam",
},
{ name: "Language", type: "REG_SZ", data: "english" },
]);
});
});
+4 -1
View File
@@ -10,5 +10,8 @@
"noEmit": true,
"types": ["bun"]
},
"include": ["src", "test"]
// `examples` is type-checked but never built: tsconfig.build.json narrows to `src`, and
// package.json ships only `dist` + README. A worked example that doesn't compile is worse than
// no example, and these are what the first-party scanner repos are cut from.
"include": ["src", "test", "examples"]
}
+10 -3
View File
@@ -6,9 +6,16 @@
# SIGTERM interrupts the whole tree STRUCTURALLY, so every plugin's scoped finalizers run before
# exit (clean deregister / preset release) — hence the generous stop timeout below.
#
# OPT-IN — unlike punktfunk-web, the package does NOT auto-enable this: the runner does nothing until
# you add scripts or install plugins. Turn it on once you have automation to run:
# systemctl --user enable --now punktfunk-scripting
# ON BY DEFAULT — the packages enable this for every user (`systemctl --global enable` from the
# .deb/.rpm scriptlets; a baked-in default.target.wants symlink in the sysext image). It used to be
# opt-in, on the reasoning that the runner does nothing until you add scripts or plugins. That
# stopped being true when the game-library scanners became plugins: the library is a flagship
# surface, and a host whose runner is off now comes up with an empty library and no obvious reason
# why (design/library-scanner-plugins.md D9).
#
# It remains opt-OUT, per user:
# systemctl --user mask punktfunk-scripting
# (`mask`, not `disable` — a plain disable cannot remove a symlink that lives in /etc or /usr.)
#
# Auto-wired like the console: a plugin's connect() reads the host's SCOPED plugin token + identity
# cert from ~/.config/punktfunk/{plugin-token,cert.pem} (written by the host's `serve`) — no env
File diff suppressed because one or more lines are too long
+14
View File
@@ -44,6 +44,17 @@ export interface PluginUiOptions {
version?: string;
/** Optional lucide icon name for the nav entry (`[a-z0-9-]`, e.g. `"gamepad-2"`). */
icon?: string;
/**
* What KIND of plugin this is (`[a-z][a-z0-9-]{0,31}`). The console groups and filters on it
* and notably keeps `"library"` plugins **out of the nav**, because a scanner's entry point is
* the Library section's Game sources surface, not a sidebar item of its own. Six installed
* scanners would otherwise flood the sidebar.
*
* `@punktfunk/plugin-kit`'s `defineLibraryPlugin` sets this for you. Set it by hand only if you
* are building a library plugin without the kit and omit it if your plugin wants a full page
* despite also syncing a library (rom-manager does).
*/
category?: string;
/**
* Directory of the built SPA. Requests are served from here first (with an `index.html` SPA
* fallback for navigations); a static miss falls through to [`fetch`]. Accepts a filesystem
@@ -182,6 +193,9 @@ export const servePluginUi = async (
secret,
...(opts.icon !== undefined ? { icon: opts.icon } : {}),
},
// Sent through the UNTYPED `pf.request` below, so an older host simply ignores the unknown
// field rather than rejecting the registration — no runner flag, no version gate.
...(opts.category !== undefined ? { category: opts.category } : {}),
};
const register = () => pf.request("PUT", `/plugins/${opts.id}`, body);
+27 -9
View File
@@ -10,11 +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_origin_untrusted_title": "Port dieses Plugins einmal bestätigen",
"plugin_origin_untrusted_hint": "Plugin-Oberflächen laufen auf einem eigenen Port, damit ein Plugin nicht in deinem Namen auf der Konsole handeln kann. Dein Browser vertraut dem Zertifikat dieses Hosts für den Konsolen-Port, aber noch nicht für diesen — und in einem Frame kann er nicht nachfragen. Öffne ihn einmal in einem Tab, bestätige das Zertifikat und komm zurück.",
"plugin_origin_untrusted_open": "In neuem Tab öffnen",
"plugin_origin_unavailable_title": "Plugin-Oberflächen sind nicht verfügbar",
"plugin_origin_unavailable_hint": "Plugin-Oberflächen laufen auf einem eigenen Port, damit ein Plugin nicht in deinem Namen auf der Konsole handeln kann. Dieser Port konnte nicht geöffnet werden, deshalb bleiben sie deaktiviert. Sieh ins Konsolen-Log, setze dann PUNKTFUNK_UI_PLUGIN_PORT auf einen freien Port und starte neu.",
"plugin_offline_hint": "Starte den Scripting-Runner und versuche es erneut.",
"plugin_retry": "Erneut versuchen",
"plugin_open_new_tab": "In neuem Tab öffnen",
@@ -276,7 +276,7 @@
"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_password_help": "Ein Startbefehl läuft auf dem Host mit deinen Rechten. Bestätige zum Speichern dein Konsolen-Passwort.",
"library_field_platform": "Plattform",
"library_field_platform_help": "Das System, auf dem dieser Titel läuft, z. B. PS2, Xbox 360, SNES, PC.",
"library_field_description": "Beschreibung",
@@ -292,9 +292,25 @@
"library_field_players": "Spieler",
"library_details_legend": "Details (optional)",
"library_owned_by": "über {provider}",
"library_providers_title": "Von Plugins synchronisiert",
"library_providers_help": "Diese Einträge gehören einem Plugin und lassen sich deshalb nicht einzeln bearbeiten oder löschen — das Plugin synchronisiert sie neu. Ist das Plugin weg, entferne seine Einträge hier.",
"library_provider_count": "{count} Einträge",
"library_launchers_title": "Launcher",
"library_empty_add_source": "Füge unten eine Spielquelle hinzu, damit deine installierten Spiele hier erscheinen.",
"library_add_source": "Quelle hinzufügen",
"library_source_detected": "Erkannt",
"library_source_running": "Läuft",
"library_source_stopped": "Gestoppt",
"library_source_settings": "Einstellungen",
"library_source_settings_title": "Einstellungen für {source}",
"library_source_settings_save": "Einstellungen speichern",
"library_source_settings_saved": "Einstellungen gespeichert.",
"library_source_settings_failed": "Einstellungen konnten nicht gespeichert werden: {issue}",
"library_source_settings_unreachable": "Die Einstellungen dieser Quelle sind nicht erreichbar: {issue}",
"library_source_settings_json_hint": "Die Einstellungen dieser Quelle passen in kein einfaches Formular — bearbeite sie als JSON. Sie werden vor dem Speichern geprüft.",
"library_migrate_title": "Spielquellen werden zu Plugins",
"library_migrate_help": "Jeder Launcher wird ein eigenes Add-on — du installierst nur die, die du nutzt, und jedes bekommt eigene Einstellungen. Installierst du eines, übernimmt es vom eingebauten Scanner; deine Spiele behalten ihre Kacheln. Wenn du nichts tust, ändert sich nichts.",
"library_migrate_install": "Quelle {source} installieren",
"library_source_installing": "{title} wird installiert…",
"library_source_install_failed": "Diese Quelle konnte nicht installiert werden.",
"library_provider_filter": "Nur diese zeigen",
"library_provider_show_all": "Alle zeigen",
"library_provider_purge": "Einträge dieses Anbieters entfernen",
@@ -585,5 +601,7 @@
"update_result_noop": "Deine Paketquelle hatte noch nichts Neueres — in ein paar Minuten erneut versuchen.",
"update_opt_in": "Um Ein-Klick-Updates von hier zu aktivieren, einmal auf dem Host ausführen (danach ab- und wieder anmelden):",
"update_result_failed": "Update auf {to} ist in Phase {stage} fehlgeschlagen.",
"update_result_log": "Installer-Log:"
"update_result_log": "Installer-Log:",
"library_field_role": "Dieser Eintrag öffnet einen Launcher",
"library_field_role_help": "Zeigt ihn in der Launcher-Reihe über deinen Spielen statt im Raster. Er startet und endet genauso wie sonst."
}
+21 -3
View File
@@ -292,9 +292,25 @@
"library_field_players": "Players",
"library_details_legend": "Details (optional)",
"library_owned_by": "via {provider}",
"library_providers_title": "Synced by plugins",
"library_providers_help": "These entries are owned by a plugin, so they can't be edited or removed one at a time — the plugin re-syncs them. If the plugin is gone, remove its entries here.",
"library_provider_count": "{count} entries",
"library_launchers_title": "Launchers",
"library_empty_add_source": "Add a game source below to see your installed games here.",
"library_add_source": "Add a source",
"library_source_detected": "Detected",
"library_source_running": "Running",
"library_source_stopped": "Stopped",
"library_source_settings": "Settings",
"library_source_settings_title": "{source} settings",
"library_source_settings_save": "Save settings",
"library_source_settings_saved": "Settings saved.",
"library_source_settings_failed": "Could not save the settings: {issue}",
"library_source_settings_unreachable": "Could not reach this source's settings: {issue}",
"library_source_settings_json_hint": "This source's settings don't fit a simple form, so edit them as JSON. They're checked before saving.",
"library_migrate_title": "Game sources are moving to plugins",
"library_migrate_help": "Each launcher is becoming its own add-on, so you only install the ones you use — and each gets its own settings. Install one and it takes over from the built-in scanner; your games keep the same tiles. Nothing changes if you do nothing yet.",
"library_migrate_install": "Install the {source} source",
"library_source_installing": "Installing {title}…",
"library_source_install_failed": "Could not install this source.",
"library_provider_filter": "Show only these",
"library_provider_show_all": "Show all",
"library_provider_purge": "Remove this provider's entries",
@@ -585,5 +601,7 @@
"update_result_noop": "Your package source had nothing newer yet — try again in a few minutes.",
"update_opt_in": "To enable one-click updates from here, run this once on the host (then log out and back in):",
"update_result_failed": "Update to {to} failed during {stage}.",
"update_result_log": "Installer log:"
"update_result_log": "Installer log:",
"library_field_role": "This entry opens a launcher",
"library_field_role_help": "Shows it in the Launchers row above your games instead of in the grid. It still starts and stops the same way."
}
+24 -2
View File
@@ -29,8 +29,18 @@ export interface PluginSummary {
version?: string;
/** Present iff the plugin serves a UI (and thus gets a nav entry). */
ui?: PluginUiSummary;
/**
* What kind of plugin this is. The console knows one value `"library"` and keeps those OUT
* of the nav: a scanner's entry point is the Library section's Game sources surface, and six
* installed scanners would otherwise flood the sidebar (design D5). Absent on an older host, and
* absent by choice for a plugin that wants its own page anyway (rom-manager).
*/
category?: string;
}
/** The one category the console treats specially. */
export const LIBRARY_CATEGORY = "library";
// A curated lucide set for plugin nav icons. Importing lucide's full dynamic icon map would defeat
// tree-shaking (U-S4), so a plugin picks a name from here; anything unknown falls back to Puzzle.
const ICONS: Record<string, LucideIcon> = {
@@ -97,6 +107,18 @@ export function usePlugins() {
});
}
/** Only the plugins that surface a UI — the ones that get a nav entry. */
/**
* The plugins that get a **nav entry**: those serving a UI, minus the library-category ones.
*
* A library plugin still serves a UI port (that is how `__config` is reached) and its
* `/plugins/$pluginId/$` route still resolves, so an existing deep link keeps working it simply
* isn't advertised in the sidebar.
*/
export const uiPlugins = (list: PluginSummary[] | undefined): PluginSummary[] =>
(list ?? []).filter((p) => p.ui);
(list ?? []).filter((p) => p.ui && p.category !== LIBRARY_CATEGORY);
/** The installed library-category plugins — the Game sources surface's own list. */
export const libraryPlugins = (
list: PluginSummary[] | undefined,
): PluginSummary[] =>
(list ?? []).filter((p) => p.category === LIBRARY_CATEGORY);
+11
View File
@@ -69,6 +69,17 @@ export interface StoreEntry {
installed_version?: string;
update_available: boolean;
blocked?: string;
/**
* What kind of plugin this is. Browse filters on these, and the Library section's "Add a source"
* rail shows exactly the `library` ones (design D5/D6). Absent on an index that predates them.
*/
categories?: string[];
/**
* Whether the launcher this plugin scans looks installed on this host, from the index's own
* existence probes (design D8). `undefined` = the entry declares no probes for this platform,
* which is "unknown" and must render differently from "not installed".
*/
detected?: boolean;
}
export interface StoreCatalog {
+30
View File
@@ -27,6 +27,10 @@ interface FormState {
* own comment at the render site (2026-08-05 review M-6). Never round-tripped from the
* server, so it is always empty on open, including when editing an entry that has one. */
password: string;
/** `true` = this entry opens a launcher rather than a game (design D4). Purely presentational:
* the console groups launcher entries into their own rail, and clients that don't know the
* field render them as ordinary tiles. */
isLauncher: boolean;
// Details — the flattened GameMeta fields; numbers and lists are kept as the raw
// text the user typed and only parsed on submit.
platform: string;
@@ -48,6 +52,7 @@ const emptyForm: FormState = {
logo: "",
command: "",
password: "",
isLauncher: false,
platform: "",
description: "",
developer: "",
@@ -68,6 +73,9 @@ function formFrom(entry: GameEntry): FormState {
logo: entry.art.logo ?? "",
command: entry.launch?.kind === "command" ? entry.launch.value : "",
password: "",
// Round-tripped like every other field: `update_custom` REPLACES the whole entry, so an
// unread field here would silently demote a launcher entry back to a game on any edit.
isLauncher: entry.role === "launcher",
platform: entry.platform ?? "",
description: entry.description ?? "",
developer: entry.developer ?? "",
@@ -113,6 +121,8 @@ function toInput(f: FormState): CustomInput {
// The BFF re-verifies this and strips it before forwarding; the host never sees the field.
// Only sent when there is a command to authorize, matching the conditional gate.
...(command ? { password: f.password } : {}),
// Omitted when it is the default, matching the host's skip-when-`game` serialization.
...(f.isLauncher ? { role: "launcher" as const } : {}),
platform: trim(f.platform),
description: trim(f.description),
developer: trim(f.developer),
@@ -297,6 +307,26 @@ export const GameForm: FC<{
required
/>
)}
{/* Design D4: a launcher entry opens the launcher itself rather than a title. It
launches and leases like any other entry this only moves it into the
console's Launchers rail. Hand-adding one is the supported way to get a
"Heroic" or "Lutris" tile without installing that source's plugin. */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
id="lib-isLauncher"
type="checkbox"
checked={form.isLauncher}
onChange={(e) =>
setForm((f) => ({ ...f, isLauncher: e.target.checked }))
}
/>
<Label htmlFor="lib-isLauncher">{m.library_field_role()}</Label>
</div>
<p className="text-xs text-muted-foreground">
{m.library_field_role_help()}
</p>
</div>
<fieldset className="space-y-4 border-t pt-2">
<legend className="sr-only">{m.library_details_legend()}</legend>
<p
+46 -20
View File
@@ -82,14 +82,42 @@ export const LibraryGrid: FC<{
/** Custom id of the card whose delete is in flight, or null — only that card disables. */
deletingId: string | null;
}> = ({ library, onEdit, onDelete, deletingId }) => {
const games = library.data ?? [];
const all = library.data ?? [];
// Launcher entries (design D4) open the launcher itself — Steam Big Picture, Heroic — rather than
// a title. They launch and lease exactly like games; grouping them into their own rail is purely
// so a shelf of 400 games doesn't bury the two or three ways to open a launcher.
const launchers = all.filter((g) => g.role === "launcher");
const games = all.filter((g) => g.role !== "launcher");
const card = (game: GameEntry) => (
<GameCard
key={game.id}
game={game}
onEdit={() => onEdit(game)}
onDelete={() => onDelete(game)}
deleting={deletingId === customId(game)}
/>
);
return (
<QueryState
isLoading={library.isLoading}
error={library.error}
refetch={library.refetch}
>
{games.length === 0 ? (
{launchers.length > 0 && (
<div className="@container mb-card">
<p className="pb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground/70">
{m.library_launchers_title()}
</p>
<motion.div
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
>
{launchers.map(card)}
</motion.div>
</div>
)}
{all.length === 0 ? (
<Card>
{/* `flush`, not a bare `p-8`: the default `sm:pt-0` would survive the override
(tailwind-merge only resolves conflicts within a variant) and eat the top
@@ -98,27 +126,25 @@ export const LibraryGrid: FC<{
flush
className="p-8 text-center text-sm text-muted-foreground"
>
{m.library_empty()}
{/* After extraction a fresh host has NO scanners at all, so "no games" is the
expected first-run state rather than a fault. Point at the fix (design D9)
instead of leaving a bare empty grid. */}
<p>{m.library_empty()}</p>
<p className="mt-2">{m.library_empty_add_source()}</p>
</CardContent>
</Card>
) : (
<div className="@container">
<motion.div
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
>
{games.map((game) => (
<GameCard
key={game.id}
game={game}
onEdit={() => onEdit(game)}
onDelete={() => onDelete(game)}
deleting={deletingId === customId(game)}
/>
))}
</motion.div>
</div>
games.length > 0 && (
<div className="@container">
<motion.div
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
>
{games.map(card)}
</motion.div>
</div>
)
)}
</QueryState>
);
-104
View File
@@ -1,104 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { Trash2 } from "lucide-react";
import type { FC } from "react";
import {
getGetLibraryQueryKey,
useDeleteProviderEntries,
} from "@/api/gen/library/library";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { apiErrorMessage } from "@/lib/errors";
import { m } from "@/paraglide/messages";
/**
* Provider-owned entries: who put them there, and how to get rid of them.
*
* A plugin can sync entries into the library (RFC §8) and they are then refused to hand-edit or
* delete individually the host answers 409 and points at the provider's own reconcile. Which is
* correct, and completely opaque if the plugin is gone: uninstalling it leaves its games in the
* library with no console-side way to remove them. `DELETE /library/provider/{provider}` is the
* documented clean-uninstall path and nothing called it.
*
* Renders nothing when no entry carries a provider, so an ordinary library sees no extra chrome.
*/
export const ProvidersCard: FC<{
entries: GameEntry[];
/** The provider currently filtered to, or null for "everything". */
active: string | null;
onFilter: (provider: string | null) => void;
}> = ({ entries, active, onFilter }) => {
const qc = useQueryClient();
const purge = useDeleteProviderEntries();
// Count per provider, in first-seen order — the list is small and operator-facing.
const counts = new Map<string, number>();
for (const e of entries) {
if (e.provider) counts.set(e.provider, (counts.get(e.provider) ?? 0) + 1);
}
if (counts.size === 0) return null;
const onPurge = async (provider: string, count: number) => {
if (!confirm(m.library_provider_purge_confirm({ provider, count }))) return;
try {
await purge.mutateAsync({ provider });
// The host emits `library.changed`, but don't wait for the round trip to redraw.
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
if (active === provider) onFilter(null);
toast.success(m.library_provider_purged({ provider }));
} catch (e) {
toast.error(apiErrorMessage(e) ?? m.library_provider_purge_failed());
}
};
return (
<Card>
<CardHeader>
<CardTitle>{m.library_providers_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="max-w-prose text-sm text-muted-foreground">
{m.library_providers_help()}
</p>
<div className="flex flex-col gap-2">
{[...counts.entries()].map(([provider, count]) => (
<div
key={provider}
className="flex flex-wrap items-center gap-3 rounded-lg border p-3"
>
<span className="font-medium">{provider}</span>
<Badge variant="secondary">
{m.library_provider_count({ count })}
</Badge>
<div className="ml-auto flex gap-2">
<Button
size="sm"
variant={active === provider ? "default" : "outline"}
aria-pressed={active === provider}
onClick={() =>
onFilter(active === provider ? null : provider)
}
>
{active === provider
? m.library_provider_show_all()
: m.library_provider_filter()}
</Button>
<Button
size="sm"
variant="outline"
disabled={purge.isPending}
aria-label={m.library_provider_purge()}
onClick={() => onPurge(provider, count)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
};
+345
View File
@@ -0,0 +1,345 @@
import { toast } from "@unom/ui/toast";
import { type FC, useEffect, useState } from "react";
import type { ScannerInfo } from "@/api/gen/model/scannerInfo";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { m } from "@/paraglide/messages";
/**
* A library source's settings, rendered as a **generic form** from the plugin's own JSON Schema.
*
* The point (design D7, closing G8): a scanner plugin ships no SPA at all. It serves
* `GET/PUT /__config` from the kit, and the console renders whatever schema comes back. Everything
* goes through the existing session-gated `/plugin-ui/<id>/…` proxy, so there is **zero new host
* surface** the browser never learns the plugin's port or secret.
*
* Fields the derivation can't express fall back to a raw JSON editor. That fallback is what bounds
* the risk of the whole approach: worst case the drawer is a validated textarea, and the PUT still
* validates by decode host-side either way.
*/
export const SourceSettingsDialog: FC<{
source: ScannerInfo;
onClose: () => void;
}> = ({ source, onClose }) => {
const pluginId = source.provider ?? source.id;
const [state, setState] = useState<
| { tag: "loading" }
| { tag: "error"; message: string }
| { tag: "ready"; schema: JsonSchemaDoc | null; value: JsonObject }
>({ tag: "loading" });
const [raw, setRaw] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`/plugin-ui/${pluginId}/__config`, {
credentials: "same-origin",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = (await res.json()) as {
schema: JsonSchemaDoc | null;
value: JsonObject | null;
};
if (cancelled) return;
const value = body.value ?? {};
setState({ tag: "ready", schema: body.schema, value });
setRaw(JSON.stringify(value, null, 2));
} catch (e) {
if (!cancelled) {
setState({ tag: "error", message: String(e) });
}
}
})();
return () => {
cancelled = true;
};
}, [pluginId]);
const save = async (value: JsonObject) => {
setSaving(true);
try {
const res = await fetch(`/plugin-ui/${pluginId}/__config`, {
method: "PUT",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify(value),
});
if (!res.ok) {
const body = (await res.json().catch(() => null)) as {
issue?: string;
} | null;
throw new Error(body?.issue ?? `HTTP ${res.status}`);
}
toast.success(m.library_source_settings_saved());
onClose();
} catch (e) {
toast.error(m.library_source_settings_failed({ issue: String(e) }));
} finally {
setSaving(false);
}
};
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{m.library_source_settings_title({ source: source.label })}
</DialogTitle>
</DialogHeader>
{state.tag === "loading" && <Spinner />}
{state.tag === "error" && (
<p className="text-sm text-destructive">
{m.library_source_settings_unreachable({ issue: state.message })}
</p>
)}
{state.tag === "ready" && (
<ConfigForm
schema={state.schema}
value={state.value}
raw={raw}
onRaw={setRaw}
saving={saving}
onSave={save}
/>
)}
</DialogContent>
</Dialog>
);
};
type JsonObject = Record<string, unknown>;
interface JsonSchemaNode {
type?: string;
title?: string;
description?: string;
default?: unknown;
enum?: string[];
properties?: Record<string, JsonSchemaNode>;
items?: JsonSchemaNode;
allOf?: JsonSchemaNode[];
}
interface JsonSchemaDoc {
schema?: JsonSchemaNode;
}
/**
* Flatten a node's `allOf` branches into it. A *checked* schema (effect's `Schema.Int`, or anything
* with `.check(...)`) nests its annotations and constraints there rather than at the top level, so
* a form that only reads the top level silently loses every title and default on those fields.
*/
const flatten = (node: JsonSchemaNode): JsonSchemaNode =>
(node.allOf ?? []).reduce<JsonSchemaNode>(
(acc, branch) => ({ ...acc, ...branch }),
{ ...node },
);
/** Can this field be rendered as a real input? Anything else sends the whole form to the editor. */
const renderable = (node: JsonSchemaNode): boolean => {
const n = flatten(node);
if (n.enum) return true;
if (n.type === "boolean" || n.type === "string") return true;
if (n.type === "number" || n.type === "integer") return true;
if (n.type === "array" && flatten(n.items ?? {}).type === "string") return true;
if (n.type === "object" && n.properties) {
return Object.values(n.properties).every(renderable);
}
return false;
};
const ConfigForm: FC<{
schema: JsonSchemaDoc | null;
value: JsonObject;
raw: string;
onRaw: (v: string) => void;
saving: boolean;
onSave: (value: JsonObject) => void;
}> = ({ schema, value, raw, onRaw, saving, onSave }) => {
const [draft, setDraft] = useState<JsonObject>(value);
const root = schema?.schema ? flatten(schema.schema) : undefined;
const props = root?.properties;
// Fall back to the JSON editor when there is no schema, or any field is a shape the generic
// form can't express (a non-enum union, a $ref). Partial rendering would be worse than none:
// a field silently missing from the form is a setting the operator cannot change.
const canRender = props !== undefined && Object.values(props).every(renderable);
if (!canRender) {
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
{m.library_source_settings_json_hint()}
</p>
<textarea
className="h-64 w-full rounded-md border bg-background p-2 font-mono text-xs"
value={raw}
onChange={(e) => onRaw(e.target.value)}
spellCheck={false}
/>
<Button
disabled={saving}
onClick={() => {
try {
onSave(JSON.parse(raw) as JsonObject);
} catch (e) {
toast.error(
m.library_source_settings_failed({ issue: String(e) }),
);
}
}}
>
{m.library_source_settings_save()}
</Button>
</div>
);
}
return (
<div className="space-y-4">
{Object.entries(props).map(([key, rawNode]) => (
<Field
key={key}
name={key}
node={flatten(rawNode)}
value={draft[key]}
onChange={(v) => setDraft((d) => ({ ...d, [key]: v }))}
/>
))}
<Button disabled={saving} onClick={() => onSave(draft)}>
{m.library_source_settings_save()}
</Button>
</div>
);
};
/** One schema field. `undefined` in the draft means "unset" — the file keeps its default out. */
const Field: FC<{
name: string;
node: JsonSchemaNode;
value: unknown;
onChange: (v: unknown) => void;
}> = ({ name, node, value, onChange }) => {
const label = node.title ?? name;
const id = `cfg-${name}`;
if (node.type === "object" && node.properties) {
const nested = (value ?? {}) as JsonObject;
return (
<fieldset className="space-y-3 rounded-lg border p-3">
<legend className="px-1 text-sm font-medium">{label}</legend>
{Object.entries(node.properties).map(([k, n]) => (
<Field
key={k}
name={`${name}.${k}`}
node={flatten(n)}
value={nested[k]}
onChange={(v) => onChange({ ...nested, [k]: v })}
/>
))}
</fieldset>
);
}
if (node.enum) {
return (
<div className="space-y-1">
<Label htmlFor={id}>{label}</Label>
<select
id={id}
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={String(value ?? node.default ?? node.enum[0])}
onChange={(e) => onChange(e.target.value)}
>
{node.enum.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
{node.description && (
<p className="text-xs text-muted-foreground">{node.description}</p>
)}
</div>
);
}
if (node.type === "boolean") {
const checked = (value ?? node.default ?? false) as boolean;
return (
<div className="space-y-1">
<div className="flex items-center gap-2">
<input
id={id}
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
<Label htmlFor={id}>{label}</Label>
</div>
{node.description && (
<p className="text-xs text-muted-foreground">{node.description}</p>
)}
</div>
);
}
if (node.type === "array") {
// One absolute path per line — the shape every "extra library folders" setting wants.
const list = (value ?? node.default ?? []) as string[];
return (
<div className="space-y-1">
<Label htmlFor={id}>{label}</Label>
<textarea
id={id}
className="h-24 w-full rounded-md border bg-background p-2 font-mono text-xs"
value={list.join("\n")}
onChange={(e) =>
onChange(
e.target.value
.split("\n")
.map((s) => s.trim())
.filter((s) => s !== ""),
)
}
/>
{node.description && (
<p className="text-xs text-muted-foreground">{node.description}</p>
)}
</div>
);
}
const numeric = node.type === "number" || node.type === "integer";
return (
<div className="space-y-1">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
type={numeric ? "number" : "text"}
value={String(value ?? "")}
placeholder={node.default != null ? String(node.default) : undefined}
onChange={(e) => {
const v = e.target.value;
// An emptied field means "unset", which is NOT the same as zero or "" — it is what
// keeps the operator's file free of a value they never chose.
if (v === "") return onChange(undefined);
onChange(numeric ? Number(v) : v);
}}
/>
{node.description && (
<p className="text-xs text-muted-foreground">{node.description}</p>
)}
</div>
);
};
@@ -1,85 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { Check } from "lucide-react";
import type { FC } from "react";
import {
getGetLibraryQueryKey,
getListLibraryScannersQueryKey,
useListLibraryScanners,
useSetLibraryScanner,
} from "@/api/gen/library/library";
import type { ScannerInfo } from "@/api/gen/model/scannerInfo";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
/**
* Container: the game-source (library scanner) toggles owns the scanner query and the toggle
* mutation. The host only reports the scanners its platform actually has (Steam everywhere,
* Lutris/Heroic on Linux, Epic/GOG/Xbox on Windows), so whatever arrives is renderable as-is.
* Rendered only once the list is loaded: this is a secondary control, and when the API is down
* the grid's own QueryState already tells the story no second error banner.
*/
export const SourceTogglesSection: FC = () => {
const qc = useQueryClient();
const scanners = useListLibraryScanners();
const toggle = useSetLibraryScanner();
const onToggle = async (scanner: ScannerInfo) => {
try {
// The PUT answers with the full updated list — seed the query cache with it directly,
// then refetch the library so the grid reflects the new source set.
const list = await toggle.mutateAsync({
id: scanner.id,
data: { enabled: !scanner.enabled },
});
qc.setQueryData(getListLibraryScannersQueryKey(), list);
await qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
} catch {
toast.error(m.library_sources_failed());
}
};
if (!scanners.data) return null;
return (
<SourceToggles
scanners={scanners.data}
busyId={toggle.isPending ? (toggle.variables?.id ?? null) : null}
onToggle={onToggle}
/>
);
};
/** The sources card: one pressed/unpressed chip per scanner (pressed = the host scans it). */
export const SourceToggles: FC<{
scanners: ScannerInfo[];
/** Scanner id whose toggle is in flight, or null — only that chip disables. */
busyId: string | null;
onToggle: (scanner: ScannerInfo) => void;
}> = ({ scanners, busyId, onToggle }) => (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{m.library_sources_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2">
{scanners.map((scanner) => (
<Button
key={scanner.id}
size="sm"
variant={scanner.enabled ? "default" : "outline"}
aria-pressed={scanner.enabled}
disabled={busyId === scanner.id}
onClick={() => onToggle(scanner)}
>
{scanner.enabled && <Check className="size-4" />}
{scanner.label}
</Button>
))}
</div>
<p className="max-w-prose text-xs text-muted-foreground">
{m.library_sources_help()}
</p>
</CardContent>
</Card>
);
+361
View File
@@ -0,0 +1,361 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { Check, Download, Settings2, Trash2 } from "lucide-react";
import { type FC, useState } from "react";
import {
getGetLibraryQueryKey,
getListLibraryScannersQueryKey,
useDeleteProviderEntries,
useListLibraryScanners,
useSetLibraryScanner,
} from "@/api/gen/library/library";
import type { ScannerInfo } from "@/api/gen/model/scannerInfo";
import { libraryPlugins, usePlugins } from "@/api/plugins";
import {
type StoreEntry,
useInstallPlugin,
useStoreCatalog,
} from "@/api/store";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { apiErrorMessage } from "@/lib/errors";
import { m } from "@/paraglide/messages";
import { SourceSettingsDialog } from "./SourceSettings";
/**
* **Game sources** the single surface for "where do my games come from", merging what used to be
* two cards (the scanner toggles and the "synced by plugins" list).
*
* They were split because they were different things: scanners were compiled into the host and
* plugins were an afterthought. After the extraction they are the *same* thing the host reports
* one list of sources whose ids match whether they came from a built-in scanner or the plugin that
* replaced it so one surface is both simpler and the only honest presentation (design D6).
*
* Deliberately kept under the existing "Game sources" label rather than a new "Plugins" heading:
* `store_title` and `nav_plugins` are both already "Plugins", and a third would be worse than the
* merge is good.
*/
export const SourcesSection: FC<{
/** The provider currently filtered to in the grid, or null for "everything". */
activeFilter: string | null;
onFilter: (provider: string | null) => void;
}> = ({ activeFilter, onFilter }) => {
const qc = useQueryClient();
const scanners = useListLibraryScanners();
const toggle = useSetLibraryScanner();
const purge = useDeleteProviderEntries();
const plugins = usePlugins();
const catalog = useStoreCatalog();
const install = useInstallPlugin();
const [settingsFor, setSettingsFor] = useState<ScannerInfo | null>(null);
const onToggle = async (source: ScannerInfo) => {
try {
// The PUT answers with the full updated list — seed the query cache with it directly,
// then refetch the library so the grid reflects the new source set.
const list = await toggle.mutateAsync({
id: source.id,
data: { enabled: !source.enabled },
});
qc.setQueryData(getListLibraryScannersQueryKey(), list);
await qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
} catch {
toast.error(m.library_sources_failed());
}
};
const onPurge = async (source: ScannerInfo) => {
const provider = source.provider ?? source.id;
const count = source.entries ?? 0;
if (!confirm(m.library_provider_purge_confirm({ provider, count }))) return;
try {
await purge.mutateAsync({ provider });
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
qc.invalidateQueries({ queryKey: getListLibraryScannersQueryKey() });
if (activeFilter === provider) onFilter(null);
toast.success(m.library_provider_purged({ provider }));
} catch (e) {
toast.error(apiErrorMessage(e) ?? m.library_provider_purge_failed());
}
};
const onInstall = async (entry: StoreEntry) => {
try {
// Install by (source, id) — the catalogued, integrity-pinned path. The raw-spec form is
// for unverified installs and must never be reachable from a one-click rail.
await install.mutateAsync({ source: entry.source, id: entry.id });
toast.success(m.library_source_installing({ title: entry.title }));
} catch (e) {
toast.error(apiErrorMessage(e) ?? m.library_source_install_failed());
}
};
// This is a secondary control: when the API is down the grid's own QueryState already tells the
// story, so render nothing rather than a second error banner.
if (!scanners.data) return null;
// Catalog rows that are library sources and not already installed — the "Add a source" rail.
const installedPkgs = new Set(
(catalog.data?.plugins ?? [])
.filter((p) => p.installed_version)
.map((p) => p.pkg),
);
const available = (catalog.data?.plugins ?? []).filter(
(p) => p.categories?.includes("library") && !installedPkgs.has(p.pkg),
);
const running = new Set(libraryPlugins(plugins.data).map((p) => p.id));
// The bridge-release nudge (design D9): a built-in scanner still doing the work, with its
// replacement plugin sitting uninstalled in the catalog. One click per scanner, and NEVER a
// silent auto-install — installing code stays an explicit operator act.
const migratable = scanners.data
.filter((s) => s.origin === "builtin" && s.enabled)
.map((s) => ({
source: s,
entry: available.find((p) => p.id === s.id && p.compatible),
}))
.filter((r): r is { source: ScannerInfo; entry: StoreEntry } => !!r.entry);
return (
<>
{migratable.length > 0 && (
<MigrationBanner
rows={migratable}
busy={catalog.data?.busy === true || install.isPending}
onInstall={onInstall}
/>
)}
<SourcesCard
sources={scanners.data}
available={available}
running={running}
busyId={toggle.isPending ? (toggle.variables?.id ?? null) : null}
installBusy={catalog.data?.busy === true || install.isPending}
activeFilter={activeFilter}
onToggle={onToggle}
onFilter={onFilter}
onSettings={setSettingsFor}
onPurge={onPurge}
onInstall={onInstall}
/>
{settingsFor && (
<SourceSettingsDialog
source={settingsFor}
onClose={() => setSettingsFor(null)}
/>
)}
</>
);
};
/**
* "Game sources are moving to plugins" shown only while a built-in scanner is still doing a job a
* catalogued plugin could take over.
*
* One button per scanner rather than a single "migrate everything": installing a plugin is an
* explicit operator act under the store's consent model, and per-scanner is also what makes it safe
* to repeat the claim suppresses the built-in idempotently (design D2), so a half-finished
* migration is a valid state rather than a mess.
*/
export const MigrationBanner: FC<{
rows: ReadonlyArray<{ source: ScannerInfo; entry: StoreEntry }>;
busy: boolean;
onInstall: (entry: StoreEntry) => void;
}> = ({ rows, busy, onInstall }) => (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{m.library_migrate_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="max-w-prose text-sm text-muted-foreground">
{m.library_migrate_help()}
</p>
<div className="flex flex-wrap gap-2">
{rows.map(({ source, entry }) => (
<Button
key={source.id}
size="sm"
variant="outline"
disabled={busy}
onClick={() => onInstall(entry)}
>
<Download className="size-4" />
{m.library_migrate_install({ source: source.label })}
</Button>
))}
</div>
</CardContent>
</Card>
);
/** The sources card itself — presentational, so Storybook can drive every state. */
export const SourcesCard: FC<{
sources: ScannerInfo[];
/** Catalog rows offering a library source that isn't installed yet. */
available: StoreEntry[];
/** Ids of library plugins whose lease is currently live. */
running: Set<string>;
/** Source id whose toggle is in flight, or null — only that row disables. */
busyId: string | null;
installBusy: boolean;
activeFilter: string | null;
onToggle: (source: ScannerInfo) => void;
onFilter: (provider: string | null) => void;
onSettings: (source: ScannerInfo) => void;
onPurge: (source: ScannerInfo) => void;
onInstall: (entry: StoreEntry) => void;
}> = ({
sources,
available,
running,
busyId,
installBusy,
activeFilter,
onToggle,
onFilter,
onSettings,
onPurge,
onInstall,
}) => (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{m.library_sources_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-2">
{sources.map((source) => (
<SourceRow
key={source.id}
source={source}
running={running.has(source.id)}
busy={busyId === source.id}
filtered={
activeFilter !== null &&
activeFilter === (source.provider ?? source.id)
}
onToggle={() => onToggle(source)}
onFilter={() => {
const p = source.provider ?? source.id;
onFilter(activeFilter === p ? null : p);
}}
onSettings={() => onSettings(source)}
onPurge={() => onPurge(source)}
/>
))}
</div>
<p className="max-w-prose text-xs text-muted-foreground">
{m.library_sources_help()}
</p>
{available.length > 0 && (
<div className="space-y-2 border-t pt-4">
<p className="text-sm font-medium">{m.library_add_source()}</p>
<div className="flex flex-wrap gap-2">
{available.map((entry) => (
<Button
key={entry.pkg}
size="sm"
variant="outline"
disabled={!entry.compatible || installBusy}
title={entry.incompatible_reason ?? entry.description}
onClick={() => onInstall(entry)}
>
<Download className="size-4" />
{entry.title}
{/* `detected` is tri-state: only badge a POSITIVE probe. An entry with
no probes for this platform is "unknown", and labelling that "not
installed" would be a lie. */}
{entry.detected === true && (
<Badge variant="secondary">
{m.library_source_detected()}
</Badge>
)}
</Button>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
/** One source row: enable toggle, provenance, counts, and its per-source actions. */
const SourceRow: FC<{
source: ScannerInfo;
/** The plugin backing this source is currently registered (its lease is live). */
running: boolean;
busy: boolean;
filtered: boolean;
onToggle: () => void;
onFilter: () => void;
onSettings: () => void;
onPurge: () => void;
}> = ({
source,
running,
busy,
filtered,
onToggle,
onFilter,
onSettings,
onPurge,
}) => {
const isPlugin = source.origin === "plugin";
return (
<div className="flex flex-wrap items-center gap-3 rounded-lg border p-3">
<Button
size="sm"
variant={source.enabled ? "default" : "outline"}
aria-pressed={source.enabled}
disabled={busy}
onClick={onToggle}
>
{source.enabled && <Check className="size-4" />}
{source.label}
</Button>
{isPlugin && (
<Badge variant={running ? "secondary" : "outline"}>
{running ? m.library_source_running() : m.library_source_stopped()}
</Badge>
)}
{source.entries != null && (
<Badge variant="secondary">
{m.library_provider_count({ count: source.entries })}
</Badge>
)}
<div className="ml-auto flex gap-2">
{isPlugin && (
<>
<Button
size="sm"
variant={filtered ? "default" : "outline"}
aria-pressed={filtered}
onClick={onFilter}
>
{filtered
? m.library_provider_show_all()
: m.library_provider_filter()}
</Button>
<Button
size="sm"
variant="outline"
aria-label={m.library_source_settings()}
onClick={onSettings}
>
<Settings2 className="size-4" />
</Button>
<Button
size="sm"
variant="outline"
aria-label={m.library_provider_purge()}
onClick={onPurge}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</>
)}
</div>
</div>
);
};
+3 -7
View File
@@ -7,8 +7,7 @@ import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { type FormTarget, GameFormSection } from "./GameForm";
import { LibraryGridSection } from "./LibraryGrid";
import { ProvidersCard } from "./Providers";
import { SourceTogglesSection } from "./SourceToggles";
import { SourcesSection } from "./Sources";
// Library = an OVERVIEW grid + a SEPARATE add/edit form, deliberately split into their own files
// (LibraryGrid / GameForm) so the two concerns never share a component. This container owns only the
@@ -44,11 +43,8 @@ export const SectionLibrary: FC = () => {
/>
)}
<SourceTogglesSection />
<ProvidersCard
entries={entries}
active={providerFilter}
<SourcesSection
activeFilter={providerFilter}
onFilter={setProviderFilter}
/>
+166 -9
View File
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { GameForm } from "@/sections/Library/GameForm";
import { LibraryGrid } from "@/sections/Library/LibraryGrid";
import { SourceToggles } from "@/sections/Library/SourceToggles";
import { MigrationBanner, SourcesCard } from "@/sections/Library/Sources";
import { library } from "./lib/fixtures";
const noop = () => {};
@@ -16,6 +16,7 @@ const emptyForm = {
// The console-password confirmation the form requires alongside a launch command; empty here
// because the story renders the untouched add form, which has no command yet.
password: "",
isLauncher: false,
platform: "",
description: "",
developer: "",
@@ -48,6 +49,31 @@ export const Populated: Story = {
),
};
/** Launcher entries (design D4) get their own rail above the grid. */
export const WithLaunchers: Story = {
render: () => (
<LibraryGrid
library={{
data: [
{
id: "steam:bigpicture",
store: "steam",
title: "Steam Big Picture",
art: { portrait: null, hero: null, logo: null, header: null },
role: "launcher",
launch: { kind: "steam_ui", value: "bigpicture" },
},
...library,
],
...idle,
}}
onEdit={noop}
onDelete={noop}
deletingId={null}
/>
),
};
export const Empty: Story = {
render: () => (
<LibraryGrid
@@ -59,17 +85,148 @@ export const Empty: Story = {
),
};
/** A catalog row for the "Add a source" rail — only the fields the card actually reads. */
const catalogEntry = (
over: Partial<Parameters<typeof SourcesCard>[0]["available"][number]>,
) =>
({
id: "steam",
pkg: "@punktfunk/plugin-steam",
title: "Steam",
description: "Steam library scanner",
author: "unom",
version: "0.1.0",
source: "unom",
tier: "verified",
platforms: [],
compatible: true,
update_available: false,
categories: ["library"],
...over,
}) as Parameters<typeof SourcesCard>[0]["available"][number];
const sourcesArgs = {
available: [],
running: new Set<string>(),
busyId: null,
installBusy: false,
activeFilter: null,
onToggle: noop,
onFilter: noop,
onSettings: noop,
onPurge: noop,
onInstall: noop,
};
/** The bridge-release shape: built-in scanners only, one turned off. */
export const Sources: Story = {
render: () => (
<SourceToggles
// A Linux host's scanner set, one turned off — the widest built-in list.
scanners={[
{ id: "steam", label: "Steam", enabled: true },
{ id: "lutris", label: "Lutris", enabled: false },
{ id: "heroic", label: "Heroic (Epic / GOG / Amazon)", enabled: true },
<SourcesCard
{...sourcesArgs}
sources={[
{ id: "steam", label: "Steam", enabled: true, origin: "builtin" },
{ id: "lutris", label: "Lutris", enabled: false, origin: "builtin" },
{
id: "heroic",
label: "Heroic (Epic / GOG / Amazon)",
enabled: true,
origin: "builtin",
},
]}
busyId={null}
onToggle={noop}
/>
),
};
/** Mid-migration: a claimed plugin source beside the remaining built-ins, one plugin stopped. */
export const SourcesWithPlugins: Story = {
render: () => (
<SourcesCard
{...sourcesArgs}
sources={[
{
id: "steam",
label: "Steam",
enabled: true,
origin: "plugin",
provider: "steam",
entries: 214,
},
{
id: "lutris",
label: "Lutris",
enabled: false,
origin: "plugin",
provider: "lutris",
entries: 12,
},
{
id: "heroic",
label: "Heroic (Epic / GOG / Amazon)",
enabled: true,
origin: "builtin",
},
]}
running={new Set(["steam"])}
available={[
catalogEntry({ pkg: "@punktfunk/plugin-heroic", title: "Heroic" }),
]}
/>
),
};
/** A fresh host after extraction: nothing installed, two launchers detected on this box. */
export const SourcesEmptyWithDetected: Story = {
render: () => (
<SourcesCard
{...sourcesArgs}
sources={[]}
available={[
catalogEntry({ detected: true }),
catalogEntry({
pkg: "@punktfunk/plugin-lutris",
title: "Lutris",
detected: true,
}),
catalogEntry({
pkg: "@punktfunk/plugin-heroic",
title: "Heroic",
detected: false,
}),
]}
/>
),
};
/** The bridge-release nudge — one button per still-built-in scanner, never an auto-install. */
export const Migration: Story = {
render: () => (
<MigrationBanner
rows={[
{
source: {
id: "steam",
label: "Steam",
enabled: true,
origin: "builtin",
},
entry: catalogEntry({}),
},
{
source: {
id: "lutris",
label: "Lutris",
enabled: true,
origin: "builtin",
},
entry: catalogEntry({
pkg: "@punktfunk/plugin-lutris",
id: "lutris",
title: "Lutris",
}),
},
]}
busy={false}
onInstall={noop}
/>
),
};