// The Lutris library source: your installed Lutris games, in the host's library. // // Everything below `scan` is Lutris-specific parsing. Everything else — claiming the `lutris` store // so these entries keep the ids the host's own scanner used to give them, the sync engine, the // `__config` surface the console renders settings from, the console registration, and the // `detect` / `scan` / `parity` / `uninstall` CLI verbs — comes from `defineLibraryPlugin`. // // Ported from the host's in-tree scanner (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. // * 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. // // `punktfunk-plugin-lutris parity --snapshot before.json` on a box that still has the built-in // scanner, then `--compare before.json`, is what proves the port is faithful. import * as os from "node:os"; import * as path from "node:path"; import { defineLibraryPlugin, fileUrl, isFile, withReadOnlyDb, } from "@punktfunk/plugin-kit/library"; import type { ProviderEntry } from "@punktfunk/plugin-kit/wire"; import { Effect, Schema } from "effect"; 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.", }), ), /** * Publish a tile that opens the Lutris window itself (design D4) alongside the games. On by * default: it is the one entry that lets someone install or fix a game from the couch, and it * costs one tile. */ launcher: Schema.Boolean.annotate({ title: "Show a Lutris tile", description: "Add a tile that opens Lutris itself, so you can manage games from a client.", }).pipe( Schema.withDecodingDefaultKey(Effect.succeed(true), { encodingStrategy: "omit", }), ), }); /** 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-]`. */ export 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; } /** * The tile that opens Lutris itself (design D4). * * `launcher_ui` is valued by *store id*, never a command: the host owns turning "lutris" into * the command that opens the window (D1), which is also why a plugin may publish this at all — * the 2026-08-05 review made `launch.kind = "command"` operator-only. * * No art on purpose. Lutris ships a square app icon, and every client cover-crops a 2:3 tile, * so an icon would arrive as a cropped strip. The clients render an art-less launcher entry * deliberately — accent face, the launcher named — which reads as "opens Lutris" rather than as * a game whose poster failed. * * A standalone exported function so it is testable without standing up the whole plugin — * the same reason `artFile` is exported. */ export const launcherEntries = (cfg: { launcher?: boolean; }): ProviderEntry[] => cfg.launcher === false ? [] : [ { external_id: "launcher", title: "Lutris", role: "launcher", launch: { kind: "launcher_ui", value: "lutris" }, platform: "PC", }, ]; export const plugin = defineLibraryPlugin({ // One string: plugin id, provider id, store claim, and the id of the built-in scanner this // replaces. It matches the package name and the repo name too, so there is no mapping to // remember — and 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", }; }); }), launchers: launcherEntries, // 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)] : []; }, });