diff --git a/src/plugin.ts b/src/plugin.ts index 4bd9ba4..f5b0574 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -38,6 +38,20 @@ const LutrisConfig = Schema.Struct({ "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. */ @@ -99,6 +113,36 @@ interface GameRow { 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 @@ -160,6 +204,8 @@ export const plugin = defineLibraryPlugin({ }); }), + launchers: launcherEntries, + // Re-scan when Lutris writes: installing a game touches the database, and downloading art // touches the cover directories. watchDirs: (cfg) => { diff --git a/test/lutris.test.ts b/test/lutris.test.ts index ef740c3..4e5237a 100644 --- a/test/lutris.test.ts +++ b/test/lutris.test.ts @@ -5,7 +5,7 @@ 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 } from "../src/plugin.js"; +import { artFile, launcherEntries } from "../src/plugin.js"; describe("cover-art lookup", () => { test("refuses a slug that could escape the art roots", () => { @@ -62,3 +62,30 @@ describe("cover-art lookup", () => { } }); }); + +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); + }); +});