Implements design/library-scanner-plugins-implementation-plan.md M0–M4 and M6. The six in-host
scanners can now be lifted out into plugins without anything downstream noticing. The scanners are
still here and still working — this is the bridge half; the removal release (WP6.4) is separate.
Two plugins are already built against this and shipping green: lutris · heroic. @punktfunk/plugin-kit@0.3.0 is published.
main is merged in and fully contained — conflict-free, CI green on every job.
Commits
a418d285
M1 — launch helpers into launch.rs; art proxy resolves any id
3d4a6599
M2 — store claims, launcher entries, plugin sources on the wire
8728d90e
M3 — the plugin-kit library framework
bd383f18
M4 — one Game sources surface, launcher rail, migration nudge
ce8f3e9e
M6/WP6.1 — the plugin runner becomes a default component
91fa32fb
the parity gate moves into the kit (one repo per plugin)
a1b8627e
the lutris pilot as a worked example, and the export gap it found
d2085879
merge main — plugin art rides through the H-2 confinement
6f07bd94
launcher tiles a plugin can actually publish (launcher_ui)
The load-bearing idea
Store claims (D2).PUT /library/provider/{p}?store=steam lets a plugin claim a store, so its
entries get the deterministic <store>:<external_id> ids the built-in scanner produced — not opaque custom:<hex> ones. That is what keeps GameStream FNV app ids, Moonlight pins, client art caches and
the operator's library-scanners.json disabled state valid across the migration. While a claim is
held the matching built-in scanner is skipped, so the two never double-list; removing the plugin
releases the claim and the built-in comes straight back.
The identity chain is one string end to end — repo name → npm package → plugin id → provider id →
store claim → scanner id — which is why the plugin repos were renamed to match (xbox, not xbox-app).
#58 hardened the art proxy in the same three files this branch rewrote, in the opposite direction:
it 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, never beside it. Everything #58 added is kept unchanged.
The subtle one: art_roots() 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, an empty
root list is not "secure by default": it serves no plugin art on Linux, i.e. every cover the
lutris and steam plugins emit. It now includes $HOME — the exact analogue of the Windows users base #58 already ships — and that is not the load-bearing control anyway: 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.
Also: file:// is converted to a path before the confinement check, so the check and the read
see the same path — percent-decoding before canonicalization, so %2e%2e can't hide from the
traversal check. Pinned by a test.
D4 promised entries that open the launcher (Steam Big Picture, Heroic, Lutris) and said the
non-Steam ones would ride the command kind. #58 then made launch.kind = "command" operator-only,
so a plugin publishing one is refused. Both changes are individually right and jointly left a hole: steam_ui was the only launcher kind a plugin could publish, so a Heroic or Lutris tile was
unreachable.
6f07bd94 adds a typed launcher_ui kind valued by store id — one kind, because every launcher but
Steam has exactly one UI to open. D1 is preserved: the plugin names a launcher, the host builds the
command. Platform-gated and validated inbound, so an unsupported value is a 400 the author can
act on rather than a tile that does nothing when clicked. Windows launchers are deliberately absent
until each has a verified activation.
Four bugs found on the way
is_steam_launch gated on a steam:// URI (gamescope.rs). A steam_ui launcher entry
carries no URI, so it would have skipped both gamescope's --steam mode and the B1
single-instance free — on a box autologged into game mode the nested Steam would see the first
instance and exit, crashing the spawn. Now tests the first token.
prep_for stripped a custom: prefix, so a claimed entry would have silently lost its
per-title prep. Now resolved through the shared id mapping.
steam_ui was missing from the host-resolved launch kinds named in privileged_field's doc
comment and in the 403 a plugin sees. Prose only — the check is a denylist — but a plugin author
reading that error would have concluded their launcher entries were refused.
GameForm had no way to mark an entry as a launcher (a WP4.3 item that was missed). Added,
and formFrom round-trips it — without which editing a launcher entry would silently demote it
to a game, which is exactly the bug that file's own comment warns about.
What IS verified
CI green on 6f07bd94: ci / rust, ci / rust-arm64, ci / web, ci / docs-site, windows / build (x86_64 and aarch64), apple / swift, android.
On .21: punktfunk-host435 passed / 0 failed — including #58's H-2 confinement tests and the
new file:// and launcher_ui ones — full workspace tests, workspace clippy --all-targets -- -D warnings, cargo fmt --all --check, and the OpenAPI drift test.
plugin-kit: tsc clean, 56 tests. Console: orval + paraglide regen, tsc, vite build, check-i18n
at 604 messages for en + de.
What is NOT verified — accept these knowingly
Everything below is code-reviewed and test-covered but has not been exercised on real hardware.
None of it is reachable from CI.
No browser click-through of the console. The store's Tabs-theme bug once shipped through green
types and lint and was only visible rendered; the new sources surface, the schema-driven settings
drawer and the launcher checkbox deserve the same scepticism.
The Windows host has not been built or linted (windows-host.yml did not run here — the
green Windows jobs are the client builds). steam_ui / epic / gog launch resolution is
Windows-only code whose unit tests only compile there.
No packaging was built or installed. The .iss change needs a fresh install, an upgrade, and
an upgrade with PunktfunkScripting deliberately disabled — that last one is the case the
fresh-vs-upgrade probe exists for. deb/rpm/sysext need package builds.
The parity gate has never been run. It exists and is unit-tested, but proving a plugin
reproduces its scanner needs a box with that launcher installed. No plugin has passed it yet,
including the two already published.
No launcher tile has been clicked on a real host, and nothing emits one automatically yet —
the steam plugin is the first that will, and it is not built. What is newly possible today is
hand-adding a Heroic or Lutris tile from the console.
plugin-kit-v0.3.0 is tagged at a1b8627e on this branch, so the published package's source only
reaches main when this lands. Its documented launch-kind table predates launcher_ui and will
catch up in the next kit release.
Not in scope
WP6.4 (deleting the six scanner modules) is the removal release, after all six plugins are pinned
and the bridge has had a quiet field window. WP5.6 index pins are per-plugin PRs to punktfunk-plugin-index, which you merge. Four plugins remain: steam, gog, epic, and xbox (still
gated on the S1 LocalService probe).
Implements `design/library-scanner-plugins-implementation-plan.md` M0–M4 and M6. The six in-host
scanners can now be lifted out into plugins without anything downstream noticing. **The scanners are
still here and still working** — this is the bridge half; the removal release (WP6.4) is separate.
Two plugins are already built against this and shipping green:
[lutris](https://git.unom.io/unom/punktfunk-plugin-lutris) ·
[heroic](https://git.unom.io/unom/punktfunk-plugin-heroic).
`@punktfunk/plugin-kit@0.3.0` is published.
`main` is merged in and fully contained — conflict-free, CI green on every job.
### Commits
| | |
|---|---|
| `a418d285` | **M1** — launch helpers into `launch.rs`; art proxy resolves any id |
| `3d4a6599` | **M2** — store claims, launcher entries, plugin sources on the wire |
| `8728d90e` | **M3** — the plugin-kit library framework |
| `bd383f18` | **M4** — one Game sources surface, launcher rail, migration nudge |
| `ce8f3e9e` | **M6/WP6.1** — the plugin runner becomes a default component |
| `91fa32fb` | the parity gate moves into the kit (one repo per plugin) |
| `a1b8627e` | the lutris pilot as a worked example, and the export gap it found |
| `d2085879` | **merge main** — plugin art rides *through* the H-2 confinement |
| `6f07bd94` | launcher tiles a plugin can actually publish (`launcher_ui`) |
### The load-bearing idea
**Store claims (D2).** `PUT /library/provider/{p}?store=steam` lets a plugin claim a store, so its
entries get the deterministic `<store>:<external_id>` ids the built-in scanner produced — not opaque
`custom:<hex>` ones. That is what keeps GameStream FNV app ids, Moonlight pins, client art caches and
the operator's `library-scanners.json` disabled state valid across the migration. While a claim is
held the matching built-in scanner is skipped, so the two never double-list; removing the plugin
releases the claim and the built-in comes straight back.
The identity chain is one string end to end — repo name → npm package → plugin id → provider id →
store claim → scanner id — which is why the plugin repos were renamed to match (`xbox`, not
`xbox-app`).
### The merge with #58 — please read this bit
#58 hardened the art proxy in the same three files this branch rewrote, in the opposite direction:
it *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, never beside it. Everything #58 added is kept unchanged.
The subtle one: `art_roots()` 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, an empty
root list is *not* "secure by default": it serves **no** plugin art on Linux, i.e. every cover the
lutris and steam plugins emit. It now includes `$HOME` — the exact analogue of the Windows users base
#58 already ships — and that is not the load-bearing control anyway: 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.
Also: `file://` is converted to a path **before** the confinement check, so the check and the read
see the same path — percent-decoding before canonicalization, so `%2e%2e` can't hide from the
traversal check. Pinned by a test.
### The second collision with #58: launcher tiles
D4 promised entries that open the *launcher* (Steam Big Picture, Heroic, Lutris) and said the
non-Steam ones would ride the `command` kind. #58 then made `launch.kind = "command"` operator-only,
so a plugin publishing one is refused. Both changes are individually right and jointly left a hole:
`steam_ui` was the only launcher kind a plugin could publish, so a Heroic or Lutris tile was
unreachable.
`6f07bd94` adds a typed `launcher_ui` kind valued by store id — one kind, because every launcher but
Steam has exactly one UI to open. D1 is preserved: the plugin names a launcher, the host builds the
command. Platform-gated and validated **inbound**, so an unsupported value is a 400 the author can
act on rather than a tile that does nothing when clicked. Windows launchers are deliberately absent
until each has a verified activation.
### Four bugs found on the way
- **`is_steam_launch` gated on a `steam://` URI** (`gamescope.rs`). A `steam_ui` launcher entry
carries no URI, so it would have skipped *both* gamescope's `--steam` mode and the B1
single-instance free — on a box autologged into game mode the nested Steam would see the first
instance and exit, crashing the spawn. Now tests the first token.
- **`prep_for` stripped a `custom:` prefix**, so a claimed entry would have silently lost its
per-title prep. Now resolved through the shared id mapping.
- **`steam_ui` was missing** from the host-resolved launch kinds named in `privileged_field`'s doc
comment and in the 403 a plugin sees. Prose only — the check is a denylist — but a plugin author
reading that error would have concluded their launcher entries were refused.
- **`GameForm` had no way to mark an entry as a launcher** (a WP4.3 item that was missed). Added,
and `formFrom` round-trips it — without which editing a launcher entry would silently demote it
to a game, which is exactly the bug that file's own comment warns about.
### What IS verified
CI green on `6f07bd94`: `ci / rust`, `ci / rust-arm64`, `ci / web`, `ci / docs-site`,
`windows / build` (x86_64 **and** aarch64), `apple / swift`, `android`.
On `.21`: `punktfunk-host` **435 passed / 0 failed** — including #58's H-2 confinement tests and the
new `file://` and `launcher_ui` ones — full workspace tests, workspace
`clippy --all-targets -- -D warnings`, `cargo fmt --all --check`, and the OpenAPI drift test.
plugin-kit: tsc clean, 56 tests. Console: orval + paraglide regen, tsc, `vite build`, `check-i18n`
at 604 messages for en + de.
### What is NOT verified — accept these knowingly
Everything below is code-reviewed and test-covered but has **not been exercised on real hardware**.
None of it is reachable from CI.
- **No browser click-through of the console.** The store's Tabs-theme bug once shipped through green
types and lint and was only visible rendered; the new sources surface, the schema-driven settings
drawer and the launcher checkbox deserve the same scepticism.
- **The Windows *host* has not been built or linted** (`windows-host.yml` did not run here — the
green Windows jobs are the client builds). `steam_ui` / `epic` / `gog` launch resolution is
Windows-only code whose unit tests only compile there.
- **No packaging was built or installed.** The `.iss` change needs a fresh install, an upgrade, and
an upgrade with `PunktfunkScripting` deliberately disabled — that last one is the case the
fresh-vs-upgrade probe exists for. deb/rpm/sysext need package builds.
- **The parity gate has never been *run*.** It exists and is unit-tested, but proving a plugin
reproduces its scanner needs a box with that launcher installed. No plugin has passed it yet,
including the two already published.
- **No launcher tile has been clicked on a real host**, and nothing emits one automatically yet —
the steam plugin is the first that will, and it is not built. What is newly possible today is
hand-adding a Heroic or Lutris tile from the console.
- `plugin-kit-v0.3.0` is tagged at `a1b8627e` on this branch, so the published package's source only
reaches `main` when this lands. Its documented launch-kind table predates `launcher_ui` and will
catch up in the next kit release.
### Not in scope
WP6.4 (deleting the six scanner modules) is the *removal* release, after all six plugins are pinned
and the bridge has had a quiet field window. WP5.6 index pins are per-plugin PRs to
`punktfunk-plugin-index`, which you merge. Four plugins remain: steam, gog, epic, and xbox (still
gated on the S1 LocalService probe).
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.
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.
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.
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.
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.
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).
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.
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.
enricobuehler
marked the pull request as ready for review 2026-08-05 18:31:03 +00:00
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Implements
design/library-scanner-plugins-implementation-plan.mdM0–M4 and M6. The six in-hostscanners can now be lifted out into plugins without anything downstream noticing. The scanners are
still here and still working — this is the bridge half; the removal release (WP6.4) is separate.
Two plugins are already built against this and shipping green:
lutris ·
heroic.
@punktfunk/plugin-kit@0.3.0is published.mainis merged in and fully contained — conflict-free, CI green on every job.Commits
a418d285launch.rs; art proxy resolves any id3d4a65998728d90ebd383f18ce8f3e9e91fa32fba1b8627ed20858796f07bd94launcher_ui)The load-bearing idea
Store claims (D2).
PUT /library/provider/{p}?store=steamlets a plugin claim a store, so itsentries get the deterministic
<store>:<external_id>ids the built-in scanner produced — not opaquecustom:<hex>ones. That is what keeps GameStream FNV app ids, Moonlight pins, client art caches andthe operator's
library-scanners.jsondisabled state valid across the migration. While a claim isheld the matching built-in scanner is skipped, so the two never double-list; removing the plugin
releases the claim and the built-in comes straight back.
The identity chain is one string end to end — repo name → npm package → plugin id → provider id →
store claim → scanner id — which is why the plugin repos were renamed to match (
xbox, notxbox-app).The merge with #58 — please read this bit
#58 hardened the art proxy in the same three files this branch rewrote, in the opposite direction:
it 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, never beside it. Everything #58 added is kept unchanged.
The subtle one:
art_roots()was empty on non-Windows, which was correct whileis_local_art_pathwas 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, an empty
root list is not "secure by default": it serves no plugin art on Linux, i.e. every cover the
lutris and steam plugins emit. It now includes
$HOME— the exact analogue of the Windows users base#58 already ships — and that is not the load-bearing control anyway: 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.
Also:
file://is converted to a path before the confinement check, so the check and the readsee the same path — percent-decoding before canonicalization, so
%2e%2ecan't hide from thetraversal check. Pinned by a test.
The second collision with #58: launcher tiles
D4 promised entries that open the launcher (Steam Big Picture, Heroic, Lutris) and said the
non-Steam ones would ride the
commandkind. #58 then madelaunch.kind = "command"operator-only,so a plugin publishing one is refused. Both changes are individually right and jointly left a hole:
steam_uiwas the only launcher kind a plugin could publish, so a Heroic or Lutris tile wasunreachable.
6f07bd94adds a typedlauncher_uikind valued by store id — one kind, because every launcher butSteam has exactly one UI to open. D1 is preserved: the plugin names a launcher, the host builds the
command. Platform-gated and validated inbound, so an unsupported value is a 400 the author can
act on rather than a tile that does nothing when clicked. Windows launchers are deliberately absent
until each has a verified activation.
Four bugs found on the way
is_steam_launchgated on asteam://URI (gamescope.rs). Asteam_uilauncher entrycarries no URI, so it would have skipped both gamescope's
--steammode and the B1single-instance free — on a box autologged into game mode the nested Steam would see the first
instance and exit, crashing the spawn. Now tests the first token.
prep_forstripped acustom:prefix, so a claimed entry would have silently lost itsper-title prep. Now resolved through the shared id mapping.
steam_uiwas missing from the host-resolved launch kinds named inprivileged_field's doccomment and in the 403 a plugin sees. Prose only — the check is a denylist — but a plugin author
reading that error would have concluded their launcher entries were refused.
GameFormhad no way to mark an entry as a launcher (a WP4.3 item that was missed). Added,and
formFromround-trips it — without which editing a launcher entry would silently demote itto a game, which is exactly the bug that file's own comment warns about.
What IS verified
CI green on
6f07bd94:ci / rust,ci / rust-arm64,ci / web,ci / docs-site,windows / build(x86_64 and aarch64),apple / swift,android.On
.21:punktfunk-host435 passed / 0 failed — including #58's H-2 confinement tests and thenew
file://andlauncher_uiones — full workspace tests, workspaceclippy --all-targets -- -D warnings,cargo fmt --all --check, and the OpenAPI drift test.plugin-kit: tsc clean, 56 tests. Console: orval + paraglide regen, tsc,
vite build,check-i18nat 604 messages for en + de.
What is NOT verified — accept these knowingly
Everything below is code-reviewed and test-covered but has not been exercised on real hardware.
None of it is reachable from CI.
types and lint and was only visible rendered; the new sources surface, the schema-driven settings
drawer and the launcher checkbox deserve the same scepticism.
windows-host.ymldid not run here — thegreen Windows jobs are the client builds).
steam_ui/epic/goglaunch resolution isWindows-only code whose unit tests only compile there.
.isschange needs a fresh install, an upgrade, andan upgrade with
PunktfunkScriptingdeliberately disabled — that last one is the case thefresh-vs-upgrade probe exists for. deb/rpm/sysext need package builds.
reproduces its scanner needs a box with that launcher installed. No plugin has passed it yet,
including the two already published.
the steam plugin is the first that will, and it is not built. What is newly possible today is
hand-adding a Heroic or Lutris tile from the console.
plugin-kit-v0.3.0is tagged ata1b8627eon this branch, so the published package's source onlyreaches
mainwhen this lands. Its documented launch-kind table predateslauncher_uiand willcatch up in the next kit release.
Not in scope
WP6.4 (deleting the six scanner modules) is the removal release, after all six plugins are pinned
and the bridge has had a quiet field window. WP5.6 index pins are per-plugin PRs to
punktfunk-plugin-index, which you merge. Four plugins remain: steam, gog, epic, and xbox (stillgated on the S1 LocalService probe).
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.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.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).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.