Library scanners become plugins — the bridge half (host, wire, kit, console, packaging) #59

Merged
enricobuehler merged 9 commits from worktree-library-plugins into main 2026-08-05 19:58:46 +00:00
Owner

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).

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).

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).
enricobuehler added 7 commits 2026-08-05 17:45:40 +00:00
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).
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
a1b8627e70
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.
enricobuehler added 1 commit 2026-08-05 17:59:19 +00:00
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
d2085879da
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
enricobuehler added 1 commit 2026-08-05 19:12:28 +00:00
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
6f07bd94d3
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.
enricobuehler merged commit 66df1624b6 into main 2026-08-05 19:58:46 +00:00
enricobuehler deleted branch worktree-library-plugins 2026-08-05 19:58:49 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#59