From a1b8627e70335c28ce3174e76a1cb8f80231cda4 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 5 Aug 2026 18:54:47 +0200 Subject: [PATCH] feat(plugin-kit): the lutris pilot as a worked example, and the export gap it found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- plugin-kit/examples/lutris-plugin.ts | 168 ++++++++++++++++++++++++ plugin-kit/src/library/parsers/index.ts | 1 + plugin-kit/tsconfig.json | 5 +- 3 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 plugin-kit/examples/lutris-plugin.ts diff --git a/plugin-kit/examples/lutris-plugin.ts b/plugin-kit/examples/lutris-plugin.ts new file mode 100644 index 00000000..1752923b --- /dev/null +++ b/plugin-kit/examples/lutris-plugin.ts @@ -0,0 +1,168 @@ +// A COMPLETE library-scanner plugin, and the template the six first-party ones are cut from. +// +// This is the lutris pilot (design M5/WP5.1) — the smallest of the six, and the one that exercises +// the POSIX local-art path end to end. It lives here as a worked example rather than shipped code: +// each scanner gets its OWN repo (the house pattern), and this is what you copy into a fresh one. +// `package.json`'s `files` is dist + README, so nothing here is published. +// +// The point it proves: everything below the `scan` function is store-specific parsing, and +// everything else — the store claim, the sync engine, launcher entries, `__config`, the console +// registration, the CLI verbs including the parity gate — comes from `defineLibraryPlugin`. That is +// what makes six repos cost nothing in duplication. +// +// Ported from crates/punktfunk-host/src/library/lutris.rs, with two deliberate changes: +// * art is emitted as `file://` URLs instead of inlined `data:` URLs. The host proxies the bytes, +// so the reconcile payload stays tiny — inlining covers is what blew the host's 2 MB body limit +// at 49 titles during the playnite work, and it is exactly why the POSIX art path exists (G4). +// * the `installed = 1` filter and the untrusted-slug guard are carried over verbatim. The slug +// comes from Lutris's own database and is interpolated into a path, so the guard is load-bearing. +import * as os from "node:os"; +import * as path from "node:path"; +import { Effect, Schema } from "effect"; +import { + defineLibraryPlugin, + fileUrl, + isFile, + withReadOnlyDb, +} from "../src/library/index.js"; +import type { ProviderEntry } from "../src/wire.js"; + +const LutrisConfig = Schema.Struct({ + /** + * Where `pga.db` lives, when it isn't in one of the standard places. Annotated because the + * console's generic settings form derives its label and help text from exactly these. + */ + databasePath: Schema.optionalKey( + Schema.String.annotate({ + title: "Lutris database", + description: + "Absolute path to pga.db. Leave empty to find it automatically.", + }), + ), +}); + +/** Candidate `pga.db` locations: XDG data dir, the classic path, Flatpak. */ +const databaseCandidates = (): string[] => { + const out: string[] = []; + const xdg = process.env.XDG_DATA_HOME; + if (xdg) out.push(path.join(xdg, "lutris/pga.db")); + const home = os.homedir(); + if (home) { + out.push(path.join(home, ".local/share/lutris/pga.db")); + out.push(path.join(home, ".var/app/net.lutris.Lutris/data/lutris/pga.db")); + } + return out; +}; + +const findDatabase = (cfg: { databasePath?: string }): string | undefined => + [...(cfg.databasePath ? [cfg.databasePath] : []), ...databaseCandidates()].find( + isFile, + ); + +/** + * `/.jpg` across the current, legacy-cache and Flatpak Lutris roots. + * + * The slug comes verbatim from Lutris's database and is interpolated into a path, so a separator, + * parent ref or NUL is refused — otherwise a crafted slug is an arbitrary-file-read primitive, and + * the resulting path would be handed to the host's art proxy to serve (security-review 2026-07-17). + * Real Lutris slugs are `[a-z0-9-]`. + */ +const artFile = (kind: string, slug: string): string | undefined => { + if ( + slug === "" || + slug.includes("/") || + slug.includes("\\") || + slug.includes("..") || + slug.includes("\0") + ) { + return undefined; + } + const home = os.homedir(); + if (!home) return undefined; + const roots = [ + path.join(home, ".local/share/lutris"), + path.join(home, ".cache/lutris"), + path.join(home, ".var/app/net.lutris.Lutris/data/lutris"), + path.join(home, ".var/app/net.lutris.Lutris/cache/lutris"), + ]; + for (const root of roots) { + const p = path.join(root, kind, `${slug}.jpg`); + if (isFile(p)) return p; + } + return undefined; +}; + +interface GameRow { + id: number; + slug: string | null; + name: string; + directory: string | null; +} + +export default defineLibraryPlugin({ + // One string: plugin id, provider id, store claim, and the id of the built-in scanner this + // replaces. That identity chain is what keeps entry ids, GameStream app ids and the operator's + // existing enable/disable state intact across the migration. + name: "lutris", + configSchema: LutrisConfig, + + detect: (cfg) => Effect.sync(() => findDatabase(cfg) !== undefined), + + scan: (cfg) => + Effect.sync(() => { + const db = findDatabase(cfg); + if (!db) return []; + // Read-only + immutable: a running Lutris holding the file can neither block us nor be + // disturbed by us. + const rows = + withReadOnlyDb(db, (h) => + // `directory` is our only detect signal but is not load-bearing for the library, so + // a schema without it must not cost the whole source — the helper answers [] on a + // bad query, and the fallback keeps the titles. + h.query( + "SELECT id, slug, name, directory FROM games " + + "WHERE installed = 1 AND name IS NOT NULL AND name <> '' " + + "ORDER BY name COLLATE NOCASE", + ), + ) ?? []; + const usable = + rows.length > 0 + ? rows + : (withReadOnlyDb(db, (h) => + h.query( + "SELECT id, slug, name, NULL AS directory FROM games " + + "WHERE installed = 1 AND name IS NOT NULL AND name <> '' " + + "ORDER BY name COLLATE NOCASE", + ), + ) ?? []); + + return usable.map((row): ProviderEntry => { + const portrait = row.slug ? artFile("coverart", row.slug) : undefined; + const header = row.slug ? artFile("banners", row.slug) : undefined; + const dir = row.directory?.trim(); + return { + // The host composes `lutris:` — byte-identical to what the built-in + // scanner produced, which the parity gate checks. + external_id: String(row.id), + title: row.name, + launch: { kind: "lutris_id", value: String(row.id) }, + art: { + ...(portrait ? { portrait: fileUrl(portrait) } : {}), + ...(header ? { header: fileUrl(header) } : {}), + }, + // Lutris stamps no per-game env marker worth relying on, so the install dir is the + // whole recipe; a game with none (an emulator entry pointing at a bare ROM) stays + // untracked, exactly as it did in-host. + ...(dir ? { detect: { install_dir: dir } } : {}), + platform: "PC", + }; + }); + }), + + // Re-scan when Lutris writes: installing a game touches the database, and downloading art + // touches the cover directories. + watchDirs: (cfg) => { + const db = findDatabase(cfg); + return db ? [path.dirname(db)] : []; + }, +}); diff --git a/plugin-kit/src/library/parsers/index.ts b/plugin-kit/src/library/parsers/index.ts index 56d5f834..697f2f12 100644 --- a/plugin-kit/src/library/parsers/index.ts +++ b/plugin-kit/src/library/parsers/index.ts @@ -38,6 +38,7 @@ export { type RegValue, validRegKey, } from "./registry.js"; +export { openReadOnly, type ReadOnlyDb, withReadOnlyDb } from "./sqlite.js"; export { crc32, parseShortcuts, diff --git a/plugin-kit/tsconfig.json b/plugin-kit/tsconfig.json index d7ae4c75..9187fe82 100644 --- a/plugin-kit/tsconfig.json +++ b/plugin-kit/tsconfig.json @@ -10,5 +10,8 @@ "noEmit": true, "types": ["bun"] }, - "include": ["src", "test"] + // `examples` is type-checked but never built: tsconfig.build.json narrows to `src`, and + // package.json ships only `dist` + README. A worked example that doesn't compile is worse than + // no example, and these are what the first-party scanner repos are cut from. + "include": ["src", "test", "examples"] }