Files
punktfunk-plugin-lutris/src/plugin.ts
T
enricobuehler bedf43214c
CI / build (pull_request) Successful in 17s
CI / publish (pull_request) Skipped
feat: a tile that opens Lutris itself
`defineLibraryPlugin` has taken a `launchers()` hook since kit 0.3.0 — entries that
open the LAUNCHER rather than a title (design D4) — and nothing implemented it. The
host has resolved `launcher_ui` valued `"lutris"` since the same release. So the
whole path existed end to end with no producer at either end.

One entry, config-toggled and on by default: it is the one tile that lets someone
install or fix a game from the couch, and it costs a single card.

`launcher_ui` is valued by STORE ID, never a command: the host turns "lutris" into
the command that opens the window (D1). That is also the only shape available to a
plugin at all — the 2026-08-05 review made `launch.kind = "command"` operator-only,
so a plugin publishing one has its entire reconcile refused.

Deliberately art-less. 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 on purpose — accent face, the launcher named — which reads as "opens
Lutris" rather than as a game whose poster failed to load.

`launcherEntries` is exported for the same reason `artFile` is: so the shape is
testable without standing up the whole plugin.

Gates: tsc --noEmit clean, 5 tests pass (2 new), biome clean.
Needs a host carrying the M2 wire (`role` + `launcher_ui`), which is on main.
2026-08-06 15:17:12 +02:00

216 lines
7.9 KiB
TypeScript

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