// 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)] : []; }, });