diff --git a/src/plugin.ts b/src/plugin.ts index 77fe28c..ca58feb 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -30,6 +30,19 @@ const HeroicConfig = Schema.Struct({ "Absolute path to Heroic's config directory. Leave empty to find it automatically.", }), ), + /** + * Publish a tile that opens the Heroic window itself (design D4) alongside the games. On by + * default: it is what lets someone install, update or log in from the couch. + */ + launcher: Schema.Boolean.annotate({ + title: "Show a Heroic tile", + description: + "Add a tile that opens Heroic itself, so you can manage games from a client.", + }).pipe( + Schema.withDecodingDefaultKey(Effect.succeed(true), { + encodingStrategy: "omit", + }), + ), }); /** Heroic's three backends: (store_cache file, runner id, the electron-store key holding games). */ @@ -134,6 +147,39 @@ export const runnerGames = ( return out; }; +/** + * The tile that opens Heroic itself (design D4). + * + * `launcher_ui` is valued by *store id*, never a command: the host resolves "heroic" to the + * native binary or the Flatpak, minus the `--no-gui` and the URI that game entries carry, so + * the window itself opens (D1). A plugin cannot publish a `command` kind at all since the + * 2026-08-05 review made that operator-only. + * + * CAVEAT: Heroic is a single-instance Electron app, so if a Heroic window is ALREADY open on + * the box the spawned process forwards to it and exits. The host's launch path documents the + * same caveat for game launches; keeping the session alive across it is WP-B1. + * + * No art on purpose - see the lutris plugin: a square app icon cover-cropped into a 2:3 tile + * looks broken, and every client renders an art-less launcher entry deliberately. + * + * 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: "Heroic", + role: "launcher", + launch: { kind: "launcher_ui", value: "heroic" }, + platform: "PC", + }, + ]; + export const plugin = defineLibraryPlugin({ // One string: plugin id, provider id, store claim, and the id of the built-in scanner this // replaces — matching the package and repo names too. @@ -149,6 +195,8 @@ export const plugin = defineLibraryPlugin({ return RUNNERS.flatMap((r) => runnerGames(root, r.file, r.runner, r.key)); }), + launchers: launcherEntries, + // Heroic rewrites these caches when a game is installed or removed. watchDirs: (cfg) => { const root = findRoot(cfg); diff --git a/test/heroic.test.ts b/test/heroic.test.ts index d99d1c5..c241d87 100644 --- a/test/heroic.test.ts +++ b/test/heroic.test.ts @@ -5,7 +5,7 @@ import { afterAll, beforeAll, 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 { runnerGames } from "../src/plugin.js"; +import { launcherEntries, runnerGames } from "../src/plugin.js"; // A throwaway Heroic config root with a real store_cache and real install dirs — the scan requires // the install dir to EXIST, so a fixture of pure JSON would report nothing and pass vacuously. @@ -108,3 +108,27 @@ describe("heroic store_cache", () => { expect(runnerGames("/nope/not/here", "x.json", "gog", "games")).toEqual([]); }); }); + +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 resolves it to the native binary + // or the Flatpak. A `command` kind would be refused (operator-only since the 2026-08-05 + // review), so `launcher_ui` valued "heroic" is the only shape that can work. + const tiles = launcherEntries({}); + expect(tiles).toHaveLength(1); + expect(tiles[0]).toMatchObject({ + role: "launcher", + launch: { kind: "launcher_ui", value: "heroic" }, + }); + // `heroic:launcher` — game ids are `:`, so this cannot collide. + expect(tiles[0]?.external_id).toBe("launcher"); + expect(tiles[0]?.title).toBe("Heroic"); + // Deliberately art-less — see the tile's own comment. + expect(tiles[0]?.art).toBeUndefined(); + }); + + test("an operator can turn it off", () => { + expect(launcherEntries({ launcher: false })).toEqual([]); + expect(launcherEntries({ launcher: true })).toHaveLength(1); + }); +});