Library scanners become plugins — the bridge half (host, wire, kit, console, packaging) #59
Merged
enricobuehler
merged 9 commits from 2026-08-05 19:58:46 +00:00
worktree-library-plugins into main
9
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6f07bd94d3 |
feat(library): launcher tiles a plugin can actually publish
ci / docs-site (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 2m28s
android / android (pull_request) Successful in 4m10s
ci / rust (pull_request) Successful in 6m11s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m56s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m35s
Design D4 promised entries that open the LAUNCHER — Steam Big Picture, Heroic,
Lutris — and the plumbing for it landed in M2/M4: the `role` field, the
`steam_ui` kind, the console's Launchers rail. But nothing could flow through it
for anything except Steam.
D4 said the other launchers would ride the `command` kind. The 2026-08-05 review
then made `launch.kind = "command"` operator-only (it is handed to a shell), so a
plugin publishing one is refused with a 403. The two changes are individually
right and jointly leave a hole: `steam_ui` was the only launcher kind a plugin
could publish, so a Heroic or Lutris tile was unreachable.
New `launcher_ui` kind, valued by store id. One kind rather than one per store
because every launcher except Steam has exactly a single UI to open; Steam keeps
its own kind because it genuinely has two. D1 is preserved — the plugin names a
launcher, the host builds the command, and no shell string crosses the wire:
heroic -> the same native-or-Flatpak resolution the `heroic` game kind uses,
minus --no-gui and minus the URI, so the window itself opens
lutris -> bare `lutris`, which opens the window (the URI form is `lutris_id`)
Platform-gated to what this host can actually resolve, and validated INBOUND: a
value naming a launcher this OS cannot open is a 400 the plugin author can act
on, not a tile that silently does nothing when a user clicks it. Windows
launchers (Epic, GOG Galaxy, Xbox app) are deliberately absent — each needs its
own verified activation and a guess would ship exactly that dead tile.
Also closes a WP4.3 item I under-delivered and did not flag: the console's
add/edit form had no way to mark an entry as a launcher, so even hand-adding one
was impossible. It now has the checkbox — and `formFrom` round-trips it, without
which editing a launcher entry would silently demote it to a game, which is the
precise bug that file's own comment warns about.
Gates on .21: punktfunk-host 435 passed / 0 failed (two new), workspace clippy
-D warnings clean, cargo fmt --all --check clean, OpenAPI drift green. Console:
orval + paraglide regen, tsc clean, check-i18n at 604 messages for en + de.
Still unproven on hardware: no launcher tile has been clicked on a real host.
The steam plugin (the first to emit one) is not built yet.
|
||
|
|
d2085879da |
Merge main: plugin art rides THROUGH the H-2 confinement, not around it
ci / web (pull_request) Successful in 58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m1s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m0s
ci / rust-arm64 (pull_request) Successful in 3m45s
ci / rust (pull_request) Successful in 9m22s
PR #58 hardened the art proxy in the same three files this branch rewrote, and the two changes pull in opposite directions: #58 narrowed what the host will read from disk, while WP1.2 widened what counts as a local art path so an extracted scanner's covers can be served at all. Resolved so the widening goes through the gate rather than beside it. Kept from #58, unchanged: art_path_is_confined (UNC refusal, canonicalize-or- refuse, config-dir exclusion, roots check), the image-extension whitelist, sniff_image_type, validate_art_paths as write-time validation, the AuthLane privileged-field check on every entry in a reconcile payload, and the launch redaction in GET /library. Three reconciliations: * `local_art_bytes` converts a `file://` value to a path BEFORE calling art_path_is_servable, so the confinement check and the read see the same path. Ordering is the point: percent-decoding happens before canonicalization, so a `%2e%2e` escape cannot hide from the traversal check. Pinned by a test. * `art_roots()` gains $HOME on POSIX. This is the one that would have bitten silently: the list was empty on non-Windows, which was correct while is_local_art_path was Windows-shaped (Playnite is Windows-only, so nothing on a POSIX host was ever classified as local art and the confinement had nothing to confine). Once WP1.2 classifies POSIX paths as local, an empty root list is not "secure by default" — it serves NO plugin art on Linux, which is every cover the lutris and steam plugins emit. $HOME is the exact analogue of the Windows users base #58 already ships, and covers Steam's librarycache and grid overrides, Lutris's coverart/banners (both copies), Heroic's caches and all the Flatpak variants. It is not the load-bearing control: a value still needs an image extension, must canonicalize to a real regular file inside a root and outside the config dir, and must CONTAIN image bytes. * The two tests that both wanted to mutate PUNKTFUNK_LIBRARY_ART_ROOTS became one. Cargo runs tests as parallel threads of a single process, so two tests setting the same env var race. The `file://` and confinement assertions moved into #58's existing confined test; what remains of the WP1.2 test is the pure classification/rewrite half, which touches neither env nor filesystem. Also: `steam_ui` was missing from the list of host-resolved launch kinds in privileged_field's doc comment and in the 403 a plugin sees. Prose only — the check is a denylist (prep, launch.kind = "command"), so steam_ui was never actually refused — but a plugin author reading that error would have concluded otherwise. Gates on .21: punktfunk-host 433 passed / 0 failed (including #58's H-2 tests and the new file:// ones), full workspace tests clean, workspace clippy -D warnings clean, cargo fmt --all --check clean, OpenAPI drift test green. |
||
|
|
a1b8627e70 |
feat(plugin-kit): the lutris pilot as a worked example, and the export gap it found
plugin-kit-publish / publish (push) Successful in 29s
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m28s
ci / rust-arm64 (pull_request) Successful in 2m50s
android / android (pull_request) Successful in 4m28s
ci / docs-site (pull_request) Successful in 1m23s
ci / rust (pull_request) Successful in 7m11s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 2m58s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 3m52s
Writing a real scanner against the kit before six repos get cut from it, rather than after. It is the lutris pilot (M5/WP5.1) — the smallest of the six and the one that exercises the POSIX local-art path end to end. It earned its keep immediately: withReadOnlyDb / openReadOnly were never exported from the parsers barrel, so the single most distinctive thing the lutris plugin needs was unreachable from @punktfunk/plugin-kit/library. Nothing caught that, because nothing had consumed the public surface yet. It also caught a vacuous green in this package: tsconfig's include was ["src","test"], so anything under examples/ type-checked as a no-op. `examples` is now in the check scope; tsconfig.build.json still narrows to src and package.json still ships only dist + README, so nothing new is published (verified against the built dist). The example carries two deliberate departures from the Rust original, both documented inline: art is emitted as file:// URLs instead of inlined data: URLs (the host proxies the bytes, so the payload stays small — inlining covers is what blew the 2 MB body limit at 49 titles during the playnite work, and is exactly why the POSIX art path exists), and the untrusted-slug guard is carried over verbatim, since the slug comes from Lutris's own database and is interpolated into a path the host will later be asked to serve. What it demonstrates, which is the reason one-repo-per-plugin is safe: everything below `scan` is store-specific parsing, and everything else — store claim, sync engine, launcher entries, __config, console registration, and the CLI verbs including the parity gate — comes from defineLibraryPlugin. plugin-kit: tsc clean (now including examples), 56 tests pass, build clean. |
||
|
|
91fa32fbb6 |
feat(plugin-kit): the parity gate moves into the kit, so plugins can be one repo each
One plugin = one repo, matching the house pattern (playnite, rom-manager and
virtualhere are already each their own repo with their own biome/bunfig/tsconfig
/CI). The implementation plan's WP5.0 had proposed a single workspace repo for
all six library scanners; this is the piece that makes the split cost nothing.
Everything the six scanners share is already published rather than adjacent: the
parsers and defineLibraryPlugin live in @punktfunk/plugin-kit/library, so repo
boundaries are irrelevant to them. Fixtures are not shared in practice either —
the Rust scanners build theirs inline in code, there are no fixture files, and
the one genuinely cross-plugin builder (binary shortcuts.vdf) is already in this
package's own tests. A pga.db fixture is useless to the epic plugin.
The parity harness was the exception: generic across all six, and parked in the
shared repo the plan assumed. It moves here.
What it is: the acceptance gate for an extracted scanner. Ported unit tests pin
the PARSERS; they do not prove the plugin reproduces the scanner it replaces. A
plugin that parses perfectly and emits steam:440.0 instead of steam:440 breaks
every Moonlight pin on the host and no parser test notices.
punktfunk-plugin-steam parity --snapshot before.json # host on its built-in
punktfunk-plugin-steam parity --compare before.json # offline; exits non-zero
--compare runs the plugin's own scan rather than requiring it to be installed
first, so a mismatch is visible before anything is published and the run is
repeatable while you fix it.
Three judgement calls in the diff, each pinned by a test:
* art is compared by PRESENCE, not value. The representation legitimately
changes on extraction (a host-relative proxy path or inlined data: URL
becomes a file:// path or a CDN URL), so comparing values would fail every
run for no reason. Losing an art kind fails; gaining one does not.
* launcher entries (role: "launcher") are reported separately instead of as
unexpected extras — the built-in scanner had no concept of them, so they can
never be in a baseline. An ORDINARY title the scanner never had still fails,
which is what catches a bad tool filter.
* absent and empty are the same thing in metadata: the host omits empty lists
and nulls, so a plugin sending genres: [] has not changed anything.
plugin-kit: tsc clean, 56 tests pass (10 new).
|
||
|
|
ce8f3e9eaf |
feat(packaging): the plugin runner becomes a default component
WP6.1 of design/library-scanner-plugins-implementation-plan.md. The library is a flagship surface and cannot depend on an opt-in subsystem (design D9, closing G9): once the scanners are plugins, a host whose runner is off comes up with an empty library and no obvious reason why. The security posture for on-by-default was already built and shipped — LocalService on Windows, a sandboxed systemd --user unit on Linux, the scoped plugin-token lane. Windows (.iss): the PunktfunkScripting task is registered ENABLED and started on a FRESH install, and left to the existing restore path on an upgrade. The distinction is a new TaskExists probe taken before StopBunRuntimes disables anything — TaskEnabled alone cannot tell a fresh install from an operator who deliberately turned the runner off, and defaulting to "on" would silently switch it back on for them. deb/rpm: `systemctl --global enable` from the postinst/%post, guarded to first install only so an upgrade never undoes a mask. `--global` because a maintainer script has no user session to act on, and it is the only mechanism that makes a --user unit on-by-default for everyone. sysext: RPM scriptlets never run from a sysext image, so the enablement symlink is baked in directly (/usr/lib/systemd/user/default.target.wants/). Without it the runner would ship present-but-off on exactly the platform where an operator is least likely to go looking for it. Opt-out throughout is `systemctl --user mask punktfunk-scripting` — `mask`, not `disable`, since a plain disable cannot remove a symlink under /etc or /usr. The unit comment, both package descriptions, and the docs-site plugins page all say so; the page also gains the Windows equivalent. Not gated on hardware: none of this is verifiable from a Mac. The .iss change needs an installer run (fresh + upgrade, and an upgrade with the task deliberately disabled), and the deb/rpm/sysext changes need a package build. |
||
|
|
bd383f1820 |
feat(web): one Game sources surface, launcher rail, and the migration nudge
M4 of design/library-scanner-plugins-implementation-plan.md, plus WP6.2. WP4.1 — SourceToggles and ProvidersCard merge into Library/Sources.tsx. They were two cards because they were two different things: scanners were compiled into the host, plugins were an afterthought. After the extraction they are the same thing — the host reports ONE list of sources whose ids match whether they came from a built-in scanner or the plugin replacing it — so one surface is both simpler and the only honest presentation. Each row carries its toggle, a running/stopped badge for plugin sources, an entry count, filter, settings and an uninstall that offers to remove the games too. An "Add a source" rail lists uncatalogued library plugins with a "Detected" badge; `detected` is deliberately tri-state, so only a POSITIVE probe badges — an entry with no probes for this platform is unknown, and calling that "not installed" would be a lie. The settings drawer (SourceSettings.tsx) renders a generic form from the plugin's own JSON Schema over GET/PUT /__config, through the existing session-gated /plugin-ui/<id>/ proxy — zero new host surface, and the browser never learns the plugin's port or secret. It flattens allOf branches (effect nests a checked schema's annotations there, so a form reading only the top level silently loses every title and default) and falls back to a JSON editor when any field is a shape it cannot express — partial rendering would be worse than none, because a field missing from the form is a setting the operator cannot change. WP4.2 — uiPlugins() now excludes category "library", which covers both the sidebar and the mobile overflow since they share the selector. The /plugins/$pluginId/$ route still resolves, so existing deep links keep working; library plugins are just not advertised. WP4.3 — LibraryGrid groups role:"launcher" entries into a rail above the grid, and the empty state points at the sources surface rather than leaving a bare grid (after extraction, "no games" is the expected first-run state). WP6.2 — a migration banner offering one install per still-built-in scanner whose plugin is catalogued. One button per scanner, never a single "migrate everything" and never a silent auto-install: installing code stays an explicit operator act, and per-scanner is what makes it safe to repeat (the claim suppresses the built-in idempotently, so a half-finished migration is a valid state). WP4.4 — i18n en+de (kept under the existing "Game sources" label rather than minting a third "Plugins"), Storybook stories for the sources card in three states, the launcher rail and the banner. Gates: orval regen, tsc clean, vite build clean, check-i18n green at 595 messages for both locales. Still owed: the browser click-through (the store's Tabs-theme bug shipped through green types and lint), and an AppShell nav story — that one needs the plugins query mocked, which does not exist in this Storybook setup yet. |
||
|
|
8728d90e01 |
feat(plugin-kit): the library-plugin framework — parsers, __config, defineLibraryPlugin
M3 of design/library-scanner-plugins-implementation-plan.md. Target shape: a
first-party scanner plugin is its parsers plus a scan function.
WP3.1 — a parsers module under the new ./library subpath, porting what the six
in-host scanners hand-rolled: text VDF/ACF, the BINARY shortcuts.vdf KeyValues
walker with its CRC-32 appid derivation and the 64-bit rungameid composition,
read-only SQLite (bun:sqlite, immutable=1 so a scan can never take a lock or
spawn WAL sidecars next to a launcher's live database), a reg.exe wrapper,
capped readers, the path-confinement join that keeps a crafted goggame-*.info
from pointing a launch at an arbitrary program, Steam root/library discovery,
art location helpers, and a fetch helper carrying the host's no-redirect
anti-SSRF posture. Every parser is total: a missing launcher or a truncated file
degrades to "no titles", never to a throw.
Two deliberate departures from the Rust originals, both about the Windows
runner's account: steam root discovery now also reads HKLM Valve\Steam
InstallPath (a non-default install dir was previously uncovered), and the
registry wrapper refuses HKCU outright — as LocalService that is not the
operator's hive, so reading it would silently look like "not installed".
WP3.2 — GET/PUT /__config on the kit's UI server, so a plugin with settings does
not ship an SPA (closes G8). GET answers {schema, value}: the derived JSON Schema
and the raw operator-authored config. PUT validates by decoding and only then
persists RAW, so defaults are never baked into the file. The handler is split out
as makeConfigHandler and driven directly in tests.
WP3.3 — defineLibraryPlugin wires SyncEngine (poll + fs-watch + debounce), the
store-claiming reconcile, launcher entries appended to every sync, a UI server
serving only __config under category "library" (which keeps six installed
scanners out of the console nav), and the standard detect/scan/uninstall CLI
verbs. It warns ONCE when a pre-M2 host silently ignores the store claim — that
degradation is otherwise invisible except as duplicated titles.
M0/S2 is recorded here as a committed fixture rather than prose. Two findings the
original spike missed because deriving a schema does not exercise it:
withDecodingDefaultKey takes an Effect, not a thunk — a thunk type-checks, derives
fine, and dies at decode time; and a checked schema (Schema.Int) nests its
annotations under allOf, so a form must merge those branches. Both are pinned.
plugin-kit: version 0.3.0, tsc clean, 46 tests pass (16 ported parser tests, 10
config/derivation). Publishing (WP3.4) is deferred — it needs a tag and a push.
|
||
|
|
3d4a659959 |
feat(host,sdk,kit): store claims, launcher entries, and plugin sources on the wire
M2 of design/library-scanner-plugins-implementation-plan.md. Everything a
library scanner plugin needs is now expressible over the API; all additive.
WP2.1/2.2 — store claims (D2). library.json gains a v2 shape ({entries, claims})
that loads the v1 bare array unchanged and is written on the first mutation.
PUT /library/provider/{p}?store=<s> claims a store for a provider: its entries
then surface with deterministic <store>:<external_id> ids and the store's own
badge instead of opaque custom:<id> ones. That identity is the whole point —
entry ids, GameStream FNV app ids, client art caches and Moonlight pins all
survive a title moving from an in-host scanner to a plugin. One provider per
store (409 otherwise); DELETE releases; an empty reconcile does NOT (a store can
legitimately have zero titles). While a claim is held, all_games() skips the
matching built-in scanner, so the two never double-list during the bridge.
WP2.3 — DetectHint gains steam_appid and env_marker, the two store-derived
signals the host used to read for itself. Without them a steam plugin's lease
tracking would drop from reaper-exact to dir-prefix, and Heroic-under-Proton
would lose the only signal that works. Malformed markers are dropped, not
honoured — this feeds a path that can end processes.
WP2.4/2.5 — role: game|launcher on the entry shapes (serde-default, skipped when
default), and a steam_ui launch kind valued bigpicture|desktop that opens the
Steam client itself. Validated inbound as well as at launch.
WP2.6 — GET/PUT /library/scanners generalizes to SOURCES: built-in scanners
minus claimed ones, plus claimed stores, plus any provider with entries. The
same library-scanners.json disabled-set backs all of them and the ids match by
construction, so a user's disabled state carries over verbatim through the whole
migration. A disabled plugin source has its entries filtered at read time,
exactly like a disabled scanner.
WP2.7/2.8 — plugin registration gains a category field (the console keeps
library plugins out of the nav); index entries gain categories and per-platform
detect probes, evaluated existence-only into CatalogEntry.detected so the host
never re-grows per-store knowledge. Index SCHEMA stays 1 — additive.
WP2.9 — OpenAPI + SDK regenerated on Linux; kit wire widened (LaunchSpec.kind is
now a plain string documented against the host's vocabulary — closes G3), and
ProviderClient.reconcile takes an optional store and returns the host's echoed
entries so a caller can detect a pre-M2 host silently ignoring the claim.
Also fixes a bug the S3 spike turned up: is_steam_launch gated on a steam:// URI,
so a steam_ui launcher entry would have skipped BOTH gamescope's --steam mode and
the B1 single-instance free — on a box autologged into game mode, the nested
second Steam would see the first and exit, crashing the spawn. It now tests the
first token.
Gates on .21: workspace tests green (punktfunk-host 425 passed), workspace
clippy -D warnings clean, cargo fmt --all --check clean, OpenAPI drift test
green. plugin-kit: tsc clean, 20 tests pass.
|
||
|
|
a418d2852a |
refactor(host/library): launch helpers into launch.rs, art proxy resolves any id
M1 of design/library-scanner-plugins-implementation-plan.md — behavior-frozen groundwork for lifting the six scanners out into plugins. WP1.1: heroic_command/heroic_launch_prefix, epic_launch_uri, gog_spawn, valid_steam_appid and shortcut_gameid move into library/launch.rs with their unit tests. The scanner modules beside it now do enumeration only, so they can be deleted wholesale later without taking launch logic with them (D1). WP1.2: is_local_art_path accepts file:// (the plugin contract) and POSIX absolute paths, excluding the two /-leading shapes the host itself emits (its own /api/ proxy path and protocol-relative CDN URLs). local_art_bytes percent-decodes and converts a file:// value first. The art proxy and fetch_box_art resolve ANY id against library.json before the legacy steam: branch, so a plugin's entries serve art without the host knowing its store. No API change; no user-visible change. |