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.
95 lines
3.6 KiB
TypeScript
95 lines
3.6 KiB
TypeScript
// 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;
|
|
};
|