Files
punktfunk/plugin-kit/src/library/parsers/vdf.ts
T
enricobuehler 8728d90e01 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.
2026-08-05 09:53:58 +02:00

81 lines
3.4 KiB
TypeScript

// 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")
);
};