Files
enricobuehler 7c29b08a96
CI / build (pull_request) Successful in 16s
CI / publish (pull_request) Skipped
fix: every runner's installed.json is what says a game is installed, and a console-mode tile
Two things, both about what Heroic actually writes to disk.

**Only Epic games were listed.** The scan read install state out of
`store_cache/<runner>_library.json`, which for GOG never contains any: Heroic
writes that cache from a copy with the install data deliberately removed
("Create new object to not write install data into library store",
gog/library.ts) and merges the real state back in when it reads the file. So
every GOG row on disk says `is_installed: false`, the scan dropped all of them,
and a user with Epic and GOG titles saw only the Epic ones. Ported straight from
the in-host scanner, so it has been true since before the extraction.

legendary and nile do bake install state into their caches — but only when a
full library refresh rewrites the file; installing a game mutates their
in-memory map and leaves the cache stale until then.

So each runner's own `installed.json` is now the authority on what is installed
and where, with the cache supplying the title and the art. Three runners, three
different shapes, disagreeing on both the wrapper and the field names (an object
keyed by app name; `{installed: [{appName, install_path}]}`; a bare array of
`{id, path}`) — `installedPaths` normalises all three, and an unreadable one
falls back to the cache rather than emptying the library. Amazon titles and
just-installed Epic titles now appear too, and `watchDirs` covers the three
installed.json directories so an install syncs instead of waiting for the poll.

**A console-mode tile.** Heroic 2.21 added a fullscreen gamepad UI — the Big
Picture of this launcher — and it is what you want from a couch. `Heroic Console
Mode` publishes `launcher_ui: heroic-console` beside the existing window tile,
both on by default and each switchable. The host builds the line: console mode
needs TWO flags (`--console` routes the UI, `--fullscreen` fills the screen) and
no `heroic://` URI can reach it, so that knowledge belongs there, not here.

Requires the host release that knows `heroic-console` — the index entry for this
version must carry a matching `minHost`, because an older host refuses the value
and a refused reconcile takes the whole batch with it.

Also: the test fixture now covers all three runners rather than only legendary,
which is the reason a year of green tests never noticed; plugin-kit ^0.4.4 (CI
was validating 0.4.1 while hosts resolve 0.4.4); a trimmed `configRoot`; and the
README/CI comments that still described the built-in scanners deleted in v0.28.0.
2026-08-26 19:05:54 +02:00

289 lines
10 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 scan --preview`, run on a box with Heroic actually installed.
//
// The fixture covers ALL THREE runners, not just legendary. That is deliberate and is the whole
// regression: the previous suite only ever fed real data through `legendary_library.json`, so it
// passed for a year while GOG and Amazon titles were being dropped on every real host.
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 {
installedPaths,
launcherEntries,
type Runner,
runnerGames,
} from "../src/plugin.js";
// A throwaway Heroic config root with real store_cache files, real installed.json files, 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;
/** The three runners, exactly as `RUNNERS` declares them (that constant stays module-private). */
const LEGENDARY: Runner = {
runner: "legendary",
file: "legendary_library.json",
key: "library",
installed: "legendaryConfig/legendary/installed.json",
};
const GOG: Runner = {
runner: "gog",
file: "gog_library.json",
key: "games",
installed: "gog_store/installed.json",
};
const NILE: Runner = {
runner: "nile",
file: "nile_library.json",
key: "library",
installed: "nile_config/nile/installed.json",
};
const write = (rel: string, doc: unknown) => {
const file = path.join(root, rel);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(doc));
};
beforeAll(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "pf-heroic-"));
installed = path.join(root, "games", "Quail");
fs.mkdirSync(installed, { recursive: true });
// legendary (Epic) — the runner that DOES bake install state into its cache.
write("store_cache/legendary_library.json", {
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 },
},
],
});
// Its installed.json: an OBJECT keyed by app name.
write("legendaryConfig/legendary/installed.json", {
Quail: { app_name: "Quail", install_path: installed },
});
// gog — the reported bug. Heroic writes this cache with install data deliberately stripped, so
// every row says `is_installed: false` with an empty `install` and the ONLY record of the
// install is gog_store/installed.json.
write("store_cache/gog_library.json", {
games: [
{
app_name: "1207658930",
title: "The Witcher",
is_installed: false,
install: { is_dlc: false },
art_square: "https://cdn/witcher_tall.jpg",
},
{
app_name: "1207658931",
title: "Owned But Not Installed",
is_installed: false,
install: { is_dlc: false },
},
],
});
// Wrapper key `installed`, an ARRAY, and the app name is camelCase here.
write("gog_store/installed.json", {
installed: [{ appName: "1207658930", install_path: installed }],
});
// nile (Amazon) — bakes install state into its cache, but the cache only gets rewritten on a
// full refresh. This fixture is the just-installed case: the cache still says "not installed"
// and installed.json already knows better.
write("store_cache/nile_library.json", {
library: [
{
app_name: "amzn1.adg.product.abc-123",
title: "Broken Roads",
is_installed: false,
install: {},
},
],
});
// A BARE ARRAY, and it says `id` / `path` rather than `app_name` / `install_path`.
write("nile_config/nile/installed.json", [
{ id: "amzn1.adg.product.abc-123", path: installed, version: "1.0" },
]);
});
afterAll(() => fs.rmSync(root, { recursive: true, force: true }));
const scan = () => runnerGames(root, LEGENDARY);
/** The single surviving legendary 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("every runner, not just Epic", () => {
// The regression this suite exists for. Each of the three reaches its install path by a
// different route, and only legendary's worked before.
test("gog titles are listed even though their cache says is_installed: false", () => {
const got = runnerGames(root, GOG);
expect(got.map((e) => e.external_id)).toEqual(["gog:1207658930"]);
expect(got[0]?.title).toBe("The Witcher");
expect(got[0]?.detect?.install_dir).toBe(installed);
// The owned-but-not-installed one is absent from installed.json, so it stays out.
});
test("nile titles are listed from installed.json before the cache catches up", () => {
const got = runnerGames(root, NILE);
expect(got.map((e) => e.external_id)).toEqual([
"nile:amzn1.adg.product.abc-123",
]);
expect(got[0]?.detect?.install_dir).toBe(installed);
});
test("legendary still works off its cache alone", () => {
expect(scan().map((e) => e.external_id)).toEqual(["legendary:Quail"]);
});
});
describe("installedPaths reads all three shapes", () => {
test("object keyed by app name (legendary)", () => {
expect([...installedPaths(root, LEGENDARY.installed)]).toEqual([
["Quail", installed],
]);
});
test("wrapper key + camelCase appName (gog)", () => {
expect([...installedPaths(root, GOG.installed)]).toEqual([
["1207658930", installed],
]);
});
test("bare array with id/path (nile)", () => {
expect([...installedPaths(root, NILE.installed)]).toEqual([
["amzn1.adg.product.abc-123", installed],
]);
});
test("a missing, empty or junk file is an empty map, never a throw", () => {
expect(installedPaths(root, "nope/installed.json").size).toBe(0);
expect(installedPaths("/nope/not/here", "x.json").size).toBe(0);
write("junk/installed.json", { installed: "not an array" });
expect(installedPaths(root, "junk/installed.json").size).toBe(0);
write("scalar/installed.json", 42);
expect(installedPaths(root, "scalar/installed.json").size).toBe(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 often absent.
expect(runnerGames("/nope/not/here", GOG)).toEqual([]);
// Present but not the shape we expect: still empty, still not a throw.
const odd = path.join(root, "odd-root");
fs.mkdirSync(path.join(odd, "store_cache"), { recursive: true });
fs.writeFileSync(
path.join(odd, "store_cache", "gog_library.json"),
'{"games":"not an array"}',
);
expect(runnerGames(odd, GOG)).toEqual([]);
fs.writeFileSync(path.join(odd, "store_cache", "gog_library.json"), "{{{");
expect(runnerGames(odd, GOG)).toEqual([]);
});
});
describe("the launcher tiles", () => {
test("both are published by default, valued by UI and never by a command", () => {
// Design D4 + D1: the plugin names a launcher UI, the host resolves it to the native binary
// or the Flatpak (plus console mode's two flags). A `command` kind would be refused
// (operator-only since the 2026-08-05 review), so `launcher_ui` is the only shape that works.
const tiles = launcherEntries({});
expect(tiles.map((t) => t.launch?.value)).toEqual([
"heroic-console",
"heroic",
]);
for (const t of tiles) {
expect(t.role).toBe("launcher");
expect(t.launch?.kind).toBe("launcher_ui");
expect(t.icon).toBe("heroic");
// Deliberately art-less — see the tiles' own comment.
expect(t.art).toBeUndefined();
}
// `heroic:console` / `heroic:launcher` — game ids are `<runner>:<appName>`, so neither
// can collide with one.
expect(tiles.map((t) => t.external_id)).toEqual(["console", "launcher"]);
expect(tiles.map((t) => t.title)).toEqual([
"Heroic Console Mode",
"Heroic",
]);
});
test("an operator can turn either off independently", () => {
expect(
launcherEntries({ consoleMode: false }).map((t) => t.external_id),
).toEqual(["launcher"]);
expect(
launcherEntries({ launcher: false }).map((t) => t.external_id),
).toEqual(["console"]);
expect(launcherEntries({ launcher: false, consoleMode: false })).toEqual(
[],
);
});
});