feat: a tile that opens Heroic itself
CI / build (pull_request) Successful in 34s
CI / publish (pull_request) Skipped

`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 `"heroic"` 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: installing, updating or logging in are
exactly the things you cannot do from a game tile.

`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). 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.

CAVEAT carried in the code: Heroic is a single-instance Electron app, so if a window
is ALREADY open the spawned process forwards to it and exits. The host documents the
same caveat for game launches; keeping the session alive across it is a host-side
question (the launcher-tile lease), not this plugin's.

Deliberately art-less — 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 as "opens
Heroic" on purpose.

Gates: tsc --noEmit clean, 7 tests pass (2 new), biome clean.
Needs a host carrying the M2 wire (`role` + `launcher_ui`), which is on main.
This commit is contained in:
2026-08-06 15:17:14 +02:00
parent 7d4f26e8d1
commit 85473e15dc
2 changed files with 73 additions and 1 deletions
+48
View File
@@ -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);
+25 -1
View File
@@ -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 `<runner>:<appName>`, 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);
});
});