One plugin = one repo, matching the house pattern (playnite, rom-manager and
virtualhere are already each their own repo with their own biome/bunfig/tsconfig
/CI). The implementation plan's WP5.0 had proposed a single workspace repo for
all six library scanners; this is the piece that makes the split cost nothing.
Everything the six scanners share is already published rather than adjacent: the
parsers and defineLibraryPlugin live in @punktfunk/plugin-kit/library, so repo
boundaries are irrelevant to them. Fixtures are not shared in practice either —
the Rust scanners build theirs inline in code, there are no fixture files, and
the one genuinely cross-plugin builder (binary shortcuts.vdf) is already in this
package's own tests. A pga.db fixture is useless to the epic plugin.
The parity harness was the exception: generic across all six, and parked in the
shared repo the plan assumed. It moves here.
What it is: the acceptance gate for an extracted scanner. Ported unit tests pin
the PARSERS; they do not prove the plugin reproduces the scanner it replaces. A
plugin that parses perfectly and emits steam:440.0 instead of steam:440 breaks
every Moonlight pin on the host and no parser test notices.
punktfunk-plugin-steam parity --snapshot before.json # host on its built-in
punktfunk-plugin-steam parity --compare before.json # offline; exits non-zero
--compare runs the plugin's own scan rather than requiring it to be installed
first, so a mismatch is visible before anything is published and the run is
repeatable while you fix it.
Three judgement calls in the diff, each pinned by a test:
* art is compared by PRESENCE, not value. The representation legitimately
changes on extraction (a host-relative proxy path or inlined data: URL
becomes a file:// path or a CDN URL), so comparing values would fail every
run for no reason. Losing an art kind fails; gaining one does not.
* launcher entries (role: "launcher") are reported separately instead of as
unexpected extras — the built-in scanner had no concept of them, so they can
never be in a baseline. An ORDINARY title the scanner never had still fails,
which is what catches a bad tool filter.
* absent and empty are the same thing in metadata: the host omits empty lists
and nulls, so a plugin sending genres: [] has not changed anything.
plugin-kit: tsc clean, 56 tests pass (10 new).
336 lines
14 KiB
TypeScript
336 lines
14 KiB
TypeScript
// `defineLibraryPlugin` — the shared framework behind every library-scanner plugin (design D10).
|
||
//
|
||
// The point of this module is that a first-party scanner should be **its parsers and a scan
|
||
// function**, ~200–400 lines, and nothing else. Everything a scanner needs beyond that is identical
|
||
// across all six of them and lives here: claiming the store, reconciling through the sync engine,
|
||
// appending launcher entries, serving `__config` so the console renders settings without the plugin
|
||
// shipping an SPA, registering under `category: "library"` so it stays out of the nav, and the
|
||
// standard CLI verbs.
|
||
import type { PluginDef } from "@punktfunk/host";
|
||
import * as fs from "node:fs";
|
||
import { Duration, Effect, Layer, Schema, Stream } from "effect";
|
||
import { type CliCommand, runPluginCli } from "../cli.js";
|
||
import { type ConfigService, makeConfigService } from "../config.js";
|
||
import { HostClient, PluginInfo } from "../host-client.js";
|
||
import { ProviderClient, type ProviderClientService } from "../reconcile.js";
|
||
import { definePluginKit, type PluginKitDef } from "../runtime.js";
|
||
import { makeSyncEngine } from "../sync-engine.js";
|
||
import { serveUi } from "../ui-server.js";
|
||
import type { ProviderEntry } from "../wire.js";
|
||
import {
|
||
diffParity,
|
||
formatParityReport,
|
||
fromHostEntry,
|
||
fromProviderEntry,
|
||
type HostGameEntry,
|
||
} from "./parity.js";
|
||
|
||
/** What a scan produced — the status surface and the CLI's `scan` verb both render this. */
|
||
export interface ScanReport {
|
||
readonly entries: number;
|
||
readonly launchers: number;
|
||
/** False when the launcher isn't installed here — the library is legitimately empty. */
|
||
readonly present: boolean;
|
||
}
|
||
|
||
export interface LibraryPluginDef<S extends Schema.Top> {
|
||
/**
|
||
* The plugin id. **This one string is also the provider id, the store claim, and the id of the
|
||
* built-in scanner this plugin replaces.** That identity chain is what makes the migration
|
||
* invisible: entry ids stay `<name>:<external_id>`, GameStream app ids and client art caches
|
||
* stay valid, and the operator's existing enable/disable state carries over untouched.
|
||
*/
|
||
readonly name: string;
|
||
readonly version?: string;
|
||
/**
|
||
* The store to claim (design D2). Defaults to {@link name} and should almost never differ — see
|
||
* the identity note above. Pass `null` to opt out of claiming entirely, which makes this an
|
||
* ordinary unclaimed provider whose entries surface as `custom:`.
|
||
*/
|
||
readonly store?: string | null;
|
||
/** The operator-facing config schema. Drives `__config` and every callback's argument. */
|
||
readonly configSchema: S;
|
||
/**
|
||
* Is this launcher present on the host at all? Surfaces in the CLI's `detect` verb, and lets the
|
||
* plugin report "not installed" rather than silently syncing an empty library.
|
||
*/
|
||
readonly detect: (cfg: S["Type"]) => Effect.Effect<boolean>;
|
||
/** Enumerate the launcher's installed titles — the only real per-store code. */
|
||
readonly scan: (
|
||
cfg: S["Type"],
|
||
) => Effect.Effect<ReadonlyArray<ProviderEntry>>;
|
||
/**
|
||
* Entries that open the LAUNCHER itself (design D4) — Steam Big Picture, Heroic, … Appended to
|
||
* every reconcile, so toggling one in config takes effect on the next sync. Emit them with
|
||
* `role: "launcher"`; the kit does not stamp it for you, because a plugin may legitimately want
|
||
* an entry that opens a launcher but still lists as an ordinary game.
|
||
*/
|
||
readonly launchers?: (cfg: S["Type"]) => ReadonlyArray<ProviderEntry>;
|
||
/** Launcher data dirs to watch, so a newly installed game appears without waiting for a poll. */
|
||
readonly watchDirs?: (cfg: S["Type"]) => ReadonlyArray<string>;
|
||
/** How often to re-scan regardless of watches. Default `Duration.minutes(15)`. */
|
||
readonly pollInterval?: Duration.Duration;
|
||
/** Debounce on filesystem events. Default `Duration.seconds(3)`. */
|
||
readonly debounce?: Duration.Duration;
|
||
/** Display title (the console's sources row falls back to the scanner label). Defaults to `name`. */
|
||
readonly title?: string;
|
||
/** Extra CLI verbs beyond the standard `detect` / `scan` / `uninstall` set. */
|
||
readonly commands?: Record<string, CliCommand<never>>;
|
||
}
|
||
|
||
/** `--flag value` from an argv slice, or undefined. */
|
||
const flagValue = (
|
||
argv: ReadonlyArray<string>,
|
||
flag: string,
|
||
): string | undefined => {
|
||
const i = argv.indexOf(flag);
|
||
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
|
||
};
|
||
|
||
/** The pieces a library plugin package wires into its entry points. */
|
||
export interface LibraryPlugin {
|
||
/** The runner-discovered default export (`export default plugin.def`). */
|
||
readonly def: PluginDef;
|
||
/** The CLI entry (`await plugin.cli()` from the package's bin). */
|
||
readonly cli: (argv?: ReadonlyArray<string>) => Promise<void>;
|
||
}
|
||
|
||
export const defineLibraryPlugin = <S extends Schema.Top>(
|
||
def: LibraryPluginDef<S>,
|
||
): LibraryPlugin => {
|
||
const store = def.store === null ? undefined : (def.store ?? def.name);
|
||
const poll = def.pollInterval ?? Duration.minutes(15);
|
||
const debounce = def.debounce ?? Duration.seconds(3);
|
||
|
||
/** The config service, built fresh wherever it is needed (it only requires `PluginInfo`). */
|
||
const config: Effect.Effect<ConfigService<S>, never, PluginInfo> =
|
||
makeConfigService({ schema: def.configSchema });
|
||
|
||
/** Scan + launcher entries, in the order they should reach the host. */
|
||
const computeEntries = (
|
||
cfg: S["Type"],
|
||
): Effect.Effect<{
|
||
readonly entries: ReadonlyArray<ProviderEntry>;
|
||
readonly report: ScanReport;
|
||
}> =>
|
||
Effect.gen(function* () {
|
||
const present = yield* def.detect(cfg);
|
||
// A launcher that isn't installed contributes NOTHING — not even its launcher entries. A
|
||
// "Steam Big Picture" tile on a box without Steam would only fail to launch.
|
||
if (!present) {
|
||
return {
|
||
entries: [] as ReadonlyArray<ProviderEntry>,
|
||
report: { entries: 0, launchers: 0, present: false } as const,
|
||
};
|
||
}
|
||
const scanned = yield* def.scan(cfg);
|
||
const launchers = def.launchers?.(cfg) ?? [];
|
||
return {
|
||
entries: [...scanned, ...launchers],
|
||
report: {
|
||
entries: scanned.length,
|
||
launchers: launchers.length,
|
||
present: true,
|
||
} as const,
|
||
};
|
||
});
|
||
|
||
/**
|
||
* Push one entry set to the host under the store claim, warning **once** if the host is too old
|
||
* to honour it.
|
||
*
|
||
* This degradation is worth the code: a pre-M2 host ignores `?store=` silently, and the only
|
||
* symptom would be this plugin's titles appearing as unbadged `custom:` entries *beside* the
|
||
* built-in scanner's identical ones — a confusing double-listing with no error anywhere.
|
||
* Checking the echoed entries turns that into one actionable log line.
|
||
*/
|
||
const applyEntries =
|
||
(provider: ProviderClientService, state: { warned: boolean }) =>
|
||
(entries: ReadonlyArray<ProviderEntry>): Effect.Effect<void, unknown> =>
|
||
provider.reconcile(def.name, entries, store).pipe(
|
||
Effect.tap((echoed) => {
|
||
if (!store || state.warned || echoed.length === 0) return Effect.void;
|
||
if (echoed.some((e) => e.store === store)) return Effect.void;
|
||
state.warned = true;
|
||
return Effect.logWarning(
|
||
`host is too old for store claims: this source's games will appear as custom ` +
|
||
`entries and the host's own "${store}" scanner is not suppressed, so titles ` +
|
||
`may be listed twice. Updating the host resolves it.`,
|
||
);
|
||
}),
|
||
Effect.asVoid,
|
||
);
|
||
|
||
const main = Effect.gen(function* () {
|
||
const cfgService = yield* config;
|
||
const provider = yield* ProviderClient;
|
||
const state = { warned: false };
|
||
|
||
const engine = yield* makeSyncEngine<
|
||
ScanReport,
|
||
ReadonlyArray<ProviderEntry>,
|
||
never
|
||
>({
|
||
compute: () => cfgService.load.pipe(Effect.flatMap(computeEntries)),
|
||
apply: applyEntries(provider, state),
|
||
// The host IS the state: a full-replace reconcile is idempotent, so there is nothing to
|
||
// persist between runs. Reporting no previous fingerprint means the first sync after a
|
||
// restart always pushes, which is exactly what we want (the host may have been reinstalled
|
||
// underneath us).
|
||
lastSync: { get: Effect.succeed(undefined), set: () => Effect.void },
|
||
settings: cfgService.load.pipe(
|
||
Effect.map((cfg) => def.watchDirs?.(cfg) ?? []),
|
||
// A config file that won't decode must not stop the poll loop: fall back to no watch
|
||
// dirs, keep syncing on the timer, and let the operator see the parse error in the
|
||
// settings drawer (`GET /__config` reports it).
|
||
Effect.catch(() => Effect.succeed([] as ReadonlyArray<string>)),
|
||
Effect.map((watchDirs) => ({
|
||
pollInterval: poll,
|
||
watch: true,
|
||
debounce,
|
||
watchDirs,
|
||
})),
|
||
),
|
||
});
|
||
|
||
// The UI server exists ONLY to serve `__config` (and the SDK's `__health`): no `staticDir`,
|
||
// no API. That is the whole "settings without an SPA" story (design D7, closing G8), and the
|
||
// `library` category is what keeps six installed scanners out of the console's sidebar.
|
||
yield* serveUi({
|
||
title: def.title ?? def.name,
|
||
category: "library",
|
||
config: { schema: def.configSchema, service: cfgService },
|
||
});
|
||
|
||
yield* engine.start;
|
||
// A saved settings change is exactly when a user expects the library to update — and it may
|
||
// have changed `watchDirs`, so re-read settings rather than just re-syncing.
|
||
yield* Effect.forkScoped(
|
||
Stream.runForEach(cfgService.changes, () => engine.reconfigure),
|
||
);
|
||
yield* Effect.never;
|
||
});
|
||
|
||
const kitDef: PluginKitDef<never, ProviderClient> = {
|
||
name: def.name,
|
||
...(def.version !== undefined ? { version: def.version } : {}),
|
||
layer: ProviderClient.layer,
|
||
main: main as Effect.Effect<
|
||
void,
|
||
never,
|
||
ProviderClient | HostClient | PluginInfo | never
|
||
>,
|
||
};
|
||
|
||
const standardCommands: Record<string, CliCommand<ProviderClient>> = {
|
||
detect: {
|
||
summary: "report whether this launcher is installed on the host",
|
||
// Offline on purpose: "is Steam here?" must be answerable without a running host.
|
||
offline: true,
|
||
run: () =>
|
||
Effect.gen(function* () {
|
||
const cfg = yield* (yield* config).load;
|
||
console.log((yield* def.detect(cfg)) ? "present" : "absent");
|
||
}),
|
||
},
|
||
scan: {
|
||
summary: "scan and print what WOULD be synced (--preview for the JSON entries)",
|
||
// Also offline: the point is to debug a scanner against real launcher files without
|
||
// touching the host's library.
|
||
offline: true,
|
||
run: (argv) =>
|
||
Effect.gen(function* () {
|
||
const cfg = yield* (yield* config).load;
|
||
const { entries, report } = yield* computeEntries(cfg);
|
||
if (argv.includes("--preview")) {
|
||
console.log(JSON.stringify(entries, null, 2));
|
||
} else {
|
||
console.log(
|
||
`${report.present ? "present" : "absent"}: ${report.entries} games, ` +
|
||
`${report.launchers} launcher entries`,
|
||
);
|
||
}
|
||
}),
|
||
},
|
||
parity: {
|
||
summary:
|
||
"prove this plugin reproduces the built-in scanner (--snapshot <f> | --compare <f>)",
|
||
// `--compare` is offline (it runs THIS plugin's scan); `--snapshot` needs the host. The
|
||
// dispatcher decides per invocation below, so the verb is registered as online and the
|
||
// snapshot path is the one that actually uses the client.
|
||
run: (argv) =>
|
||
Effect.gen(function* () {
|
||
const snapshot = flagValue(argv, "--snapshot");
|
||
const compare = flagValue(argv, "--compare");
|
||
if (!snapshot && !compare) {
|
||
console.error(
|
||
"usage: parity --snapshot <file> (capture the host's CURRENT library for this store)\n" +
|
||
" parity --compare <file> (diff this plugin's scan against that capture)",
|
||
);
|
||
process.exitCode = 2;
|
||
return;
|
||
}
|
||
if (snapshot) {
|
||
// The baseline: what the host reports for THIS store while its built-in scanner
|
||
// is still the thing producing it. Capture before installing the plugin.
|
||
const host = yield* HostClient;
|
||
const body = yield* host.request("GET", "/library");
|
||
const mine = (Array.isArray(body) ? (body as HostGameEntry[]) : [])
|
||
.filter((e) => e.store === (store ?? def.name))
|
||
.map(fromHostEntry)
|
||
.sort((a, b) => a.id.localeCompare(b.id));
|
||
yield* Effect.sync(() =>
|
||
fs.writeFileSync(snapshot, `${JSON.stringify(mine, null, 2)}\n`),
|
||
);
|
||
console.log(
|
||
`captured ${mine.length} "${store ?? def.name}" entries to ${snapshot}`,
|
||
);
|
||
return;
|
||
}
|
||
const baseline = yield* Effect.try({
|
||
try: () =>
|
||
JSON.parse(fs.readFileSync(compare as string, "utf8")) as ReturnType<
|
||
typeof fromHostEntry
|
||
>[],
|
||
catch: (cause) => new Error(`cannot read ${compare}: ${cause}`),
|
||
});
|
||
const cfg = yield* (yield* config).load;
|
||
const { entries } = yield* computeEntries(cfg);
|
||
const produced = entries.map((e) =>
|
||
fromProviderEntry(store ?? def.name, e),
|
||
);
|
||
const report = diffParity(baseline, produced);
|
||
console.log(formatParityReport(report));
|
||
// A non-zero exit is what makes this usable as a release gate rather than a report
|
||
// somebody skims.
|
||
if (!report.ok) process.exitCode = 1;
|
||
}),
|
||
},
|
||
uninstall: {
|
||
summary: "remove this source's games from the host and release its store claim",
|
||
run: () =>
|
||
Effect.gen(function* () {
|
||
const provider = yield* ProviderClient;
|
||
// The empty reconcile clears the entries; DELETE is what releases the CLAIM — and
|
||
// releasing is what brings the host's own built-in scanner straight back.
|
||
yield* provider.reconcile(def.name, [], undefined);
|
||
yield* provider.remove(def.name);
|
||
console.log(`${def.name}: entries removed, store claim released`);
|
||
}),
|
||
},
|
||
};
|
||
|
||
return {
|
||
def: definePluginKit(kitDef),
|
||
cli: (argv) =>
|
||
runPluginCli({
|
||
def: kitDef,
|
||
commands: {
|
||
...standardCommands,
|
||
...(def.commands ?? {}),
|
||
} as Record<string, CliCommand<ProviderClient>>,
|
||
...(argv !== undefined ? { argv } : {}),
|
||
}),
|
||
};
|
||
};
|