Files
warp-engine-client/lib/store.js
T
mr.zeroandClaude Opus 5 cd5361e222 A side menu, categories, and a store switcher
Everything that is not a title moved out of the bar into a menu on the left that
folds away with the button in the bar; the state is remembered between runs. It
carries the stores on this machine, the two actions, the categories and the
language.

Categories narrow the grid one at a time, with counts: the state of a title on
this machine, then a row per platform and per kind. The axes are built from what
the catalog contains — a WarpEngine catalog has no genre — and a category that
disappears under you falls back to Everything rather than leaving a blank grid.

With more than one store installed, clicking another in the menu opens it: the
grid, the categories and the folders follow, the choice is remembered, and stores
sharing an id are told apart by their folder. app:state now reports every store,
store:use switches, and the engine is re-checked per store because two stores can
be at different versions.

While the CLI runs, the menu, the log drawer and the filters stay live; only what
would start a second call is disabled.

Fixes the grid, which was broken and passing every check: its implicit rows split
the window's height evenly instead of following their content, so every card came
out 94px tall with its box art collapsed to nothing and its buttons clipped away.
The DOM was intact throughout — ten cards, twenty buttons — which is exactly why
the counts said nothing. Rows are content-sized now, and the art is one band of
one height, with the title's first letter where the catalog has no image.

So the window is looked at and not only counted, `SELFTEST_SHOT` has it
photograph itself; the terminal has no screen-recording permission here. The
selftest also clicks the store that is not open when there are two, and checks
that the bar, the grid and the categories follow.

Also fixes scripts/release.sh, which could not upload a package whose name has a
space in it — "WarpEngine Store-1.1.0-arm64.dmg" does — because the list was one
string split on whitespace. And `make release` cleans dist/ first, so a release
cannot pick up the previous version's artifacts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 14:12:37 +02:00

259 lines
8.3 KiB
JavaScript

'use strict'
// The bridge to the store CLI.
//
// The CLI is the product; this file only finds it and talks to it. Every
// operation is `desktop_store.py --json …`, which puts data on stdout and its
// log on stderr — so nothing here parses a sentence meant for a person.
//
// Shaped for more than one engine on purpose: ENGINES is a list today with one
// entry, and the RetroArch store could be added without touching the callers.
const { spawn, spawnSync } = require('node:child_process')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const ENGINES = [
{
id: 'desktop',
script: 'desktop_store.py',
// The installer names the store home `<store id>-desktop`, so the RetroArch
// engine can share the same root without sharing config.json and state.json.
homeSuffix: '-desktop',
launcherSuffix: '-desktop-store'
}
]
/** The roots the shell installers use, in the same order they would. */
function storeRoots () {
const home = os.homedir()
const roots = []
if (process.env.STORE_ROOT) roots.push(process.env.STORE_ROOT)
if (process.env.XDG_DATA_HOME) {
roots.push(path.join(process.env.XDG_DATA_HOME, 'warp-engine-store'))
}
roots.push(path.join(home, '.local', 'share', 'warp-engine-store'))
if (process.platform === 'darwin') {
roots.push(path.join(home, 'Library', 'Application Support', 'warp-engine-store'))
}
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
roots.push(path.join(process.env.LOCALAPPDATA, 'warp-engine-store'))
}
return [...new Set(roots)]
}
/** Every installed store this client can drive. */
function findStores () {
const found = []
for (const root of storeRoots()) {
let entries = []
try {
entries = fs.readdirSync(root, { withFileTypes: true })
} catch { continue }
for (const entry of entries) {
if (!entry.isDirectory()) continue
const home = path.join(root, entry.name)
for (const engine of ENGINES) {
const script = path.join(home, engine.script)
const config = path.join(home, 'config.json')
if (fs.existsSync(script) && fs.existsSync(config)) {
found.push({ engine: engine.id, id: entry.name.replace(engine.homeSuffix, ''), home, script, config })
}
}
}
}
return found
}
/**
* The store to drive: the one asked for by home if it is still there, otherwise
* the first one found. The client remembers the choice, so a machine with two
* stores reopens on the one last used rather than on whichever sorts first.
*/
function findStore (preferredHome) {
const stores = findStores()
if (preferredHome) {
const wanted = stores.find((store) => store.home === preferredHome)
if (wanted) return wanted
}
return stores[0] || null
}
/**
* The store's own name, from the config the installer wrote. Worth reading here
* rather than waiting for `paths`: the switcher lists every store on the machine,
* and starting a Python process per entry to learn its name would be absurd.
*/
function storeName (store) {
try {
const config = JSON.parse(fs.readFileSync(store.config, 'utf8'))
return (config.store && config.store.name) || store.id
} catch {
return store.id
}
}
/** An installed store as the window needs it. */
function describe (store) {
return store && { id: store.id, home: store.home, engine: store.engine, name: storeName(store) }
}
/** Where a store would be installed if there is none yet. */
function defaultHome (storeId = 'ttg') {
const engine = ENGINES[0]
return path.join(storeRoots()[0], `${storeId}${engine.homeSuffix}`)
}
// Python 3 is what the CLI needs, and its name differs per platform. `py -3` is
// the Windows launcher, which is often the only one on PATH.
const PYTHON_CANDIDATES = process.platform === 'win32'
? [['py', ['-3']], ['python', []], ['python3', []]]
: [['python3', []], ['python', []]]
let cachedPython = null
function findPython () {
if (cachedPython !== undefined && cachedPython !== null) return cachedPython
for (const [cmd, args] of PYTHON_CANDIDATES) {
try {
const probe = spawnSync(cmd, [...args, '--version'], { encoding: 'utf8', timeout: 10000 })
const out = `${probe.stdout || ''}${probe.stderr || ''}`
if (probe.status === 0 && /Python 3\./.test(out)) {
cachedPython = { cmd, args, version: out.trim() }
return cachedPython
}
} catch { /* try the next one */ }
}
cachedPython = null
return null
}
class StoreError extends Error {
constructor (message, code) {
super(message)
this.code = code
}
}
// The oldest engine that speaks `--json`. An older one is not broken, it simply
// cannot be driven from a window — and it will be met in the wild, because the
// CLI shipped before this client did.
const MIN_ENGINE = [1, 1, 0]
function parseVersion (text) {
const match = /(\d+)\.(\d+)\.(\d+)/.exec(String(text || ''))
return match ? match.slice(1, 4).map(Number) : null
}
function atLeast (version, minimum) {
if (!version) return false
for (let i = 0; i < minimum.length; i += 1) {
if ((version[i] || 0) > minimum[i]) return true
if ((version[i] || 0) < minimum[i]) return false
}
return true
}
/** The installed engine's version string, and whether this client can drive it. */
function engineVersion (store) {
const python = findPython()
if (!python || !store) return null
try {
const probe = spawnSync(python.cmd, [...python.args, store.script, '--version'],
{ encoding: 'utf8', timeout: 15000 })
const text = `${probe.stdout || ''}${probe.stderr || ''}`.trim()
if (probe.status !== 0 || !text) return null
return { text, version: parseVersion(text), ok: atLeast(parseVersion(text), MIN_ENGINE) }
} catch {
return null
}
}
/**
* Run one CLI command.
*
* `onLine` gets every stdout line already parsed (the CLI emits one JSON object
* per line), `onLog` every stderr line as text. Resolves with the parsed lines.
*/
function run (store, args, { onLine, onLog, signal } = {}) {
const python = findPython()
if (!python) throw new StoreError('python3 was not found on this machine', 'NO_PYTHON')
if (!store) throw new StoreError('no store is installed yet', 'NO_STORE')
const argv = [...python.args, store.script, '--config', store.config, '--json', ...args]
return new Promise((resolve, reject) => {
const child = spawn(python.cmd, argv, {
env: { ...process.env, DESKTOP_STORE_HOME: store.home },
signal
})
const lines = []
let stdoutRest = ''
let stderrRest = ''
const takeStdout = (chunk) => {
stdoutRest += chunk
const parts = stdoutRest.split('\n')
stdoutRest = parts.pop()
for (const part of parts) {
if (!part.trim()) continue
let value
try {
value = JSON.parse(part)
} catch {
// Not ours to interpret — hand it on as a log line rather than crash.
if (onLog) onLog(part)
continue
}
lines.push(value)
if (onLine) onLine(value)
}
}
const takeStderr = (chunk) => {
stderrRest += chunk
const parts = stderrRest.split('\n')
stderrRest = parts.pop()
for (const part of parts) if (part.trim() && onLog) onLog(part)
}
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', takeStdout)
child.stderr.on('data', takeStderr)
child.on('error', (err) => reject(new StoreError(err.message, 'SPAWN_FAILED')))
child.on('close', (code) => {
takeStdout('\n')
takeStderr('\n')
if (code === 0) resolve(lines)
else reject(new StoreError(`the store exited with code ${code}`, 'CLI_FAILED'))
})
})
}
async function list (store, hooks) {
const lines = await run(store, ['list'], hooks)
return lines[lines.length - 1] || { games: [], skipped: [] }
}
async function paths (store, hooks) {
const lines = await run(store, ['paths'], hooks)
return lines[lines.length - 1] || {}
}
function sync (store, names = [], hooks) {
return run(store, ['sync', ...names], hooks)
}
function remove (store, name, hooks) {
return run(store, ['remove', name], hooks)
}
function purge (store, hooks) {
return run(store, ['purge'], hooks)
}
module.exports = {
ENGINES, MIN_ENGINE, StoreError, atLeast, defaultHome, describe, engineVersion,
findPython, findStore, findStores, list, parseVersion, paths, purge, remove, run,
storeName, storeRoots, sync
}