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>
259 lines
8.3 KiB
JavaScript
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
|
|
}
|