diff --git a/plugin-kit/package.json b/plugin-kit/package.json index 855d1a82..c53efb8e 100644 --- a/plugin-kit/package.json +++ b/plugin-kit/package.json @@ -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"], diff --git a/plugin-kit/src/index.ts b/plugin-kit/src/index.ts index 3d2758dd..cd4aef44 100644 --- a/plugin-kit/src/index.ts +++ b/plugin-kit/src/index.ts @@ -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"; diff --git a/plugin-kit/src/library/define.ts b/plugin-kit/src/library/define.ts new file mode 100644 index 00000000..c66d1a32 --- /dev/null +++ b/plugin-kit/src/library/define.ts @@ -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 { + /** + * 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 `:`, 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; + /** Enumerate the launcher's installed titles — the only real per-store code. */ + readonly scan: ( + cfg: S["Type"], + ) => Effect.Effect>; + /** + * 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; + /** Launcher data dirs to watch, so a newly installed game appears without waiting for a poll. */ + readonly watchDirs?: (cfg: S["Type"]) => ReadonlyArray; + /** 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>; +} + +/** 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) => Promise; +} + +export const defineLibraryPlugin = ( + def: LibraryPluginDef, +): 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, 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; + 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, + 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): Effect.Effect => + 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, + 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)), + 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 = { + 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> = { + 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>, + ...(argv !== undefined ? { argv } : {}), + }), + }; +}; diff --git a/plugin-kit/src/library/index.ts b/plugin-kit/src/library/index.ts new file mode 100644 index 00000000..ce80c675 --- /dev/null +++ b/plugin-kit/src/library/index.ts @@ -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"; diff --git a/plugin-kit/src/library/parsers/art.ts b/plugin-kit/src/library/parsers/art.ts new file mode 100644 index 00000000..f39ec22d --- /dev/null +++ b/plugin-kit/src/library/parsers/art.ts @@ -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///`, 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 `p`, hero + * `_hero`, logo `_logo`, wide capsule `` — 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//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; +}; diff --git a/plugin-kit/src/library/parsers/fs.ts b/plugin-kit/src/library/parsers/fs.ts new file mode 100644 index 00000000..288f9243 --- /dev/null +++ b/plugin-kit/src/library/parsers/fs.ts @@ -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 = ( + 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-.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; +}; diff --git a/plugin-kit/src/library/parsers/http.ts b/plugin-kit/src/library/parsers/http.ts new file mode 100644 index 00000000..d1f629d9 --- /dev/null +++ b/plugin-kit/src/library/parsers/http.ts @@ -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 => + Effect.tryPromise({ + try: async (): Promise => { + 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 = ( + url: string, + limits: FetchLimits = {}, +): Effect.Effect => + 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, + }), + }), + ), + ); diff --git a/plugin-kit/src/library/parsers/index.ts b/plugin-kit/src/library/parsers/index.ts new file mode 100644 index 00000000..56d5f834 --- /dev/null +++ b/plugin-kit/src/library/parsers/index.ts @@ -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"; diff --git a/plugin-kit/src/library/parsers/registry.ts b/plugin-kit/src/library/parsers/registry.ts new file mode 100644 index 00000000..4b420d66 --- /dev/null +++ b/plugin-kit/src/library/parsers/registry.ts @@ -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: ` `, 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; +}; diff --git a/plugin-kit/src/library/parsers/shortcuts.ts b/plugin-kit/src/library/parsers/shortcuts.ts new file mode 100644 index 00000000..b1348952 --- /dev/null +++ b/plugin-kit/src/library/parsers/shortcuts.ts @@ -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" `); 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(); diff --git a/plugin-kit/src/library/parsers/sqlite.ts b/plugin-kit/src/library/parsers/sqlite.ts new file mode 100644 index 00000000..6831796b --- /dev/null +++ b/plugin-kit/src/library/parsers/sqlite.ts @@ -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: >( + 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: >(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 = ( + file: string, + use: (db: ReadOnlyDb) => T, +): T | undefined => { + const db = openReadOnly(file); + if (!db) return undefined; + try { + return use(db); + } finally { + db.close(); + } +}; diff --git a/plugin-kit/src/library/parsers/steam-root.ts b/plugin-kit/src/library/parsers/steam-root.ts new file mode 100644 index 00000000..02865816 --- /dev/null +++ b/plugin-kit/src/library/parsers/steam-root.ts @@ -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(); + 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(); + 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//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; +}; diff --git a/plugin-kit/src/library/parsers/vdf.ts b/plugin-kit/src/library/parsers/vdf.ts new file mode 100644 index 00000000..370ddeae --- /dev/null +++ b/plugin-kit/src/library/parsers/vdf.ts @@ -0,0 +1,80 @@ +// Valve Data Format (text) — the flat-field reader Steam's `libraryfolders.vdf` and +// `appmanifest_.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. + +/** `"" ""` on a single line → ``. 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 `"" ""` 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" ""` 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_.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") + ); +}; diff --git a/plugin-kit/src/ui-server.ts b/plugin-kit/src/ui-server.ts index e30a223e..ca12e9b1 100644 --- a/plugin-kit/src/ui-server.ts +++ b/plugin-kit/src/ui-server.ts @@ -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 | null => { + try { + const doc = Schema.toJsonSchemaDocument(schema as never); + return doc as unknown as Record; + } 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 { + /** 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; +} + +/** + * 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 = ( + cfg: ServeUiConfig, +): ((req: Request) => Promise) => { + // 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 => { + 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//…` proxy, so there is no new host surface and nothing new exposed to the LAN. + */ + readonly config?: ServeUiConfig; /** * 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; + readonly api?: Layer.Layer; /** 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 => { 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 }), diff --git a/plugin-kit/test/library-config.test.ts b/plugin-kit/test/library-config.test.ts new file mode 100644 index 00000000..9c398bf0 --- /dev/null +++ b/plugin-kit/test/library-config.test.ts @@ -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), + { 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> => { + const doc = deriveConfigJsonSchema(ScannerConfig) as { + schema: { properties: Record> }; + }; + 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 ( + use: (handler: (req: Request) => Promise, file: string) => Promise, + ): Promise => { + 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); + }); + }); +}); diff --git a/plugin-kit/test/library-parsers.test.ts b/plugin-kit/test/library-parsers.test.ts new file mode 100644 index 00000000..8c038cd3 --- /dev/null +++ b/plugin-kit/test/library-parsers.test.ts @@ -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" }, + ]); + }); +});