Files
enricobuehler 15dccde2f2 feat: the Steam library source
Ported from the host's in-tree scanner (crates/punktfunk-host/src/library/steam.rs) — the
biggest of the six, and deliberately the LAST to move, so the seam was proven by the other
five first. Almost all of the parsing already lived in `@punktfunk/plugin-kit/library`,
which is exactly what those parsers were hoisted for; this is the assembly on top.

Covers what the scanner covered:

  * Every library's `steamapps/appmanifest_*.acf`, with the install dir resolved under
    `common/`. The FIRST library wins for an appid present in several — the same tie-break
    the host used, and the parity harness compares ids.
  * `isSteamTool` filtering, so Proton, the Steam Linux Runtime and the Steamworks
    redistributables never appear as games.
  * Non-Steam shortcuts from every `userdata/<id>/config/shortcuts.vdf`: hidden ones
    skipped, duplicate appids across profiles collapsed, and the launch value is the
    **64-bit** `rungameid` composition — handing `rungameid` the bare 32-bit appid does not
    launch a shortcut.
  * Art in the host's order: the user's own `userdata/…/grid` overrides, then
    `appcache/librarycache`, then the flat CDN URL. Local files ride out as `file://` so
    the host proxies the bytes and the payload stays tiny. A shortcut's appid has the high
    bit set and is never a store appid, so it gets no CDN guess — grid overrides are the
    only art it can have.
  * `detect.steam_appid` (authoritative on Linux, where Steam's reaper wraps every launch)
    plus the install dir, which is what the Windows matcher keys off instead.

New here: the `steam_ui` launcher tiles (design D4) — Big Picture ON by default, the desktop
client off. Steam is the one store with two UIs, which is why `steam_ui` is the one launch
kind with two values; the host turns each into a command, so the plugin never constructs one
(D1). Their external ids are `big-picture` and `desktop`, which cannot collide with an appid
because every real appid is digits.

An install dir named by a manifest but absent on disk yields NO detect hint rather than a
wrong one — a partial or cancelled install would otherwise bind the session to a path that
does not exist. Same reasoning for a shortcut with an empty target.

Runs under the runner's principal. On Windows that is NT AUTHORITY\LocalService, where HKLM
is readable (measured 2026-08-06) but HKCU is not — the kit's root discovery is built around
that. On Linux the runner is the user's own service, so `~/.steam` and `~/.var/app` are
directly readable.

Gates: tsc --noEmit clean, 10 tests pass, biome clean, `bun run build` produces both entry
points, and the CLI answers honestly off-platform (`detect` -> absent, `scan` -> []).

Still owed, and it matters more here than for any other library plugin: `parity --compare`
against a real install. These are the `steam:<appid>` ids the GameStream app list and every
client's art cache are keyed by, so a drift of even one id is a visible regression. The
Deck / SteamOS leg additionally covers real non-Steam shortcuts launching via the 64-bit
rungameid, which the Rust shortcuts work still owes too.
2026-08-06 15:55:21 +02:00

151 lines
5.6 KiB
TypeScript

// The Steam-specific half — the assembly this plugin adds on top of the kit's parsers (which are
// tested there). The real end-to-end proof is `punktfunk-plugin-steam parity --compare` on a box
// with a real Steam install.
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 {
detectFromExe,
installedApps,
launcherEntries,
} from "../src/plugin.js";
const tmp = (): string => fs.mkdtempSync(path.join(os.tmpdir(), "pf-steam-"));
/** A `steamapps` dir holding the given `appmanifest_<id>.acf` files. */
const steamapps = (
manifests: Array<{ appid: number; name: string; installdir?: string }>,
opts: { makeInstallDirs?: boolean } = {},
): string => {
const dir = path.join(tmp(), "steamapps");
fs.mkdirSync(dir, { recursive: true });
for (const m of manifests) {
const body = [
'"AppState"',
"{",
`\t"appid"\t\t"${m.appid}"`,
`\t"name"\t\t"${m.name}"`,
...(m.installdir ? [`\t"installdir"\t\t"${m.installdir}"`] : []),
"}",
].join("\n");
fs.writeFileSync(path.join(dir, `appmanifest_${m.appid}.acf`), body);
if (opts.makeInstallDirs && m.installdir) {
fs.mkdirSync(path.join(dir, "common", m.installdir), { recursive: true });
}
}
return dir;
};
describe("installed apps", () => {
test("reads every appmanifest and resolves the install dir under common/", () => {
const dir = steamapps(
[
{ appid: 570, name: "Dota 2", installdir: "dota 2 beta" },
{ appid: 620, name: "Portal 2", installdir: "Portal 2" },
],
{ makeInstallDirs: true },
);
const apps = installedApps([dir]);
expect([...apps.keys()].sort((a, b) => a - b)).toEqual([570, 620]);
expect(apps.get(570)?.name).toBe("Dota 2");
expect(apps.get(570)?.installDir).toBe(
path.join(dir, "common", "dota 2 beta"),
);
});
test("an installdir that isn't on disk yields no hint rather than a wrong one", () => {
// A manifest can name a dir a partial/cancelled install never created. Sending it would
// bind the session's detection to a path that does not exist.
const dir = steamapps([{ appid: 1, name: "Ghost", installdir: "Nowhere" }]);
expect(installedApps([dir]).get(1)?.installDir).toBeUndefined();
});
test("the FIRST library wins for an appid present in several", () => {
// The same appid legitimately appears in two libraries mid-move; the host's scanner took
// the first, and the parity harness compares ids, so the tie-break has to match.
const a = steamapps([{ appid: 570, name: "From Library A" }]);
const b = steamapps([{ appid: 570, name: "From Library B" }]);
expect(installedApps([a, b]).get(570)?.name).toBe("From Library A");
expect(installedApps([b, a]).get(570)?.name).toBe("From Library B");
});
test("non-manifest files, junk and empty dirs are all skipped quietly", () => {
const dir = steamapps([{ appid: 570, name: "Dota 2" }]);
fs.writeFileSync(path.join(dir, "readme.txt"), "not a manifest");
fs.writeFileSync(
path.join(dir, "appmanifest_bad.acf"),
"totally malformed",
);
// A manifest with no usable appid+name contributes nothing.
fs.writeFileSync(
path.join(dir, "appmanifest_9.acf"),
'"AppState"\n{\n\t"appid"\t"9"\n}',
);
expect([...installedApps([dir]).keys()]).toEqual([570]);
expect(installedApps([path.join(tmp(), "does-not-exist")]).size).toBe(0);
});
});
describe("the launcher tiles", () => {
test("Big Picture is on by default and the desktop client is not", () => {
// Big Picture IS the couch UI, and reaching it is most of the point of streaming a Steam
// machine; the desktop window is one more card in front of the games.
const tiles = launcherEntries({});
expect(tiles).toHaveLength(1);
expect(tiles[0]).toMatchObject({
external_id: "big-picture",
role: "launcher",
launch: { kind: "steam_ui", value: "bigpicture" },
});
});
test("both can be published, and both can be turned off", () => {
const both = launcherEntries({ bigPicture: true, desktopClient: true });
expect(both.map((t) => t.launch?.value)).toEqual(["bigpicture", "desktop"]);
// `steam_ui` is the one kind with two values because Steam genuinely has two UIs; the host
// owns turning each into a command.
expect(both.every((t) => t.launch?.kind === "steam_ui")).toBe(true);
expect(
launcherEntries({ bigPicture: false, desktopClient: false }),
).toEqual([]);
});
test("the tile ids cannot collide with a real appid", () => {
// The host composes `steam:<external_id>`, and every real appid is digits.
for (const t of launcherEntries({
bigPicture: true,
desktopClient: true,
})) {
expect(Number.isNaN(Number(t.external_id))).toBe(true);
}
});
});
describe("shortcut detect hints", () => {
test("unwraps Steam's quoted exe, dropping trailing arguments", () => {
expect(detectFromExe('"C:\\Games\\game.exe"')).toEqual({
detect: { exe: "C:\\Games\\game.exe" },
});
expect(detectFromExe('"C:\\Games\\game.exe" -windowed -novid')).toEqual({
detect: { exe: "C:\\Games\\game.exe" },
});
// A path with spaces survives, which is the whole reason Steam quotes it.
expect(detectFromExe('"C:\\Program Files\\My Game\\game.exe"')).toEqual({
detect: { exe: "C:\\Program Files\\My Game\\game.exe" },
});
});
test("an unquoted target still yields its first token", () => {
expect(detectFromExe("/usr/bin/retroarch -L core.so")).toEqual({
detect: { exe: "/usr/bin/retroarch" },
});
});
test("an empty target yields NO hint rather than a wrong one", () => {
// A bad exe hint is worse than none: it would bind the session to the wrong process.
expect(detectFromExe("")).toEqual({});
expect(detectFromExe(" ")).toEqual({});
});
});