Skip to content

Case study · Desktop app

Playdex

My games are split across Steam, Epic and GOG, and none of them will tell another program what you own without a login. Their own files on disk will — but each launcher stores them differently: an undocumented binary format, a base64 blob of JSON, and a SQLite database another program is holding open. Reading them is easy to describe. The hard part is that every count has to match what the launcher itself shows, or nobody trusts the rest.

3
Launchers, one library
1,073
Lines of JavaScript
34
Tests in CI
0
Runtime dependencies
Playdex library: Steam, Epic and GOG games in one cover grid with store badges and playtime

Three launchers,
three formats

No store APIs, no keys, nothing uploaded. Each parser reads one launcher’s local data, read-only.

01Steamappcache/appinfo.vdf

A binary file with no public documentation. The library cache says which app ids you have; only appinfo.vdf says which of them are games rather than DLC, tools or test configs — and it still knows games the store has delisted, which the web API no longer returns.

const magic = buf.readUInt32LE(0);
if (magic !== 0x07564429) throw new Error(`unsupported appinfo.vdf format 0x${magic.toString(16)}`);
const tableAt = Number(buf.readBigUInt64LE(8));

The format version is checked before anything else is read. When Valve changes the layout, Playdex says so by name, rather than reading the new layout as if it were the old one and filling the library with garbage.

// Entry: appid, size, then 60 bytes of header (state, update time, token, 2 hashes, change number), then KeyValues.
for (let off = 16; off < tableAt; ) {
  const appid = String(buf.readUInt32LE(off));
  if (appid === '0') break;
  const size = buf.readUInt32LE(off + 4);
  if (wanted.has(appid)) {
    const common = readKV(off + 68).appinfo?.common;
    if (common) apps.set(appid, { name: common.name, type: common.type });
  }
  off += 8 + size;
}

Every entry carries its own size, so the loop can jump straight over every app it does not need and decode only the ones in your library. Key names are not stored inline: they are indexes into a string table at the end of the file, which is read first.

02EpicData/Catalog/catcache.bin

Not binary at all once you look: it is base64-encoded JSON of every catalog entry in your library. The work is in the filtering, because Epic files creator kits as engines and applications, and Unreal Engine as an engine alone.

const catalog = JSON.parse(Buffer.from(fs.readFileSync(path.join(base, 'Catalog/catcache.bin'), 'utf8'), 'base64').toString('utf8'));
return catalog
  // Same as Epic's Library tab: games plus creator/mod kits (filed as engines + applications, e.g.
  // "Hogwarts Legacy Creator Kit"). Not DLC (has a main game), Unreal Engine (engines only) or Twinmotion (software).
  .filter(i => {
    const cats = new Set(i.categories?.map(c => c.path));
    return !i.mainGameItem?.id && (cats.has('games') || (cats.has('engines') && cats.has('applications')));
  })

The cache also stores ™ and ® as a literal question mark — Apex Legends? — so titles are cleaned before they are matched against the other two stores.

03GOG Galaxystorage/galaxy-2.0.db

A real SQLite database, which Galaxy keeps open the whole time it runs. Playdex never opens the live file. It copies it to a temporary folder and reads the copy with Node's built-in SQLite.

// Copy first: Galaxy keeps the live DB open, and SQLite on Windows fails on very long paths.
const tmp = path.join(os.tmpdir(), 'playdex-gog');
fs.mkdirSync(tmp, { recursive: true });
for (const ext of ['', '-wal', '-shm']) {
  if (fs.existsSync(src + ext)) fs.copyFileSync(src + ext, path.join(tmp, 'galaxy-2.0.db' + ext));
}
const db = new DatabaseSync(path.join(tmp, 'galaxy-2.0.db'));

Copying the .db alone looks right and is subtly stale: in WAL mode, recent writes sit in the -wal file until SQLite checkpoints them. Copy all three, and the snapshot matches what Galaxy itself is showing.

Launcher data is
untrusted input

Epic’s and GOG’s folders live under C:\ProgramData, which every Windows user on the PC can write. A launch command read from there is a command someone else may have written. So only exact, known link shapes get through:

const SAFE_URIS = [
  /^steam:\/\/(rungameid|install|uninstall)\/\d+$/,
  /^com\.epicgames\.launcher:\/\/apps\/[\w-]+%3A[\w-]+%3A[\w-]+\?action=launch&silent=true$/,
  /^com\.epicgames\.launcher:\/\/store\/library$/, // Epic has no uninstall link: open its library, uninstall there
  /^goggalaxy:\/\/openGameView\/gog_\d+$/,
];

/** argv to run, or null if the URI isn't one we expect. */
export const uriLaunch = uri => (SAFE_URIS.some(re => re.test(uri)) ? [EXPLORER, uri] : null);

No commas, quotes or spaces can survive those patterns, which matters because explorer.exe would read them as its own switches. And the validated command never leaves the main process. The page is told only whether a game can be uninstalled, and can send back nothing but a game id:

// Commands stay in this process; the page only sends back a game id. It just learns whether uninstall is possible.
return JSON.parse(JSON.stringify(result, (k, v) => (k === 'launch' ? undefined : k === 'uninstall' ? !!v : v)));

ipcMain.handle('launch', (e, id) => {
  if (!fromApp(e)) throw new Error('Forbidden');
  return run(games.get(id)?.launch);
});

The count has to
match the launcher

If Steam says 240 and Playdex says 251, every other number on the screen is suspect. So each store is counted by its own rules: Steam keeps games and applications like Wallpaper Engine, but not DLC or tools. Epic keeps games and creator kits, but not Unreal Engine. GOG uses Galaxy’s own isDlc and isVisibleInLibrary flags, which hide Amazon Prime claim stubs and superseded releases.

Every one of those rules has a test that builds a fake launcher folder on disk with one entry per rule. A change that would make Playdex drift from what the launcher shows fails in CI before it ships.

Owning it twice

Duplicates are matched on a normalised title. Simple enough to explain in one function, which is the point:

export function normalize(title) {
  let t = title.toLowerCase().replace(/[™®©]/g, '').trim()
    .replace(/^(.+),\s*(the|a|an)$/, '$2 $1'); // catalog style: "Ultimate DOOM, The" -> "the ultimate doom"
  for (let prev; prev !== t; ) { prev = t; t = t.replace(SUFFIX, '').trim(); }
  return t.replace(/&/g, ' and ').replace(/['’]/g, '').replace(/[^a-z0-9]+/g, ' ').trim();
}

The suffix strip runs until nothing changes, because editions stack: “Game of the Year Edition” and “Director’s Cut” can both be on one title.

What I’d change

Four known limits, each one a trade I chose rather than one I missed.

Duplicates match on exact titles

After normalising, two titles either match or they do not. A game sold under a different name on each store is missed. Fuzzy matching is the fix, and it is deliberately waiting for real libraries to show misses — it brings false positives of its own.

The ™ fix eats a real question mark

Epic's cache stores ™ as "?", so a "?" glued to the end of a word is dropped. A game whose title genuinely ends in a question mark loses it. Rare enough to accept, and marked in the code as a known corner.

The GitHub installer is unsigned

SmartScreen warns on it, and Smart App Control blocks it outright. The Microsoft Store build is the real answer — the Store signs the package — but anyone installing from GitHub still meets the warning first.

Galaxy's database is still trusted for paths

A GOG install must contain its goggame-<id>.info before Playdex will launch it or measure it. Another Windows user who can edit the database could still point a game at a different folder holding a matching file. Galaxy trusts the same data, so this adds no new risk — but it is not zero.

Electron · Node’s built-in SQLite · plain JavaScript, no UI framework · type-checked with TypeScript over JSDoc · Playwright UI tests · electron-builder for the installer and the Store package · GitHub Actions with SHA-pinned actions and Dependabot.
Every excerpt above is copied from the repository, and every figure is counted from it.

© 2026 Divyansh Garg