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.
151 lines
5.6 KiB
TypeScript
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({});
|
|
});
|
|
});
|