`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.
92 lines
3.9 KiB
TypeScript
92 lines
3.9 KiB
TypeScript
// The Lutris-specific half. Everything else this plugin does (claiming the store, reconciling,
|
|
// serving __config, the CLI verbs) is the kit's and is tested there; the real end-to-end proof is
|
|
// `punktfunk-plugin-lutris parity --compare`, run on a box with Lutris actually installed.
|
|
import { describe, expect, test } from "bun:test";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { artFile, launcherEntries } from "../src/plugin.js";
|
|
|
|
describe("cover-art lookup", () => {
|
|
test("refuses a slug that could escape the art roots", () => {
|
|
// The slug comes verbatim out of Lutris's own database, and is interpolated into a path whose
|
|
// bytes the host's art proxy will then serve to any paired client. An escape here is an
|
|
// arbitrary-file-read primitive, not a cosmetic bug — so this guard is the point of the
|
|
// function, and it is checked before anything touches the filesystem.
|
|
for (const bad of [
|
|
"",
|
|
"../../../../etc/passwd",
|
|
"..",
|
|
"a/../../b",
|
|
"foo/bar",
|
|
"foo\\bar",
|
|
"foo\0bar",
|
|
]) {
|
|
expect(artFile("coverart", bad)).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
test("finds a real cover under a Lutris root, and nothing for an unknown slug", () => {
|
|
// `artFile` searches under $HOME, so plant the fixture in the real one and clean it up. The
|
|
// slug is namespaced with the pid so a parallel run can't collide.
|
|
const home = os.homedir();
|
|
const slug = `pf-test-${process.pid}`;
|
|
const dir = path.join(home, ".local/share/lutris/coverart");
|
|
const file = path.join(dir, `${slug}.jpg`);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(file, "not-really-a-jpeg");
|
|
try {
|
|
expect(artFile("coverart", slug)).toBe(file);
|
|
// Right slug, wrong kind — Lutris keeps banners in their own directory.
|
|
expect(artFile("banners", slug)).toBeUndefined();
|
|
expect(artFile("coverart", `${slug}-nope`)).toBeUndefined();
|
|
} finally {
|
|
fs.rmSync(file, { force: true });
|
|
}
|
|
});
|
|
|
|
test("an empty file is not a cover", () => {
|
|
// `isFile` requires a non-empty regular file: Lutris leaves zero-byte placeholders behind when
|
|
// an art download fails, and proxying one would render as a broken image rather than the
|
|
// title card the client falls back to.
|
|
const home = os.homedir();
|
|
const slug = `pf-empty-${process.pid}`;
|
|
const dir = path.join(home, ".local/share/lutris/coverart");
|
|
const file = path.join(dir, `${slug}.jpg`);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(file, "");
|
|
try {
|
|
expect(artFile("coverart", slug)).toBeUndefined();
|
|
} finally {
|
|
fs.rmSync(file, { force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("the launcher tile", () => {
|
|
test("is published by default, valued by store id and never by a command", () => {
|
|
// Design D4 + D1: the plugin names a launcher, the host builds the command. A `command`
|
|
// kind here would be refused outright — the 2026-08-05 review made that operator-only —
|
|
// so `launcher_ui` valued "lutris" is the only shape that can work, and the host's
|
|
// `valid_launcher_ui` allow-list is what validates it inbound.
|
|
const tiles = launcherEntries({});
|
|
expect(tiles).toHaveLength(1);
|
|
expect(tiles[0]).toMatchObject({
|
|
role: "launcher",
|
|
launch: { kind: "launcher_ui", value: "lutris" },
|
|
});
|
|
// `lutris:launcher` — real Lutris ids are numeric, so this cannot collide with a game.
|
|
expect(tiles[0]?.external_id).toBe("launcher");
|
|
expect(tiles[0]?.title).toBe("Lutris");
|
|
// Deliberately art-less: a square app icon cover-cropped into a 2:3 tile looks broken, and
|
|
// the clients render an art-less launcher entry as "opens Lutris" on purpose.
|
|
expect(tiles[0]?.art).toBeUndefined();
|
|
});
|
|
|
|
test("an operator can turn it off", () => {
|
|
expect(launcherEntries({ launcher: false })).toEqual([]);
|
|
// Absent means default-on, which is what an untouched config decodes to.
|
|
expect(launcherEntries({ launcher: true })).toHaveLength(1);
|
|
});
|
|
});
|