feat(plugin-kit): the library-plugin framework — parsers, __config, defineLibraryPlugin
M3 of design/library-scanner-plugins-implementation-plan.md. Target shape: a
first-party scanner plugin is its parsers plus a scan function.
WP3.1 — a parsers module under the new ./library subpath, porting what the six
in-host scanners hand-rolled: text VDF/ACF, the BINARY shortcuts.vdf KeyValues
walker with its CRC-32 appid derivation and the 64-bit rungameid composition,
read-only SQLite (bun:sqlite, immutable=1 so a scan can never take a lock or
spawn WAL sidecars next to a launcher's live database), a reg.exe wrapper,
capped readers, the path-confinement join that keeps a crafted goggame-*.info
from pointing a launch at an arbitrary program, Steam root/library discovery,
art location helpers, and a fetch helper carrying the host's no-redirect
anti-SSRF posture. Every parser is total: a missing launcher or a truncated file
degrades to "no titles", never to a throw.
Two deliberate departures from the Rust originals, both about the Windows
runner's account: steam root discovery now also reads HKLM Valve\Steam
InstallPath (a non-default install dir was previously uncovered), and the
registry wrapper refuses HKCU outright — as LocalService that is not the
operator's hive, so reading it would silently look like "not installed".
WP3.2 — GET/PUT /__config on the kit's UI server, so a plugin with settings does
not ship an SPA (closes G8). GET answers {schema, value}: the derived JSON Schema
and the raw operator-authored config. PUT validates by decoding and only then
persists RAW, so defaults are never baked into the file. The handler is split out
as makeConfigHandler and driven directly in tests.
WP3.3 — defineLibraryPlugin wires SyncEngine (poll + fs-watch + debounce), the
store-claiming reconcile, launcher entries appended to every sync, a UI server
serving only __config under category "library" (which keeps six installed
scanners out of the console nav), and the standard detect/scan/uninstall CLI
verbs. It warns ONCE when a pre-M2 host silently ignores the store claim — that
degradation is otherwise invisible except as duplicated titles.
M0/S2 is recorded here as a committed fixture rather than prose. Two findings the
original spike missed because deriving a schema does not exercise it:
withDecodingDefaultKey takes an Effect, not a thunk — a thunk type-checks, derives
fine, and dies at decode time; and a checked schema (Schema.Int) nests its
annotations under allOf, so a form must merge those branches. Both are pinned.
plugin-kit: version 0.3.0, tsc clean, 46 tests pass (16 ported parser tests, 10
config/derivation). Publishing (WP3.4) is deferred — it needs a tag and a push.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@punktfunk/plugin-kit",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
|
||||
"type": "module",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
@@ -29,6 +29,10 @@
|
||||
"types": "./dist/wire.d.ts",
|
||||
"default": "./dist/wire.js"
|
||||
},
|
||||
"./library": {
|
||||
"types": "./dist/library/index.d.ts",
|
||||
"default": "./dist/library/index.js"
|
||||
},
|
||||
"./theme.css": "./dist/theme.css"
|
||||
},
|
||||
"files": ["dist", "README.md"],
|
||||
|
||||
@@ -43,6 +43,13 @@ export {
|
||||
type SyncSettings,
|
||||
type SyncStatus,
|
||||
} from "./sync-engine.js";
|
||||
export { httpApiEnv, serveUi, type ServeUiOptions } from "./ui-server.js";
|
||||
export {
|
||||
deriveConfigJsonSchema,
|
||||
httpApiEnv,
|
||||
makeConfigHandler,
|
||||
serveUi,
|
||||
type ServeUiConfig,
|
||||
type ServeUiOptions,
|
||||
} from "./ui-server.js";
|
||||
export { sseRoute, type SseRouteOptions } from "./sse.js";
|
||||
export { type CliCommand, runPluginCli } from "./cli.js";
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// `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 { Duration, Effect, Layer, Schema, Stream } from "effect";
|
||||
import { type CliCommand, runPluginCli } from "../cli.js";
|
||||
import { type ConfigService, makeConfigService } from "../config.js";
|
||||
import { type 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";
|
||||
|
||||
/** 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>>;
|
||||
}
|
||||
|
||||
/** 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`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
},
|
||||
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 } : {}),
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
// `@punktfunk/plugin-kit/library` — the shared framework for library-scanner plugins.
|
||||
//
|
||||
// A first-party scanner is its parsers plus a scan function; everything else (store claim, sync
|
||||
// engine wiring, launcher entries, `__config`, nav category, CLI verbs) comes from
|
||||
// `defineLibraryPlugin`. See design/library-scanner-plugins.md D10.
|
||||
export {
|
||||
defineLibraryPlugin,
|
||||
type LibraryPlugin,
|
||||
type LibraryPluginDef,
|
||||
type ScanReport,
|
||||
} from "./define.js";
|
||||
export * from "./parsers/index.js";
|
||||
@@ -0,0 +1,120 @@
|
||||
// Where a title's cover art lives: Steam's local caches, its per-account `grid/` overrides, and the
|
||||
// public CDN. Ported from the host scanner's art resolution (steam.rs).
|
||||
//
|
||||
// After extraction a plugin emits art VALUES and the host serves them: a `file://` URL for anything
|
||||
// on disk (the documented local-art contract — the host proxies the bytes), or an absolute CDN URL
|
||||
// the client fetches itself. `data:` URLs remain legal but are small-logo-only: inlining covers is
|
||||
// what blew the host's 2 MB body limit at 49 titles during the playnite work.
|
||||
import * as path from "node:path";
|
||||
import { isFile, listDir } from "./fs.js";
|
||||
|
||||
/** The four art slots the library model carries. */
|
||||
export type ArtKind = "portrait" | "hero" | "logo" | "header";
|
||||
|
||||
export const ART_KINDS: readonly ArtKind[] = [
|
||||
"portrait",
|
||||
"hero",
|
||||
"logo",
|
||||
"header",
|
||||
];
|
||||
|
||||
/** A `file://` URL for a local path — the shape the host's art proxy understands. */
|
||||
export const fileUrl = (p: string): string => {
|
||||
// Percent-encode, but keep the separators: the host converts this back to a path and expects the
|
||||
// structure intact. Windows drive paths become `file:///C:/…`.
|
||||
const abs = path.resolve(p);
|
||||
const posix = abs.replace(/\\/g, "/");
|
||||
const encoded = posix
|
||||
.split("/")
|
||||
.map((seg) => encodeURIComponent(seg))
|
||||
.join("/");
|
||||
return posix.startsWith("/") ? `file://${encoded}` : `file:///${encoded}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* The legacy flat CDN URL for a Steam appid's art kind. Correct for the many titles Valve hasn't
|
||||
* re-hashed; newer ones serve from an unpredictable per-asset-hash path, where this 404s and the
|
||||
* client falls through to its next candidate. That degradation is intentional and pre-existing.
|
||||
*/
|
||||
export const steamCdnUrl = (appid: number, kind: ArtKind): string | undefined => {
|
||||
// A non-Steam shortcut's appid has the high bit set and is never a real store appid — the CDN
|
||||
// would only 404, so don't emit a URL that is guaranteed to fail.
|
||||
if ((appid & 0x8000_0000) !== 0) return undefined;
|
||||
const file =
|
||||
kind === "portrait"
|
||||
? "library_600x900.jpg"
|
||||
: kind === "hero"
|
||||
? "library_hero.jpg"
|
||||
: kind === "logo"
|
||||
? "logo.png"
|
||||
: "header.jpg";
|
||||
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/${file}`;
|
||||
};
|
||||
|
||||
/** Filenames Steam's local `librarycache` uses per kind, in preference order (2x is sharper). */
|
||||
const localFilenames = (kind: ArtKind): string[] =>
|
||||
kind === "portrait"
|
||||
? ["library_600x900_2x.jpg", "library_600x900.jpg"]
|
||||
: kind === "hero"
|
||||
? ["library_hero.jpg"]
|
||||
: kind === "logo"
|
||||
? ["logo.png"]
|
||||
: // Steam's local cache names the header asset differently from the store CDN's
|
||||
// `header.jpg` — this trips everyone once.
|
||||
["library_header.jpg"];
|
||||
|
||||
/**
|
||||
* This kind's file under one Steam root's `appcache/librarycache/<appid>/<hash>/`, or `undefined`.
|
||||
* Steam reuses one hash dir per asset version, so there is normally exactly one candidate.
|
||||
*/
|
||||
export const findLocalArtFile = (
|
||||
root: string,
|
||||
appid: number,
|
||||
kind: ArtKind,
|
||||
): string | undefined => {
|
||||
const base = path.join(root, "appcache", "librarycache", String(appid));
|
||||
for (const hash of listDir(base)) {
|
||||
for (const name of localFilenames(kind)) {
|
||||
const p = path.join(base, hash, name);
|
||||
if (isFile(p)) return p;
|
||||
}
|
||||
}
|
||||
// Older Steam wrote the files directly under `librarycache/` with the appid in the name.
|
||||
for (const name of localFilenames(kind)) {
|
||||
const flat = path.join(root, "appcache", "librarycache", `${appid}_${name}`);
|
||||
if (isFile(flat)) return flat;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* The `grid/` basenames Steam names each art kind under for an appid: portrait `<A>p`, hero
|
||||
* `<A>_hero`, logo `<A>_logo`, wide capsule `<A>` — each as `.png` then `.jpg`.
|
||||
*
|
||||
* These overrides are the **only** art a non-Steam shortcut ever has.
|
||||
*/
|
||||
export const gridFilenames = (appid: number, kind: ArtKind): string[] => {
|
||||
const base =
|
||||
kind === "portrait"
|
||||
? `${appid}p`
|
||||
: kind === "hero"
|
||||
? `${appid}_hero`
|
||||
: kind === "logo"
|
||||
? `${appid}_logo`
|
||||
: `${appid}`;
|
||||
return [`${base}.png`, `${base}.jpg`];
|
||||
};
|
||||
|
||||
/** This kind's user override under a `userdata/<id>/config/grid/` dir, or `undefined`. */
|
||||
export const findGridArtFile = (
|
||||
configDir: string,
|
||||
appid: number,
|
||||
kind: ArtKind,
|
||||
): string | undefined => {
|
||||
const grid = path.join(configDir, "grid");
|
||||
for (const name of gridFilenames(appid, kind)) {
|
||||
const p = path.join(grid, name);
|
||||
if (isFile(p)) return p;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
// Bounded filesystem reads and path confinement — the posture the in-host scanners established,
|
||||
// ported so a library plugin inherits it instead of re-deriving it.
|
||||
//
|
||||
// The rules here exist because a plugin reads files it does not own: a launcher's manifests, a
|
||||
// catalog cache, a `goggame-*.info` a user could have edited. None of that is hostile in the normal
|
||||
// case, and all of it is untrusted in the case that matters.
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
/** A launcher manifest / `.acf` / `.info`: text, small. Matches `epic.rs`'s posture. */
|
||||
export const MAX_MANIFEST_BYTES = 1024 * 1024;
|
||||
/** A binary catalog cache (Epic's `catcache.bin`, a `shortcuts.vdf`): larger, still bounded. */
|
||||
export const MAX_CACHE_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Read a file as UTF-8, refusing anything over `max`. `undefined` on any error, a non-regular file,
|
||||
* or an over-cap file — a plugin scanning a directory must never die on one odd entry.
|
||||
*
|
||||
* The size is checked by `stat` BEFORE the read, so an enormous file costs a stat, not the memory.
|
||||
*/
|
||||
export const readTextCapped = (
|
||||
file: string,
|
||||
max = MAX_MANIFEST_BYTES,
|
||||
): string | undefined => {
|
||||
try {
|
||||
const st = fs.statSync(file);
|
||||
if (!st.isFile() || st.size === 0 || st.size > max) return undefined;
|
||||
return fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Read a file as bytes, refusing anything over `max`. Same posture as {@link readTextCapped}. */
|
||||
export const readBytesCapped = (
|
||||
file: string,
|
||||
max = MAX_CACHE_BYTES,
|
||||
): Uint8Array | undefined => {
|
||||
try {
|
||||
const st = fs.statSync(file);
|
||||
if (!st.isFile() || st.size === 0 || st.size > max) return undefined;
|
||||
return new Uint8Array(fs.readFileSync(file));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Read + `JSON.parse` a capped text file. `undefined` on any read or parse failure. */
|
||||
export const readJsonCapped = <T = unknown>(
|
||||
file: string,
|
||||
max = MAX_MANIFEST_BYTES,
|
||||
): T | undefined => {
|
||||
const text = readTextCapped(file, max);
|
||||
if (text === undefined) return undefined;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** List a directory's entry names, or `[]` if it isn't readable. */
|
||||
export const listDir = (dir: string): string[] => {
|
||||
try {
|
||||
return fs.readdirSync(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/** Does this path exist as a directory? */
|
||||
export const isDir = (p: string): boolean => {
|
||||
try {
|
||||
return fs.statSync(p).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Does this path exist as a regular, non-empty file? */
|
||||
export const isFile = (p: string): boolean => {
|
||||
try {
|
||||
const st = fs.statSync(p);
|
||||
return st.isFile() && st.size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Join `rel` onto `base` **only if it cannot escape** — the port of the host's `confined_join`
|
||||
* (gog.rs), which exists because a crafted `goggame-<id>.info` could otherwise point a play task's
|
||||
* exe at an arbitrary program (security-review 2026-07-17).
|
||||
*
|
||||
* Refuses any relative path carrying a drive prefix (`C:`), a root (`/` or `\`), or a `..`
|
||||
* component — each of which `path.join` would let REPLACE or climb out of `base`. `undefined` ⇒
|
||||
* out of bounds, and the caller must refuse the launch rather than fall back to something plausible.
|
||||
*/
|
||||
export const confinedJoin = (base: string, rel: string): string | undefined => {
|
||||
if (rel === "") return undefined;
|
||||
// Normalize separators so a Windows-shaped relative path is checked on any platform (a plugin
|
||||
// may parse a Windows manifest while its tests run on Linux).
|
||||
const parts = rel.split(/[\\/]/);
|
||||
if (parts[0] === "" ) return undefined; // rooted
|
||||
if (/^[A-Za-z]:$/.test(parts[0])) return undefined; // drive prefix
|
||||
if (parts.some((p) => p === "..")) return undefined; // traversal
|
||||
const joined = path.join(base, ...parts.filter((p) => p !== "" && p !== "."));
|
||||
// Belt and braces: the component check above is the real guard, but a symlink-free string check
|
||||
// costs nothing and catches anything the split missed.
|
||||
const rootWithSep = base.endsWith(path.sep) ? base : base + path.sep;
|
||||
return joined === base || joined.startsWith(rootWithSep) ? joined : undefined;
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
// The one outbound-HTTP helper a library plugin should use, carrying the host's `fetch_image`
|
||||
// posture verbatim (art.rs): http(s) only, **no redirects**, a size cap, and a short timeout.
|
||||
//
|
||||
// The no-redirect rule is the important one and it is not paranoia: a scanner fetches URLs it read
|
||||
// out of a launcher's cache — data the plugin did not author. A `3xx` chased automatically is an
|
||||
// SSRF pivot from a process running on the operator's box (`http://169.254.169.254/…`, an internal
|
||||
// service). The host learned this in the 2026-07-17 security review; a plugin fetching the same
|
||||
// class of URL inherits the same rule. A rare legitimately-redirecting CDN just yields no art.
|
||||
import { HostRequestError } from "../../errors.js";
|
||||
import { Effect } from "effect";
|
||||
|
||||
export interface FetchLimits {
|
||||
/** Hard cap on the response body. Default 8 MiB — a cover never approaches it. */
|
||||
readonly maxBytes?: number;
|
||||
/** Wall-clock timeout in ms. Default 10 000. */
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX = 8 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT = 10_000;
|
||||
|
||||
export interface FetchedBytes {
|
||||
readonly bytes: Uint8Array;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET an `http(s)` URL under the posture above. Fails with {@link HostRequestError} on any non-2xx,
|
||||
* a redirect, an over-cap body, a timeout, or a non-http(s) scheme.
|
||||
*
|
||||
* Most scanners never need this: they emit CDN URLs and let the CLIENT fetch them, which is both
|
||||
* faster and keeps the host out of the loop. Reach for it only when a store's art requires an API
|
||||
* lookup the client cannot do (GOG's product API, Microsoft's display catalog).
|
||||
*/
|
||||
export const fetchBytes = (
|
||||
url: string,
|
||||
limits: FetchLimits = {},
|
||||
): Effect.Effect<FetchedBytes, HostRequestError> =>
|
||||
Effect.tryPromise({
|
||||
try: async (): Promise<FetchedBytes> => {
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
throw new Error("only http(s) URLs may be fetched");
|
||||
}
|
||||
const maxBytes = limits.maxBytes ?? DEFAULT_MAX;
|
||||
const signal = AbortSignal.timeout(limits.timeoutMs ?? DEFAULT_TIMEOUT);
|
||||
// `redirect: "manual"` rather than "error": we want to SEE the 3xx and report it as a
|
||||
// refusal, not have fetch throw something opaque.
|
||||
const res = await fetch(url, { redirect: "manual", signal });
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
throw new Error(`refusing to follow a ${res.status} redirect`);
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
// Trust Content-Length when it is there (cheap rejection), but still bound the read: a
|
||||
// hostile server can lie about it or omit it entirely.
|
||||
const declared = Number(res.headers.get("content-length"));
|
||||
if (Number.isFinite(declared) && declared > maxBytes) {
|
||||
throw new Error(`body larger than ${maxBytes} bytes`);
|
||||
}
|
||||
const buf = new Uint8Array(await res.arrayBuffer());
|
||||
if (buf.byteLength === 0) throw new Error("empty body");
|
||||
if (buf.byteLength > maxBytes) {
|
||||
throw new Error(`body larger than ${maxBytes} bytes`);
|
||||
}
|
||||
return {
|
||||
bytes: buf,
|
||||
contentType: res.headers.get("content-type") ?? "image/jpeg",
|
||||
};
|
||||
},
|
||||
catch: (cause) =>
|
||||
new HostRequestError({
|
||||
method: "GET",
|
||||
path: url,
|
||||
cause,
|
||||
}),
|
||||
});
|
||||
|
||||
/** {@link fetchBytes}, JSON-decoded. Same posture; use for a store's public product API. */
|
||||
export const fetchJson = <T = unknown>(
|
||||
url: string,
|
||||
limits: FetchLimits = {},
|
||||
): Effect.Effect<T, HostRequestError> =>
|
||||
fetchBytes(url, limits).pipe(
|
||||
Effect.flatMap((r) =>
|
||||
Effect.try({
|
||||
try: () => JSON.parse(new TextDecoder().decode(r.bytes)) as T,
|
||||
catch: (cause) =>
|
||||
new HostRequestError({
|
||||
method: "GET",
|
||||
path: url,
|
||||
cause,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,60 @@
|
||||
// The launcher-file parsing toolkit: what the six in-host scanners hand-rolled, hoisted so a
|
||||
// library plugin is its scan function and nothing else.
|
||||
//
|
||||
// Everything here is total — a missing launcher, a truncated file, a schema drift in a launcher
|
||||
// upgrade all degrade to "no titles from this source", never to a thrown error. A scanner that dies
|
||||
// on one odd file takes the user's whole library with it.
|
||||
export {
|
||||
ART_KINDS,
|
||||
type ArtKind,
|
||||
fileUrl,
|
||||
findGridArtFile,
|
||||
findLocalArtFile,
|
||||
gridFilenames,
|
||||
steamCdnUrl,
|
||||
} from "./art.js";
|
||||
export {
|
||||
confinedJoin,
|
||||
isDir,
|
||||
isFile,
|
||||
listDir,
|
||||
MAX_CACHE_BYTES,
|
||||
MAX_MANIFEST_BYTES,
|
||||
readBytesCapped,
|
||||
readJsonCapped,
|
||||
readTextCapped,
|
||||
} from "./fs.js";
|
||||
export {
|
||||
type FetchedBytes,
|
||||
type FetchLimits,
|
||||
fetchBytes,
|
||||
fetchJson,
|
||||
} from "./http.js";
|
||||
export {
|
||||
parseRegQuery,
|
||||
regQueryValue,
|
||||
regQueryValues,
|
||||
regSubKeys,
|
||||
type RegValue,
|
||||
validRegKey,
|
||||
} from "./registry.js";
|
||||
export {
|
||||
crc32,
|
||||
parseShortcuts,
|
||||
type Shortcut,
|
||||
shortcutAppId,
|
||||
shortcutGameId,
|
||||
} from "./shortcuts.js";
|
||||
export {
|
||||
steamLibraryDirs,
|
||||
steamRoots,
|
||||
steamUserConfigDirs,
|
||||
} from "./steam-root.js";
|
||||
export {
|
||||
type AppManifest,
|
||||
isSteamTool,
|
||||
parseAppManifest,
|
||||
vdfField,
|
||||
vdfPaths,
|
||||
vdfValue,
|
||||
} from "./vdf.js";
|
||||
@@ -0,0 +1,94 @@
|
||||
// Windows registry reads by spawning `reg.exe query` — dependency-free, and (the part that
|
||||
// matters) it works from the scripting runner's LocalService account.
|
||||
//
|
||||
// **HKLM only, by design.** The runner runs as `NT AUTHORITY\LocalService` on Windows, which has no
|
||||
// user profile: HKCU is not the operator's hive there, it is LocalService's own — so a plugin that
|
||||
// read HKCU would silently see an empty registry rather than the user's launcher config. Every
|
||||
// launcher fact a scanner needs (Steam's InstallPath, GOG's game list) lives under HKLM
|
||||
// `WOW6432Node` anyway. Asking for HKCU is a bug, so this refuses it outright.
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
/** One `reg.exe query` value row. */
|
||||
export interface RegValue {
|
||||
readonly name: string;
|
||||
/** `REG_SZ`, `REG_DWORD`, … */
|
||||
readonly type: string;
|
||||
readonly data: string;
|
||||
}
|
||||
|
||||
const HKLM = "HKLM\\";
|
||||
|
||||
/** Is this a key path this module will touch? See the module docs on why HKLM only. */
|
||||
export const validRegKey = (key: string): boolean =>
|
||||
key.startsWith(HKLM) &&
|
||||
key.length > HKLM.length &&
|
||||
key.length <= 260 &&
|
||||
!key.includes("..") &&
|
||||
// `reg.exe` takes the key as one argv element (no shell), but keep the charset tame anyway so a
|
||||
// malformed key can never turn into a switch.
|
||||
!key.startsWith("/") &&
|
||||
!/[\r\n\0"]/.test(key);
|
||||
|
||||
const run = (args: string[]): string | undefined => {
|
||||
if (process.platform !== "win32") return undefined;
|
||||
const r = spawnSync("reg.exe", args, {
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
// A registry read is instant; a hang means something is badly wrong and a scan must not
|
||||
// block on it forever.
|
||||
timeout: 10_000,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
});
|
||||
if (r.status !== 0 || typeof r.stdout !== "string") return undefined;
|
||||
return r.stdout;
|
||||
};
|
||||
|
||||
/**
|
||||
* The values directly under one HKLM key. `[]` when the key is absent, unreadable, or this is not
|
||||
* Windows — a missing launcher is the normal case, never an error.
|
||||
*/
|
||||
export const regQueryValues = (key: string): RegValue[] => {
|
||||
if (!validRegKey(key)) return [];
|
||||
const out = run(["query", key]);
|
||||
if (out === undefined) return [];
|
||||
return parseRegQuery(out);
|
||||
};
|
||||
|
||||
/** One named value under an HKLM key, or `undefined`. */
|
||||
export const regQueryValue = (key: string, name: string): string | undefined =>
|
||||
regQueryValues(key).find((v) => v.name.toLowerCase() === name.toLowerCase())
|
||||
?.data;
|
||||
|
||||
/** The immediate SUBKEY paths under one HKLM key (GOG lists one subkey per installed game). */
|
||||
export const regSubKeys = (key: string): string[] => {
|
||||
if (!validRegKey(key)) return [];
|
||||
const out = run(["query", key]);
|
||||
if (out === undefined) return [];
|
||||
const prefix = `${key.toLowerCase()}\\`;
|
||||
return out
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.toLowerCase().startsWith(prefix))
|
||||
.filter((l) => !l.slice(key.length + 1).includes("\\"));
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse `reg.exe query` output rows: ` <name> <TYPE> <data>`, separated by runs of
|
||||
* whitespace. Data may itself contain spaces (a path), so only the first two columns are split off.
|
||||
*
|
||||
* Exported for tests — the format is stable but this is exactly the kind of thing that quietly
|
||||
* breaks, and a plugin's tests can pin it without a Windows box.
|
||||
*/
|
||||
export const parseRegQuery = (stdout: string): RegValue[] => {
|
||||
const out: RegValue[] = [];
|
||||
for (const raw of stdout.split(/\r?\n/)) {
|
||||
// Value rows are indented; the key path header is not.
|
||||
if (!/^\s/.test(raw)) continue;
|
||||
const line = raw.trim();
|
||||
if (line === "") continue;
|
||||
const m = line.match(/^(.*?)\s{2,}(REG_[A-Z_]+)\s{2,}([\s\S]*)$/);
|
||||
if (!m) continue;
|
||||
out.push({ name: m[1], type: m[2], data: m[3] });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
// Steam's BINARY `shortcuts.vdf` — the user's "Add a Non-Steam Game to My Library" entries.
|
||||
//
|
||||
// Ported from the host's in-tree scanner (crates/punktfunk-host/src/library/steam.rs), together
|
||||
// with its unit tests, which are the real specification here: the format is undocumented, and the
|
||||
// two id derivations below (`shortcutAppId`, `shortcutGameId`) are the difference between a
|
||||
// shortcut that launches and one that silently does nothing.
|
||||
//
|
||||
// Format: a 1-byte type tag (`0x00` nested map, `0x01` string, `0x02` int32, `0x07` uint64), a
|
||||
// NUL-terminated key, then a type-specific payload; `0x08` closes the current map. The whole file is
|
||||
// one `shortcuts` map whose children (keyed "0", "1", …) are the individual shortcuts.
|
||||
//
|
||||
// Lenient and total by design: a truncated file or an unrecognized tag stops the walk and returns
|
||||
// whatever parsed so far. A user's shortcuts file is not something to be strict about.
|
||||
|
||||
export interface Shortcut {
|
||||
/** The 32-bit shortcut appid — always high-bit set. Keys the entry id and its `grid/` art. */
|
||||
readonly appid: number;
|
||||
readonly name: string;
|
||||
/** The shortcut's target, as Steam stores it (quoted, possibly with trailing arguments). */
|
||||
readonly exe: string;
|
||||
readonly hidden: boolean;
|
||||
}
|
||||
|
||||
/** A cursor over the buffer — the ported code's `pos` threaded explicitly. */
|
||||
interface Cursor {
|
||||
pos: number;
|
||||
}
|
||||
|
||||
/** Read a NUL-terminated UTF-8 string, advancing past the terminator. `undefined` if unterminated. */
|
||||
const readCStr = (buf: Uint8Array, c: Cursor): string | undefined => {
|
||||
const start = c.pos;
|
||||
let end = start;
|
||||
while (end < buf.length && buf[end] !== 0) end++;
|
||||
if (end >= buf.length) return undefined;
|
||||
const s = new TextDecoder("utf-8").decode(buf.subarray(start, end));
|
||||
c.pos = end + 1;
|
||||
return s;
|
||||
};
|
||||
|
||||
/** Read a little-endian int32, advancing 4 bytes. `undefined` if fewer than 4 remain. */
|
||||
const readI32 = (buf: Uint8Array, c: Cursor): number | undefined => {
|
||||
if (c.pos + 4 > buf.length) return undefined;
|
||||
const v = new DataView(buf.buffer, buf.byteOffset + c.pos, 4).getInt32(0, true);
|
||||
c.pos += 4;
|
||||
return v;
|
||||
};
|
||||
|
||||
/** Skip a nested map's contents (positioned just after its key) up to and including its `0x08`. */
|
||||
const skipMap = (buf: Uint8Array, c: Cursor): boolean => {
|
||||
for (;;) {
|
||||
if (c.pos >= buf.length) return false;
|
||||
const tag = buf[c.pos];
|
||||
c.pos += 1;
|
||||
if (tag === 0x08) return true;
|
||||
if (readCStr(buf, c) === undefined) return false;
|
||||
if (tag === 0x00) {
|
||||
if (!skipMap(buf, c)) return false;
|
||||
} else if (tag === 0x01) {
|
||||
if (readCStr(buf, c) === undefined) return false;
|
||||
} else if (tag === 0x02) {
|
||||
c.pos += 4;
|
||||
} else if (tag === 0x07) {
|
||||
c.pos += 8;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Parse one shortcut's fields (positioned just after its index key) up to the map-closing `0x08`. */
|
||||
const parseOne = (buf: Uint8Array, c: Cursor): Shortcut | undefined => {
|
||||
let appid: number | undefined;
|
||||
let name = "";
|
||||
let exe = "";
|
||||
let hidden = false;
|
||||
for (;;) {
|
||||
if (c.pos >= buf.length) return undefined;
|
||||
const tag = buf[c.pos];
|
||||
c.pos += 1;
|
||||
if (tag === 0x08) break;
|
||||
const key = readCStr(buf, c)?.toLowerCase();
|
||||
if (key === undefined) return undefined;
|
||||
if (tag === 0x00) {
|
||||
if (!skipMap(buf, c)) return undefined; // nested map (e.g. `tags`) — not needed
|
||||
} else if (tag === 0x01) {
|
||||
const val = readCStr(buf, c);
|
||||
if (val === undefined) return undefined;
|
||||
if (key === "appname") name = val;
|
||||
else if (key === "exe") exe = val;
|
||||
} else if (tag === 0x02) {
|
||||
const val = readI32(buf, c);
|
||||
if (val === undefined) return undefined;
|
||||
if (key === "appid") appid = val >>> 0;
|
||||
else if (key === "ishidden") hidden = val !== 0;
|
||||
} else if (tag === 0x07) {
|
||||
c.pos += 8; // uint64 — skip
|
||||
} else {
|
||||
return undefined; // unknown tag: payload size unknown, can't continue safely
|
||||
}
|
||||
}
|
||||
if (name.trim() === "") return undefined; // nothing worth showing
|
||||
// Prefer the stored appid; fall back to Steam's derivation when it's absent (0 / missing).
|
||||
const id = appid && appid !== 0 ? appid : shortcutAppId(exe, name);
|
||||
return { appid: id, name, exe, hidden };
|
||||
};
|
||||
|
||||
/** Parse a binary `shortcuts.vdf` into its shortcuts. Never throws. */
|
||||
export const parseShortcuts = (buf: Uint8Array): Shortcut[] => {
|
||||
const out: Shortcut[] = [];
|
||||
const c: Cursor = { pos: 0 };
|
||||
// Enter the top-level map (`<0x00> "shortcuts" <NUL>`); tolerate any key name.
|
||||
if (buf[0] !== 0x00) return out;
|
||||
c.pos = 1;
|
||||
if (readCStr(buf, c) === undefined) return out;
|
||||
while (c.pos < buf.length) {
|
||||
const tag = buf[c.pos];
|
||||
c.pos += 1;
|
||||
if (tag !== 0x00) break; // `0x08` (end of shortcuts) or anything unexpected
|
||||
if (readCStr(buf, c) === undefined) break; // the index key ("0", "1", …)
|
||||
const sc = parseOne(buf, c);
|
||||
if (!sc) break;
|
||||
out.push(sc);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Standard reflected (IEEE) CRC-32 — what Steam hashes a shortcut's `exe + name` with. */
|
||||
export const crc32 = (data: Uint8Array): number => {
|
||||
let crc = 0xffff_ffff;
|
||||
for (const byte of data) {
|
||||
crc ^= byte;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const mask = -(crc & 1);
|
||||
crc = (crc >>> 1) ^ (0xedb8_8320 & mask);
|
||||
}
|
||||
}
|
||||
return (~crc) >>> 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* The 32-bit appid Steam derives for a shortcut from its target+name — `crc32(exe + name)` with the
|
||||
* high bit set. Only used when `shortcuts.vdf` omits the stored `appid` (very old Steam); modern
|
||||
* Steam writes it and the stored value is preferred.
|
||||
*
|
||||
* The high bit is load-bearing downstream: it is how a shortcut is told apart from a real store
|
||||
* appid, which is what makes the CDN art fetch skippable for shortcuts (they only ever have `grid/`
|
||||
* overrides).
|
||||
*/
|
||||
export const shortcutAppId = (exe: string, name: string): number =>
|
||||
(crc32(new TextEncoder().encode(exe + name)) | 0x8000_0000) >>> 0;
|
||||
|
||||
/**
|
||||
* The 64-bit game id `steam://rungameid/` needs in order to launch a non-Steam shortcut: high dword
|
||||
* = the 32-bit shortcut appid, low dword = the shortcut marker `0x02000000`.
|
||||
*
|
||||
* Handing `rungameid` the bare 32-bit appid does NOT launch a shortcut — it must be this composed
|
||||
* id. Returned as a decimal string because it exceeds 2^53 and would lose precision as a `number`.
|
||||
*/
|
||||
export const shortcutGameId = (appid: number): string =>
|
||||
((BigInt(appid >>> 0) << 32n) | 0x0200_0000n).toString();
|
||||
@@ -0,0 +1,68 @@
|
||||
// Read-only SQLite over `bun:sqlite` — for launcher databases a plugin must never disturb.
|
||||
//
|
||||
// Lutris' `pga.db` is the motivating case: it belongs to a running application, and a scanner that
|
||||
// opened it read-write could take a write lock, create `-wal`/`-shm` sidecars next to it, or (worst
|
||||
// case) be blamed for a corrupted library. `immutable=1` promises the file will not change while
|
||||
// open, which makes Bun skip locking entirely — the strictest possible "look, don't touch".
|
||||
import { Database } from "bun:sqlite";
|
||||
import { isFile } from "./fs.js";
|
||||
|
||||
export interface ReadOnlyDb {
|
||||
/** Run a query and return its rows. Returns `[]` rather than throwing on a bad query. */
|
||||
readonly query: <T = Record<string, unknown>>(
|
||||
sql: string,
|
||||
...params: unknown[]
|
||||
) => T[];
|
||||
readonly close: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a launcher database read-only and immutably. `undefined` if the file is absent or not a
|
||||
* database — the normal "this launcher isn't installed" case, not an error.
|
||||
*
|
||||
* Always `close()` when done (or use {@link withReadOnlyDb}, which does it for you).
|
||||
*/
|
||||
export const openReadOnly = (file: string): ReadOnlyDb | undefined => {
|
||||
if (!isFile(file)) return undefined;
|
||||
let db: Database;
|
||||
try {
|
||||
// `readonly` alone still takes locks and can spawn WAL sidecars; `immutable=1` is what makes
|
||||
// this a pure read. It is safe here precisely because a scan is a point-in-time snapshot —
|
||||
// if the launcher writes mid-scan we simply pick it up on the next sync.
|
||||
db = new Database(`file:${encodeURI(file)}?immutable=1`, { readonly: true });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
query: <T = Record<string, unknown>>(sql: string, ...params: unknown[]) => {
|
||||
try {
|
||||
return db.query(sql).all(...(params as never[])) as T[];
|
||||
} catch {
|
||||
// A schema drift (a renamed column in a launcher upgrade) must degrade to "no
|
||||
// titles from this source", never take the whole plugin down.
|
||||
return [] as T[];
|
||||
}
|
||||
},
|
||||
close: () => {
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Open, use, and always close. Returns `undefined` when the database isn't there. */
|
||||
export const withReadOnlyDb = <T>(
|
||||
file: string,
|
||||
use: (db: ReadOnlyDb) => T,
|
||||
): T | undefined => {
|
||||
const db = openReadOnly(file);
|
||||
if (!db) return undefined;
|
||||
try {
|
||||
return use(db);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
// Where Steam lives on this host, and which `steamapps` dirs hold installed titles.
|
||||
//
|
||||
// Ported from the host scanner (steam.rs `steam_roots` / `steam_library_dirs`) with one deliberate
|
||||
// addition and one deliberate exclusion, both about the Windows runner's account:
|
||||
//
|
||||
// * ADDED: HKLM `WOW6432Node\Valve\Steam\InstallPath`, so a non-default Steam install dir is
|
||||
// found. The host scanner never covered this (it relied on an explorer.exe protocol fallback at
|
||||
// launch time), but a plugin that can't find the root finds no games at all.
|
||||
// * EXCLUDED: HKCU `Software\Valve\Steam`. The runner is LocalService, whose HKCU is its own empty
|
||||
// hive, not the operator's — reading it would look like "Steam isn't installed".
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { isDir, listDir, readTextCapped } from "./fs.js";
|
||||
import { regQueryValue } from "./registry.js";
|
||||
import { vdfPaths } from "./vdf.js";
|
||||
|
||||
/** Canonicalize-ish: resolve and drop a trailing separator so dedup is reliable. */
|
||||
const norm = (p: string): string => path.resolve(p);
|
||||
|
||||
/**
|
||||
* Candidate Steam roots that actually exist (have a `steamapps` dir), deduped.
|
||||
*
|
||||
* A "root" is the Steam install itself — `userdata/`, `appcache/` and the first `steamapps/` live
|
||||
* under it. Extra library folders on other drives are NOT roots; see {@link steamLibraryDirs}.
|
||||
*/
|
||||
export const steamRoots = (): string[] => {
|
||||
const candidates: string[] = [];
|
||||
if (process.platform === "win32") {
|
||||
for (const v of ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"]) {
|
||||
const pf = process.env[v];
|
||||
if (pf) candidates.push(path.join(pf, "Steam"));
|
||||
}
|
||||
// The registry install path — covers a Steam installed somewhere other than Program Files.
|
||||
for (const key of [
|
||||
"HKLM\\SOFTWARE\\WOW6432Node\\Valve\\Steam",
|
||||
"HKLM\\SOFTWARE\\Valve\\Steam",
|
||||
]) {
|
||||
const p = regQueryValue(key, "InstallPath");
|
||||
if (p) candidates.push(p);
|
||||
}
|
||||
} else {
|
||||
const home = os.homedir();
|
||||
if (home) {
|
||||
candidates.push(
|
||||
path.join(home, ".local/share/Steam"),
|
||||
path.join(home, ".steam/steam"),
|
||||
path.join(home, ".steam/root"),
|
||||
// Flatpak Steam
|
||||
path.join(home, ".var/app/com.valvesoftware.Steam/.local/share/Steam"),
|
||||
);
|
||||
}
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const roots: string[] = [];
|
||||
for (const c of candidates) {
|
||||
const n = norm(c);
|
||||
if (!seen.has(n) && isDir(path.join(n, "steamapps"))) {
|
||||
seen.add(n);
|
||||
roots.push(n);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every `steamapps` dir holding installed titles: each root's own, plus the extra library folders
|
||||
* listed in its `libraryfolders.vdf` (Steam installs to other drives).
|
||||
*/
|
||||
export const steamLibraryDirs = (roots = steamRoots()): string[] => {
|
||||
const seen = new Set<string>();
|
||||
const dirs: string[] = [];
|
||||
const push = (p: string) => {
|
||||
const n = norm(p);
|
||||
if (!seen.has(n) && isDir(n)) {
|
||||
seen.add(n);
|
||||
dirs.push(n);
|
||||
}
|
||||
};
|
||||
for (const root of roots) {
|
||||
const steamapps = path.join(root, "steamapps");
|
||||
const text = readTextCapped(path.join(steamapps, "libraryfolders.vdf"));
|
||||
if (text !== undefined) {
|
||||
for (const p of vdfPaths(text)) push(path.join(p, "steamapps"));
|
||||
}
|
||||
push(steamapps);
|
||||
}
|
||||
return dirs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every `userdata/<accountId>/config` dir across all roots — one per Steam account that has signed
|
||||
* in on this host. `shortcuts.vdf` and the `grid/` art overrides live here.
|
||||
*/
|
||||
export const steamUserConfigDirs = (roots = steamRoots()): string[] => {
|
||||
const out: string[] = [];
|
||||
for (const root of roots) {
|
||||
const userdata = path.join(root, "userdata");
|
||||
for (const acct of listDir(userdata)) {
|
||||
const cfg = path.join(userdata, acct, "config");
|
||||
if (isDir(cfg)) out.push(cfg);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
// Valve Data Format (text) — the flat-field reader Steam's `libraryfolders.vdf` and
|
||||
// `appmanifest_<appid>.acf` need, ported from the host's in-tree scanner
|
||||
// (crates/punktfunk-host/src/library/steam.rs `vdf_value` / `vdf_paths` / `scan_manifests`).
|
||||
//
|
||||
// Deliberately NOT a full VDF parser. Every field these files expose that a library plugin cares
|
||||
// about sits on one line as `"key" "value"`, and a real parser would be a much larger surface to
|
||||
// keep correct against a format Valve changes without notice. If you need nested values, read the
|
||||
// file yourself — this is the 90% case, kept small enough to be obviously right.
|
||||
|
||||
/** `"<key>" "<value>"` on a single line → `<value>`. Whitespace between the two is arbitrary. */
|
||||
export const vdfValue = (line: string, key: string): string | undefined => {
|
||||
const rest = line.trimStart();
|
||||
const prefix = `"${key}"`;
|
||||
if (!rest.startsWith(prefix)) return undefined;
|
||||
const after = rest.slice(prefix.length);
|
||||
const open = after.indexOf('"');
|
||||
if (open === -1) return undefined;
|
||||
const value = after.slice(open + 1);
|
||||
const close = value.indexOf('"');
|
||||
if (close === -1) return undefined;
|
||||
return value.slice(0, close);
|
||||
};
|
||||
|
||||
/** The first `"<key>" "<value>"` anywhere in a multi-line document. */
|
||||
export const vdfField = (text: string, key: string): string | undefined => {
|
||||
for (const line of text.split("\n")) {
|
||||
const v = vdfValue(line, key);
|
||||
if (v !== undefined) return v;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every `"path" "<dir>"` value in a `libraryfolders.vdf` — the extra drives Steam installs to.
|
||||
*
|
||||
* On Windows the values are backslash-escaped (`D:\\SteamLibrary`), so `\\` collapses to `\`. POSIX
|
||||
* paths need no unescaping, and the collapse is harmless there (a literal `\\` in a Linux path is
|
||||
* vanishingly rare and was already ambiguous).
|
||||
*/
|
||||
export const vdfPaths = (text: string): string[] =>
|
||||
text
|
||||
.split("\n")
|
||||
.map((l) => vdfValue(l, "path"))
|
||||
.filter((p): p is string => p !== undefined)
|
||||
.map((p) => p.replaceAll("\\\\", "\\"));
|
||||
|
||||
/** One installed title as described by its `appmanifest_<appid>.acf`. */
|
||||
export interface AppManifest {
|
||||
readonly appid: number;
|
||||
readonly name: string;
|
||||
/** The bare folder name under this library's `common/` — resolve it yourself. */
|
||||
readonly installdir?: string;
|
||||
}
|
||||
|
||||
/** Parse an `.acf` manifest's flat fields. `undefined` when it carries no usable appid+name. */
|
||||
export const parseAppManifest = (text: string): AppManifest | undefined => {
|
||||
const appid = Number(vdfField(text, "appid"));
|
||||
const name = vdfField(text, "name");
|
||||
if (!Number.isInteger(appid) || appid <= 0 || !name) return undefined;
|
||||
const installdir = vdfField(text, "installdir");
|
||||
return installdir ? { appid, name, installdir } : { appid, name };
|
||||
};
|
||||
|
||||
/**
|
||||
* Steam installs runtimes and redistributables as "apps" too. A *game* library must not list them.
|
||||
* Ported verbatim from the host scanner so an extracted steam plugin filters identically — the
|
||||
* parity harness compares entry sets, and a stray Proton row would fail it.
|
||||
*/
|
||||
export const isSteamTool = (appid: number, name: string): boolean => {
|
||||
// Steamworks Common Redistributables; Steam Linux Runtime 1.0/2.0/3.0 (Sniper/Soldier).
|
||||
const TOOL_IDS = [228980, 1070560, 1391110, 1628350, 1493710];
|
||||
if (TOOL_IDS.includes(appid)) return true;
|
||||
const n = name.toLowerCase();
|
||||
return (
|
||||
n.includes("proton") ||
|
||||
n.startsWith("steam linux runtime") ||
|
||||
n.includes("steamworks common") ||
|
||||
n.includes("steamvr")
|
||||
);
|
||||
};
|
||||
+130
-3
@@ -3,8 +3,9 @@
|
||||
// register/renew/deregister through Scope. Validated end-to-end by the phase-0 spike:
|
||||
// core-only env layers, no platform package, SPA fallthrough preserved.
|
||||
import { type PluginUiHandle, servePluginUi } from "@punktfunk/host";
|
||||
import { Effect, FileSystem, Layer, Path, Scope } from "effect";
|
||||
import { Effect, FileSystem, Layer, Path, Schema, Scope } from "effect";
|
||||
import { Etag, HttpPlatform, HttpRouter } from "effect/unstable/http";
|
||||
import type { ConfigService } from "./config.js";
|
||||
import { UiServeError } from "./errors.js";
|
||||
import { HostClient, PluginInfo } from "./host-client.js";
|
||||
|
||||
@@ -17,6 +18,100 @@ export const httpApiEnv = Layer.provideMerge(
|
||||
FileSystem.layerNoop({}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Derive a JSON Schema for a config schema, for the console's generic settings form.
|
||||
*
|
||||
* Returns `null` when derivation isn't possible, which the console reads as "render the raw JSON
|
||||
* editor instead" — the fallback that bounds this whole feature's risk.
|
||||
*
|
||||
* Authoring rules, verified against effect 4.0.0-beta.99 and pinned by
|
||||
* `test/library-config.test.ts` — if an effect upgrade changes any of them, that test fails:
|
||||
*
|
||||
* * Use `Schema.Finite` / `Schema.Int`, **never `Schema.Number`** — Number's *encoded* form admits
|
||||
* the strings `"NaN"`/`"Infinity"`/`"-Infinity"`, so it derives a four-way `anyOf` that no sane
|
||||
* form can render as a number input.
|
||||
* * A decoding default is an **Effect**: `withDecodingDefaultKey(Effect.succeed(true), …)`. Passing
|
||||
* a bare thunk (`() => true`) still derives a schema and still type-checks, then dies at DECODE
|
||||
* time with "Not a valid effect" — deriving is not evidence that the schema works.
|
||||
* * Annotate every field: `.annotate({ title, description, default })`. The derivation does NOT
|
||||
* infer `default` from `withDecodingDefaultKey`, so an un-annotated field shows no placeholder.
|
||||
* * A *checked* schema (`Schema.Int`, or anything with `.check(...)`) nests its annotations and
|
||||
* constraints under `allOf`, so a form must merge those branches, not read only the top level.
|
||||
* * `Schema.Literals([...])` derives a clean `enum` — prefer it over a union of strings. A union of
|
||||
* non-literals derives an `anyOf`, which is the JSON-editor fallback case.
|
||||
* * Fields carrying `withDecodingDefaultKey(..., { encodingStrategy: "omit" })` correctly drop out
|
||||
* of `required`, which is what keeps the raw file free of baked-in defaults.
|
||||
*/
|
||||
export const deriveConfigJsonSchema = (
|
||||
schema: Schema.Top,
|
||||
): Record<string, unknown> | null => {
|
||||
try {
|
||||
const doc = Schema.toJsonSchemaDocument(schema as never);
|
||||
return doc as unknown as Record<string, unknown>;
|
||||
} catch {
|
||||
// A schema shape the derivation can't express (a transform, a recursive ref). The console
|
||||
// falls back to the JSON editor; the PUT still validates by decode, so nothing is lost but
|
||||
// the pretty form.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** The plugin config surface the console's settings drawer drives. */
|
||||
export interface ServeUiConfig<S extends Schema.Top> {
|
||||
/** The schema the raw file is validated against, and the form is derived from. */
|
||||
readonly schema: S;
|
||||
/** The config service (from `makeConfigService`) holding the raw round-trip semantics. */
|
||||
readonly service: ConfigService<S>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `/__config` request handler, split out so it can be driven directly in tests (the wire shape
|
||||
* is the contract the console's settings drawer codes against — it deserves a real round-trip test,
|
||||
* not a mock).
|
||||
*
|
||||
* `ConfigService`'s effects are context-free by construction (the `PluginInfo` was resolved when the
|
||||
* service was built), so this runs them straight from a plain async handler.
|
||||
*/
|
||||
export const makeConfigHandler = <S extends Schema.Top>(
|
||||
cfg: ServeUiConfig<S>,
|
||||
): ((req: Request) => Promise<Response>) => {
|
||||
// The derivation is stable for the life of the process — do it once, not per request.
|
||||
const schema = deriveConfigJsonSchema(cfg.schema);
|
||||
return async (req: Request): Promise<Response> => {
|
||||
if (req.method === "GET") {
|
||||
// A config file that fails to decode must not blank the whole drawer — answer with a
|
||||
// null value so the operator can still see (and replace) what is on disk.
|
||||
const value = await Effect.runPromise(cfg.service.loadRaw).catch(
|
||||
() => null,
|
||||
);
|
||||
return Response.json({ schema, value });
|
||||
}
|
||||
if (req.method === "PUT") {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch (cause) {
|
||||
return Response.json(
|
||||
{ error: "body must be JSON", issue: String(cause) },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
// Validate-by-decode, persist RAW: `saveRaw` refuses a body the schema rejects and
|
||||
// never writes decoded defaults back into the operator's file.
|
||||
await Effect.runPromise(cfg.service.saveRaw(body));
|
||||
return Response.json({ ok: true });
|
||||
} catch (cause) {
|
||||
return Response.json(
|
||||
{ error: "config rejected", issue: String(cause) },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
return new Response("method not allowed", { status: 405 });
|
||||
};
|
||||
};
|
||||
|
||||
export interface ServeUiOptions {
|
||||
/** Console nav title. */
|
||||
readonly title: string;
|
||||
@@ -26,12 +121,33 @@ export interface ServeUiOptions {
|
||||
readonly version?: string;
|
||||
/** Built SPA directory (served with SPA fallback by the SDK). */
|
||||
readonly staticDir?: string | URL;
|
||||
/**
|
||||
* What kind of plugin this is (`[a-z][a-z0-9-]{0,31}`). `"library"` keeps the plugin out of the
|
||||
* console nav — its entry point is the Library section's Game sources surface instead.
|
||||
*/
|
||||
readonly category?: string;
|
||||
/**
|
||||
* Serve `GET`/`PUT /__config` for the console's **generic settings form**, so a plugin with
|
||||
* settings does not need to ship an SPA at all.
|
||||
*
|
||||
* `GET` answers `{schema, value}` — the derived JSON Schema (or `null`) and the raw,
|
||||
* operator-authored config. `PUT` validates by decoding the body against the schema and, only
|
||||
* then, persists it **raw**; defaults are never baked into the file. A rejected body comes back
|
||||
* 400 with the decode issue.
|
||||
*
|
||||
* Auth is the existing per-boot UI secret — the console reaches this through its session-gated
|
||||
* `/plugin-ui/<id>/…` proxy, so there is no new host surface and nothing new exposed to the LAN.
|
||||
*/
|
||||
readonly config?: ServeUiConfig<Schema.Top>;
|
||||
/**
|
||||
* The plugin API: `HttpApiBuilder.layer(api)` + group handler layers + raw routes
|
||||
* (e.g. `sseRoute`), with plugin services already provided. `httpApiEnv` is provided
|
||||
* here — only `HttpRouter` may remain open.
|
||||
*
|
||||
* Optional: a plugin whose only surface is `__config` (every library scanner) serves no API of
|
||||
* its own, and omitting this leaves an empty router that 404s under `apiPrefix`.
|
||||
*/
|
||||
readonly api: Layer.Layer<never, never, HttpRouter.HttpRouter>;
|
||||
readonly api?: Layer.Layer<never, never, HttpRouter.HttpRouter>;
|
||||
/** Path prefix owned by the API handler (default "/api/"). */
|
||||
readonly apiPrefix?: string;
|
||||
}
|
||||
@@ -54,14 +170,22 @@ export const serveUi = (
|
||||
const prefix = opts.apiPrefix ?? "/api/";
|
||||
|
||||
const { handler, dispose } = HttpRouter.toWebHandler(
|
||||
Layer.provide(opts.api, httpApiEnv),
|
||||
Layer.provide(opts.api ?? Layer.empty, httpApiEnv),
|
||||
);
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => dispose()).pipe(Effect.ignore),
|
||||
);
|
||||
|
||||
const serveConfig = opts.config ? makeConfigHandler(opts.config) : undefined;
|
||||
|
||||
const fetch = async (req: Request): Promise<Response | undefined> => {
|
||||
const url = new URL(req.url);
|
||||
// `__`-prefixed paths are the kit/SDK's own contract surface (`__health` lives in the
|
||||
// SDK), deliberately checked BEFORE the API prefix and before any static asset so a
|
||||
// plugin's own routes can never shadow them.
|
||||
if (url.pathname === "/__config") {
|
||||
return serveConfig?.(req) ?? new Response("not found", { status: 404 });
|
||||
}
|
||||
if (!url.pathname.startsWith(prefix)) return undefined; // → static SPA
|
||||
return handler(req);
|
||||
};
|
||||
@@ -79,6 +203,9 @@ export const serveUi = (
|
||||
...(opts.staticDir !== undefined
|
||||
? { staticDir: opts.staticDir }
|
||||
: {}),
|
||||
...(opts.category !== undefined
|
||||
? { category: opts.category }
|
||||
: {}),
|
||||
fetch,
|
||||
}),
|
||||
catch: (cause) => new UiServeError({ cause }),
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
// The `__config` contract — the wire shape the console's generic settings drawer codes against,
|
||||
// plus the JSON-Schema derivation's committed fixture (design M0/S2).
|
||||
//
|
||||
// The derivation fixture is not decoration: it is the record of WHICH schema shapes the generic
|
||||
// form can render. If an effect upgrade changes any of it, this test fails and the console's form
|
||||
// needs re-checking before the change ships — far cheaper than discovering it on a user's box.
|
||||
import { 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 { Effect, Layer, Schema } from "effect";
|
||||
import { makeConfigService } from "../src/config.js";
|
||||
import { pluginInfoLayer } from "../src/host-client.js";
|
||||
import { deriveConfigJsonSchema, makeConfigHandler } from "../src/ui-server.js";
|
||||
|
||||
/** A representative scanner config: booleans, a string, a string array, a nested object, an enum. */
|
||||
const ScannerConfig = Schema.Struct({
|
||||
enabled: Schema.Boolean.annotate({
|
||||
title: "Enable scanning",
|
||||
description: "Whether this source contributes titles.",
|
||||
default: true,
|
||||
}).pipe(
|
||||
Schema.withDecodingDefaultKey(Effect.succeed(true), {
|
||||
encodingStrategy: "omit",
|
||||
}),
|
||||
),
|
||||
root: Schema.optionalKey(
|
||||
Schema.String.annotate({ title: "Launcher root", description: "Absolute path." }),
|
||||
),
|
||||
extraRoots: Schema.Array(Schema.String)
|
||||
.annotate({ title: "Extra roots" })
|
||||
.pipe(
|
||||
Schema.withDecodingDefaultKey(
|
||||
Effect.succeed([] as ReadonlyArray<string>),
|
||||
{ encodingStrategy: "omit" },
|
||||
),
|
||||
),
|
||||
launchers: Schema.Struct({
|
||||
bigpicture: Schema.Boolean.annotate({ title: "Big Picture", default: true }),
|
||||
desktop: Schema.Boolean.annotate({ title: "Desktop", default: false }),
|
||||
}).pipe(
|
||||
Schema.withDecodingDefaultKey(
|
||||
Effect.succeed({ bigpicture: true, desktop: false }),
|
||||
{ encodingStrategy: "omit" },
|
||||
),
|
||||
),
|
||||
pollMinutes: Schema.Int.annotate({
|
||||
title: "Poll interval (minutes)",
|
||||
default: 15,
|
||||
}).pipe(
|
||||
Schema.withDecodingDefaultKey(Effect.succeed(15), {
|
||||
encodingStrategy: "omit",
|
||||
}),
|
||||
),
|
||||
artSource: Schema.Literals(["local", "cdn", "both"])
|
||||
.annotate({ title: "Art source", default: "both" })
|
||||
.pipe(
|
||||
Schema.withDecodingDefaultKey(Effect.succeed("both" as const), {
|
||||
encodingStrategy: "omit",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const props = (): Record<string, Record<string, unknown>> => {
|
||||
const doc = deriveConfigJsonSchema(ScannerConfig) as {
|
||||
schema: { properties: Record<string, Record<string, unknown>> };
|
||||
};
|
||||
return doc.schema.properties;
|
||||
};
|
||||
|
||||
describe("S2 — JSON Schema derivation for __config", () => {
|
||||
test("derives a renderable form for every shape a scanner config uses", () => {
|
||||
const p = props();
|
||||
expect(p.enabled).toMatchObject({ type: "boolean" });
|
||||
expect(p.root).toMatchObject({ type: "string" });
|
||||
expect(p.extraRoots).toMatchObject({
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
});
|
||||
// A nested object stays nested — the form renders a fieldset, not a JSON blob.
|
||||
expect(p.launchers).toMatchObject({
|
||||
type: "object",
|
||||
properties: { bigpicture: { type: "boolean" }, desktop: { type: "boolean" } },
|
||||
});
|
||||
// A literal union derives a clean enum — prefer it over a union of strings.
|
||||
expect(p.artSource).toMatchObject({
|
||||
type: "string",
|
||||
enum: ["local", "cdn", "both"],
|
||||
});
|
||||
});
|
||||
|
||||
test("annotations pass through — they are the ONLY source of labels and defaults", () => {
|
||||
const p = props();
|
||||
expect(p.enabled.title).toBe("Enable scanning");
|
||||
expect(p.enabled.description).toBe("Whether this source contributes titles.");
|
||||
// The derivation does NOT infer `default` from withDecodingDefaultKey, so an un-annotated
|
||||
// field shows the form no placeholder at all. Annotate every field.
|
||||
expect(p.enabled.default).toBe(true);
|
||||
expect(p.artSource.default).toBe("both");
|
||||
// A CHECKED schema (Int is String-plus-a-check) nests its annotations under `allOf`, so a
|
||||
// form reading `default` must merge allOf branches rather than only looking at the top level.
|
||||
expect(p.pollMinutes.allOf).toEqual([
|
||||
{ default: 15, title: "Poll interval (minutes)" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a decoding default is an Effect, not a thunk — and it actually applies", () => {
|
||||
// The trap this pins: `withDecodingDefaultKey` takes an `Effect`, and passing a bare thunk
|
||||
// (`() => true`) type-checks against the derivation path but blows up at DECODE time with
|
||||
// "Not a valid effect". Deriving a schema is therefore NOT evidence that it works.
|
||||
expect(Schema.decodeUnknownSync(ScannerConfig)({})).toMatchObject({
|
||||
enabled: true,
|
||||
pollMinutes: 15,
|
||||
artSource: "both",
|
||||
launchers: { bigpicture: true, desktop: false },
|
||||
});
|
||||
});
|
||||
|
||||
test("Schema.Int derives a plain integer — Schema.Number does NOT", () => {
|
||||
expect(props().pollMinutes).toMatchObject({ type: "integer" });
|
||||
// The trap, pinned: Schema.Number's ENCODED form admits "NaN"/"Infinity"/"-Infinity", so it
|
||||
// derives a four-way anyOf that no number input can render. Use Finite or Int.
|
||||
const bad = deriveConfigJsonSchema(
|
||||
Schema.Struct({ n: Schema.Number }),
|
||||
) as { schema: { properties: { n: { anyOf?: unknown[] } } } };
|
||||
expect(Array.isArray(bad.schema.properties.n.anyOf)).toBe(true);
|
||||
const ok = deriveConfigJsonSchema(
|
||||
Schema.Struct({ n: Schema.Finite }),
|
||||
) as { schema: { properties: { n: { type?: string } } } };
|
||||
expect(ok.schema.properties.n.type).toBe("number");
|
||||
});
|
||||
|
||||
test("defaulted fields drop out of `required` — the raw file stays default-free", () => {
|
||||
const doc = deriveConfigJsonSchema(ScannerConfig) as {
|
||||
schema: { required?: string[] };
|
||||
};
|
||||
// Every field here either has a decoding default or is optionalKey, so nothing is required.
|
||||
expect(doc.schema.required ?? []).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("__config wire contract", () => {
|
||||
const withService = async <A>(
|
||||
use: (handler: (req: Request) => Promise<Response>, file: string) => Promise<A>,
|
||||
): Promise<A> => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-kit-cfg-"));
|
||||
const prev = process.env.PUNKTFUNK_CONFIG_DIR;
|
||||
process.env.PUNKTFUNK_CONFIG_DIR = dir;
|
||||
try {
|
||||
const service = await Effect.runPromise(
|
||||
makeConfigService({ schema: ScannerConfig }).pipe(
|
||||
Effect.provide(
|
||||
Layer.mergeAll(pluginInfoLayer({ name: "steam", version: "0.1.0" })),
|
||||
),
|
||||
),
|
||||
);
|
||||
return await use(
|
||||
makeConfigHandler({ schema: ScannerConfig, service }),
|
||||
service.path,
|
||||
);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
|
||||
else process.env.PUNKTFUNK_CONFIG_DIR = prev;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
test("GET answers {schema, value} with an absent file reading as empty", async () => {
|
||||
await withService(async (handler) => {
|
||||
const res = await handler(new Request("http://x/__config"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { schema: unknown; value: unknown };
|
||||
// Both keys are ALWAYS present and never `undefined` — the console decodes this shape,
|
||||
// and an omitted-vs-null field is the wire trap that bit the rom-manager 0.3.1 release.
|
||||
expect(body).toHaveProperty("schema");
|
||||
expect(body).toHaveProperty("value");
|
||||
expect(body.schema).not.toBeNull();
|
||||
// A missing config file is an EMPTY config, not an error.
|
||||
expect(body.value).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
test("PUT validates by decode, persists RAW, and never bakes in defaults", async () => {
|
||||
await withService(async (handler, file) => {
|
||||
const res = await handler(
|
||||
new Request("http://x/__config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
// The file holds exactly what was authored — the five defaulted fields are NOT written,
|
||||
// which is what keeps a future change to a default from being silently pinned.
|
||||
expect(JSON.parse(fs.readFileSync(file, "utf8"))).toEqual({
|
||||
enabled: false,
|
||||
});
|
||||
const get = (await (
|
||||
await handler(new Request("http://x/__config"))
|
||||
).json()) as { value: unknown };
|
||||
expect(get.value).toEqual({ enabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
test("PUT rejects a body the schema refuses, with the issue, and writes nothing", async () => {
|
||||
await withService(async (handler, file) => {
|
||||
const res = await handler(
|
||||
new Request("http://x/__config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled: "yes please" }),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string; issue: string };
|
||||
expect(body.error).toBe("config rejected");
|
||||
expect(body.issue.length).toBeGreaterThan(0);
|
||||
expect(fs.existsSync(file)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("PUT rejects a non-JSON body", async () => {
|
||||
await withService(async (handler) => {
|
||||
const res = await handler(
|
||||
new Request("http://x/__config", { method: "PUT", body: "not json" }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(((await res.json()) as { error: string }).error).toBe(
|
||||
"body must be JSON",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("other methods are refused", async () => {
|
||||
await withService(async (handler) => {
|
||||
const res = await handler(
|
||||
new Request("http://x/__config", { method: "DELETE" }),
|
||||
);
|
||||
expect(res.status).toBe(405);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
// The parser ports, tested against the SAME cases the host's Rust scanners pin.
|
||||
//
|
||||
// These are not "does TypeScript work" tests. The formats here are undocumented and the host's
|
||||
// versions are the reference implementation; a port that drifts produces a library that looks fine
|
||||
// and launches nothing. Where a Rust test exists, its assertions are carried over verbatim — the
|
||||
// per-plugin parity harness (design M5) then checks the whole pipeline against a live host, but
|
||||
// these catch a drift long before that.
|
||||
import { 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 {
|
||||
confinedJoin,
|
||||
crc32,
|
||||
findGridArtFile,
|
||||
findLocalArtFile,
|
||||
fileUrl,
|
||||
gridFilenames,
|
||||
isSteamTool,
|
||||
parseAppManifest,
|
||||
parseRegQuery,
|
||||
parseShortcuts,
|
||||
readTextCapped,
|
||||
shortcutAppId,
|
||||
shortcutGameId,
|
||||
steamCdnUrl,
|
||||
vdfPaths,
|
||||
vdfValue,
|
||||
} from "../src/library/parsers/index.js";
|
||||
|
||||
const tmp = (name: string): string => {
|
||||
const dir = path.join(os.tmpdir(), `pf-kit-${name}-${process.pid}`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
};
|
||||
|
||||
describe("text VDF / ACF", () => {
|
||||
test("vdfValue extracts a quoted field", () => {
|
||||
expect(vdfValue('"path"\t\t"/mnt/games/SteamLibrary"', "path")).toBe(
|
||||
"/mnt/games/SteamLibrary",
|
||||
);
|
||||
expect(vdfValue('"appid"\t\t"570"', "appid")).toBe("570");
|
||||
expect(vdfValue('"name"\t\t"Dota 2"', "name")).toBe("Dota 2");
|
||||
// Wrong key → nothing (a prefix match must not leak the neighbouring field).
|
||||
expect(vdfValue('"installdir"\t\t"x"', "appid")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("vdfPaths pulls every library folder and unescapes Windows separators", () => {
|
||||
const vdf = `
|
||||
"libraryfolders"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"path" "/home/u/.local/share/Steam"
|
||||
"label" ""
|
||||
}
|
||||
"1"
|
||||
{
|
||||
"path" "D:\\\\SteamLibrary"
|
||||
}
|
||||
}`;
|
||||
expect(vdfPaths(vdf)).toEqual([
|
||||
"/home/u/.local/share/Steam",
|
||||
"D:\\SteamLibrary",
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseAppManifest reads the flat fields it needs", () => {
|
||||
const acf = `"AppState"
|
||||
{
|
||||
"appid" "570"
|
||||
"name" "Dota 2"
|
||||
"installdir" "dota 2 beta"
|
||||
}`;
|
||||
expect(parseAppManifest(acf)).toEqual({
|
||||
appid: 570,
|
||||
name: "Dota 2",
|
||||
installdir: "dota 2 beta",
|
||||
});
|
||||
// A manifest missing the essentials is not a title.
|
||||
expect(parseAppManifest('"AppState" { "name" "x" }')).toBeUndefined();
|
||||
});
|
||||
|
||||
test("isSteamTool keeps runtimes out of a game library", () => {
|
||||
expect(isSteamTool(228980, "Steamworks Common Redistributables")).toBe(true);
|
||||
expect(isSteamTool(1628350, "Steam Linux Runtime 3.0 (sniper)")).toBe(true);
|
||||
expect(isSteamTool(999, "Proton 9.0")).toBe(true);
|
||||
expect(isSteamTool(999, "SteamVR")).toBe(true);
|
||||
expect(isSteamTool(570, "Dota 2")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("binary shortcuts.vdf", () => {
|
||||
/** Build a binary shortcuts.vdf the way Steam writes one. */
|
||||
const buildShortcuts = (
|
||||
entries: ReadonlyArray<{
|
||||
appid?: number;
|
||||
appname: string;
|
||||
exe: string;
|
||||
hidden?: boolean;
|
||||
}>,
|
||||
): Uint8Array => {
|
||||
const parts: number[] = [];
|
||||
const cstr = (s: string) => {
|
||||
for (const b of new TextEncoder().encode(s)) parts.push(b);
|
||||
parts.push(0);
|
||||
};
|
||||
const i32 = (v: number) => {
|
||||
parts.push(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff);
|
||||
};
|
||||
parts.push(0x00);
|
||||
cstr("shortcuts");
|
||||
entries.forEach((e, i) => {
|
||||
parts.push(0x00);
|
||||
cstr(String(i));
|
||||
if (e.appid !== undefined) {
|
||||
parts.push(0x02);
|
||||
cstr("appid");
|
||||
i32(e.appid);
|
||||
}
|
||||
parts.push(0x01);
|
||||
cstr("AppName");
|
||||
cstr(e.appname);
|
||||
parts.push(0x01);
|
||||
cstr("Exe");
|
||||
cstr(e.exe);
|
||||
parts.push(0x02);
|
||||
cstr("IsHidden");
|
||||
i32(e.hidden ? 1 : 0);
|
||||
// A nested map the parser must skip wholesale.
|
||||
parts.push(0x00);
|
||||
cstr("tags");
|
||||
parts.push(0x01);
|
||||
cstr("0");
|
||||
cstr("favourite");
|
||||
parts.push(0x08);
|
||||
parts.push(0x08); // end of this shortcut
|
||||
});
|
||||
parts.push(0x08); // end of shortcuts
|
||||
parts.push(0x08); // end of document
|
||||
return new Uint8Array(parts);
|
||||
};
|
||||
|
||||
test("parses entries, skips nested maps, and reads the hidden flag", () => {
|
||||
const buf = buildShortcuts([
|
||||
{ appid: 2456789012, appname: "My Emulator", exe: '"/usr/bin/foo"' },
|
||||
{ appid: 3000000000, appname: "Hidden One", exe: '"/x"', hidden: true },
|
||||
]);
|
||||
const got = parseShortcuts(buf);
|
||||
expect(got).toHaveLength(2);
|
||||
expect(got[0]).toMatchObject({
|
||||
appid: 2456789012,
|
||||
name: "My Emulator",
|
||||
hidden: false,
|
||||
});
|
||||
expect(got[1]).toMatchObject({ name: "Hidden One", hidden: true });
|
||||
});
|
||||
|
||||
test("derives the appid when the file omits it", () => {
|
||||
const buf = buildShortcuts([{ appname: "No Appid", exe: '"/usr/bin/x"' }]);
|
||||
const got = parseShortcuts(buf);
|
||||
expect(got).toHaveLength(1);
|
||||
// Derived ids always carry the high bit — that is how a shortcut is told apart from a real
|
||||
// store appid downstream (and why its CDN art fetch is skipped).
|
||||
expect(got[0].appid & 0x8000_0000).not.toBe(0);
|
||||
expect(got[0].appid).toBe(shortcutAppId('"/usr/bin/x"', "No Appid"));
|
||||
});
|
||||
|
||||
test("is total on a truncated or garbled file", () => {
|
||||
expect(parseShortcuts(new Uint8Array([]))).toEqual([]);
|
||||
expect(parseShortcuts(new Uint8Array([0x01, 0x02, 0x03]))).toEqual([]);
|
||||
const good = buildShortcuts([{ appid: 1, appname: "A", exe: "/a" }]);
|
||||
// Every truncation of a valid file must return, not throw.
|
||||
for (let i = 0; i < good.length; i++) {
|
||||
expect(() => parseShortcuts(good.subarray(0, i))).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test("crc32 matches the IEEE check value", () => {
|
||||
// The canonical CRC-32 check: crc32("123456789") == 0xCBF43926.
|
||||
expect(crc32(new TextEncoder().encode("123456789"))).toBe(0xcbf4_3926);
|
||||
});
|
||||
|
||||
test("shortcutGameId composes the appid and the shortcut marker", () => {
|
||||
// high dword = appid, low dword = 0x02000000. Handing rungameid the bare 32-bit appid does
|
||||
// NOT launch a shortcut, which is the entire reason this function exists.
|
||||
const id = BigInt(shortcutGameId(0x8000_0000));
|
||||
expect(id >> 32n).toBe(0x8000_0000n);
|
||||
expect(id & 0xffff_ffffn).toBe(0x0200_0000n);
|
||||
// Digits only — it rides the `steam_appid` launch kind, which the host validates as digits.
|
||||
expect(shortcutGameId(2_456_789_012)).toMatch(/^\d+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("path confinement", () => {
|
||||
test("confinedJoin refuses anything that could escape the install dir", () => {
|
||||
const base = path.join(path.sep, "games", "W3");
|
||||
expect(confinedJoin(base, "bin/game.exe")).toBe(
|
||||
path.join(base, "bin", "game.exe"),
|
||||
);
|
||||
expect(confinedJoin(base, "bin\\game.exe")).toBe(
|
||||
path.join(base, "bin", "game.exe"),
|
||||
);
|
||||
// The three shapes a crafted goggame-*.info would use to point elsewhere.
|
||||
expect(confinedJoin(base, "../../windows/system32/cmd.exe")).toBeUndefined();
|
||||
expect(confinedJoin(base, "/etc/passwd")).toBeUndefined();
|
||||
expect(confinedJoin(base, "C:\\Windows\\system32\\cmd.exe")).toBeUndefined();
|
||||
expect(confinedJoin(base, "")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("capped reads", () => {
|
||||
test("readTextCapped refuses an over-cap file and a missing one", () => {
|
||||
const dir = tmp("caps");
|
||||
const small = path.join(dir, "small.txt");
|
||||
fs.writeFileSync(small, "hello");
|
||||
expect(readTextCapped(small)).toBe("hello");
|
||||
expect(readTextCapped(small, 2)).toBeUndefined(); // over the cap
|
||||
expect(readTextCapped(path.join(dir, "nope.txt"))).toBeUndefined();
|
||||
expect(readTextCapped(dir)).toBeUndefined(); // a directory is not a file
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("art locations", () => {
|
||||
test("steamCdnUrl skips shortcut appids, which have no CDN entry", () => {
|
||||
expect(steamCdnUrl(570, "header")).toContain("/570/header.jpg");
|
||||
expect(steamCdnUrl(570, "portrait")).toContain("library_600x900.jpg");
|
||||
// The local cache names the header asset differently from the CDN — pinned because it is
|
||||
// the single most common way to get Steam art wrong.
|
||||
expect(steamCdnUrl(570, "header")).not.toContain("library_header");
|
||||
expect(steamCdnUrl(0x8000_0001, "header")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("grid filenames follow Steam's per-kind naming", () => {
|
||||
expect(gridFilenames(570, "portrait")).toEqual(["570p.png", "570p.jpg"]);
|
||||
expect(gridFilenames(570, "hero")).toEqual(["570_hero.png", "570_hero.jpg"]);
|
||||
expect(gridFilenames(570, "logo")).toEqual(["570_logo.png", "570_logo.jpg"]);
|
||||
expect(gridFilenames(570, "header")).toEqual(["570.png", "570.jpg"]);
|
||||
});
|
||||
|
||||
test("finds cached and user-override art on disk", () => {
|
||||
const dir = tmp("art");
|
||||
const hashDir = path.join(dir, "appcache", "librarycache", "570", "abc123");
|
||||
fs.mkdirSync(hashDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(hashDir, "library_600x900.jpg"), "x");
|
||||
expect(findLocalArtFile(dir, 570, "portrait")).toBe(
|
||||
path.join(hashDir, "library_600x900.jpg"),
|
||||
);
|
||||
expect(findLocalArtFile(dir, 570, "hero")).toBeUndefined();
|
||||
|
||||
const cfg = path.join(dir, "userdata", "1", "config");
|
||||
fs.mkdirSync(path.join(cfg, "grid"), { recursive: true });
|
||||
fs.writeFileSync(path.join(cfg, "grid", "570p.jpg"), "x");
|
||||
expect(findGridArtFile(cfg, 570, "portrait")).toBe(
|
||||
path.join(cfg, "grid", "570p.jpg"),
|
||||
);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("fileUrl produces the host's local-art contract shape", () => {
|
||||
const u = fileUrl(path.join(path.sep, "home", "u", "My Games", "c.jpg"));
|
||||
expect(u.startsWith("file:///")).toBe(true);
|
||||
// Spaces are percent-encoded; the separators survive so the host can rebuild the path.
|
||||
expect(u).toContain("My%20Games");
|
||||
expect(u).toContain("/c.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reg.exe output", () => {
|
||||
test("parses value rows and leaves the key header alone", () => {
|
||||
const stdout = [
|
||||
"",
|
||||
"HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Valve\\Steam",
|
||||
" InstallPath REG_SZ C:\\Program Files (x86)\\Steam",
|
||||
" Language REG_SZ english",
|
||||
"",
|
||||
].join("\r\n");
|
||||
expect(parseRegQuery(stdout)).toEqual([
|
||||
{
|
||||
name: "InstallPath",
|
||||
type: "REG_SZ",
|
||||
// Data may contain spaces — only the first two columns are split off.
|
||||
data: "C:\\Program Files (x86)\\Steam",
|
||||
},
|
||||
{ name: "Language", type: "REG_SZ", data: "english" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user