Files
enricobuehler 85473e15dc
CI / build (pull_request) Successful in 34s
CI / publish (pull_request) Skipped
feat: a tile that opens Heroic itself
`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.
2026-08-06 15:17:14 +02:00

135 lines
5.4 KiB
TypeScript

// The Heroic-specific half: which cached titles become entries, and what art survives. Everything
// else this plugin does is the kit's. The real end-to-end proof is
// `punktfunk-plugin-heroic parity --compare`, run on a box with Heroic actually installed.
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 { 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.
let root: string;
let installed: string;
beforeAll(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "pf-heroic-"));
installed = path.join(root, "games", "Quail");
fs.mkdirSync(installed, { recursive: true });
fs.mkdirSync(path.join(root, "store_cache"), { recursive: true });
fs.writeFileSync(
path.join(root, "store_cache", "legendary_library.json"),
JSON.stringify({
library: [
{
app_name: "Quail",
title: "Quail",
is_installed: true,
install: { install_path: installed },
art_square: "https://cdn/quail_tall.jpg",
art_cover: "https://cdn/quail_wide.jpg",
// Sideloaded titles carry local paths here — the client cannot fetch them.
art_logo: "file:///home/u/logo.png",
},
{ app_name: "Owned", title: "Owned Only", is_installed: false },
{
app_name: "Ghost",
title: "Uninstalled Underneath Us",
is_installed: true,
// Heroic's gog `is_installed` bug (#2691): flagged installed, directory long gone.
install: { install_path: path.join(root, "games", "gone") },
},
{
title: "No App Name",
is_installed: true,
install: { install_path: installed },
},
],
}),
);
});
afterAll(() => fs.rmSync(root, { recursive: true, force: true }));
const scan = () =>
runnerGames(root, "legendary_library.json", "legendary", "library");
/** The single surviving entry. Throws rather than returning undefined, so a regression that
* empties the scan fails loudly here instead of skipping every assertion below. */
const only = () => {
const got = scan();
if (got.length !== 1) throw new Error(`expected 1 entry, got ${got.length}`);
return got[0]!;
};
describe("heroic store_cache", () => {
test("keeps only installed titles whose install dir still exists", () => {
const got = scan();
expect(got.map((e) => e.external_id)).toEqual(["legendary:Quail"]);
// Each exclusion is a distinct real case, so spell out why the other three are gone:
// not installed, installed-but-directory-gone (the #2691 workaround), and no app_name.
expect(got).toHaveLength(1);
});
test("the external id is <runner>:<appName>, which the host prefixes with the store", () => {
// This is the migration invariant: the host composes `heroic:legendary:Quail`, byte-identical
// to what the built-in scanner produced, so GameStream app ids and Moonlight pins survive.
const e = only();
expect(e.external_id).toBe("legendary:Quail");
expect(e.launch).toEqual({ kind: "heroic", value: "legendary:Quail" });
});
test("only http(s) art survives; a sideloaded file:// path is dropped", () => {
const e = only();
expect(e.art?.portrait).toBe("https://cdn/quail_tall.jpg");
expect(e.art?.header).toBe("https://cdn/quail_wide.jpg");
// No art_background in the fixture → hero falls back to the cover, as in-host.
expect(e.art?.hero).toBe("https://cdn/quail_wide.jpg");
expect(e.art?.logo).toBeNull();
});
test("carries both detect signals — the dir AND the env marker", () => {
// Heroic hands off to legendary/gogdl/nile, so the host never sees the game's own process
// any other way. The env marker is load-bearing under Proton specifically.
const e = only();
expect(e.detect?.install_dir).toBe(installed);
expect(e.detect?.env_marker).toEqual({
key: "HEROIC_APP_NAME",
value: "Quail",
});
});
test("a missing or malformed cache is empty, not an error", () => {
// The normal "this backend is unused" case — two of the three files are usually absent.
expect(runnerGames(root, "gog_library.json", "gog", "games")).toEqual([]);
expect(
runnerGames(root, "legendary_library.json", "legendary", "nope"),
).toEqual([]);
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);
});
});