From bedf43214c61ceb7deb10b77eaad90be577cc614 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 15:17:12 +0200 Subject: [PATCH] feat: a tile that opens Lutris itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/plugin.ts | 46 +++++++++++++++++++++++++++++++++++++++++++++ test/lutris.test.ts | 29 +++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) 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); + }); +});