feat(plugin-kit): the lutris pilot as a worked example, and the export gap it found
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.
This commit is contained in:
@@ -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,
|
||||
);
|
||||
|
||||
/**
|
||||
* `<kind>/<slug>.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<GameRow>(
|
||||
"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<GameRow>(
|
||||
"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:<external_id>` — 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)] : [];
|
||||
},
|
||||
});
|
||||
@@ -38,6 +38,7 @@ export {
|
||||
type RegValue,
|
||||
validRegKey,
|
||||
} from "./registry.js";
|
||||
export { openReadOnly, type ReadOnlyDb, withReadOnlyDb } from "./sqlite.js";
|
||||
export {
|
||||
crc32,
|
||||
parseShortcuts,
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user