An Electron client for the desktop store

The desktop store put the catalog on ordinary computers, and then asked people to
open a terminal — which on Windows is not even a workable ask, because the
installer is `curl … | sh`. This is the window: a grid of cards, one click to
install a title into the application menu, one to play it, one to remove it.

The CLI stays the product. Every action runs `desktop_store.py --json`, so there
is one catalog logic, one state file and one delete guard; the window never
touches the filesystem itself. That is also why the engine grew `--json` first
rather than this app growing a parser for prose.

It doubles as the Windows install path: with no store on the machine, the app
downloads the engine, the shared core and a config into the same folder the shell
installer would use — Node's https, no curl. An engine older than 1.1.0 cannot be
driven from a window, so the client checks the version and offers to refresh it
instead of failing on the first call.

Deliberate choices worth knowing:

- No renderer framework and no build step. Plain HTML, CSS and JS, one runtime
  dependency. The whole UI is readable in one sitting.
- `contextIsolation` on, `nodeIntegration` off, `sandbox` on, a CSP that permits
  only the app's own script and stylesheet plus images over HTTPS. The renderer
  can do exactly what preload.js exposes and nothing else.
- English and Hungarian, following the system language. The CLIs and the docs stay
  English; this is the one end-user surface where that is not enough.
- `ENGINES` is a list with one entry. The RetroArch store has the same command
  shape, so adding it is an entry, not a rewrite.

Two ways to test it without a working installation in the way: `npm run smoke`
drives the bridge with no window at all, and `npm run uitest` loads the window
once and reports what rendered — the only way a renderer error would otherwise be
noticed, since the main process log stays empty. Both accept a sandbox store
through STORE_ROOT / SMOKE_HOME.

Verified on macOS arm64, including the packaged .app: the store is found, ten
titles list, a sync installs three, and the window renders them as installed with
their Play and Remove buttons. Linux and Windows are unproven, as they are for the
CLI itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 10:55:12 +02:00
co-authored by Claude Opus 5
commit f88340d63c
13 changed files with 5097 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
'use strict'
// Setting up the store when there is none yet.
//
// This is the reason the client exists on Windows at all: the shell installer is
// `curl … | sh`, which Windows does not have. The three files it would place are
// downloaded here instead, into the very same store home — so the CLI and the
// client stay one installation, and running install.sh afterwards only adds the
// launcher script.
const fs = require('node:fs')
const https = require('node:https')
const path = require('node:path')
const FORGE = 'https://git.teletypegames.org'
const SOURCES = {
engine: `${FORGE}/stores/warp-engine-desktop-store/raw/branch/master/desktop_store.py`,
core: `${FORGE}/engines/warpstore/raw/branch/master/warpstore.py`,
config: `${FORGE}/stores/ttg-desktop-store/raw/branch/master/config.json`
}
/** GET a URL as a string, following redirects — a moved repo answers 301. */
function fetchText (url, redirects = 5) {
return new Promise((resolve, reject) => {
const request = https.get(url, { headers: { 'User-Agent': 'warp-engine-desktop-gui' } }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume()
if (redirects <= 0) return reject(new Error(`too many redirects for ${url}`))
const next = new URL(res.headers.location, url).toString()
return fetchText(next, redirects - 1).then(resolve, reject)
}
if (res.statusCode !== 200) {
res.resume()
return reject(new Error(`${url} answered ${res.statusCode}`))
}
let body = ''
res.setEncoding('utf8')
res.on('data', (chunk) => { body += chunk })
res.on('end', () => resolve(body))
})
request.setTimeout(60000, () => request.destroy(new Error(`${url} timed out`)))
request.on('error', reject)
})
}
/**
* Download the engine, the shared core and the store's config into `home`.
*
* `onLog` reports each step, because on a slow line this takes a few seconds and
* silence looks like a hang. An existing config is left alone: a store that is
* already set up keeps its settings.
*/
async function install (home, { onLog = () => {} } = {}) {
fs.mkdirSync(home, { recursive: true })
const wrote = []
for (const [name, file] of [['engine', 'desktop_store.py'], ['core', 'warpstore.py']]) {
onLog(`downloading ${file}`)
const body = await fetchText(SOURCES[name])
if (!body.startsWith('#!/usr/bin/env python3')) {
throw new Error(`${file} does not look like the store engine — refusing to install it`)
}
const dest = path.join(home, file)
fs.writeFileSync(dest, body, { mode: 0o755 })
wrote.push(dest)
}
const config = path.join(home, 'config.json')
if (fs.existsSync(config)) {
onLog('keeping the config already in place')
} else {
onLog('downloading config.json')
const body = await fetchText(SOURCES.config)
JSON.parse(body) // a broken config would fail later and less clearly
fs.writeFileSync(config, body)
wrote.push(config)
}
onLog(`the store is set up in ${home}`)
return { home, config, script: path.join(home, 'desktop_store.py'), wrote }
}
module.exports = { FORGE, SOURCES, fetchText, install }
+94
View File
@@ -0,0 +1,94 @@
'use strict'
// Two languages, the way the public site has them. The CLI and the docs stay
// English; this is the one end-user surface where Hungarian matters.
//
// Catalog text — titles, descriptions — is never translated here: it arrives
// from the store as it was published.
const STRINGS = {
en: {
appName: 'WarpEngine Store',
syncAll: 'Install all',
refresh: 'Refresh',
install: 'Install',
update: 'Update',
play: 'Play',
open: 'Open',
remove: 'Remove',
installed: 'installed',
native: 'native',
hosted: 'hosted',
hostedHint: 'Opens in your browser — needs the network',
nativeHint: 'Installed on this machine — works offline',
updateAvailable: 'update available',
log: 'Log',
noGames: 'No installable titles in the catalog.',
setupTitle: 'Set up the store',
setupBody: 'The store engine is not on this machine yet. It can be downloaded now — the same files the shell installer would place, in the same folder.',
setupAction: 'Download the store',
setupWorking: 'Setting up…',
oldEngineTitle: 'The store needs refreshing',
oldEngineBody: 'The store engine on this machine is older than this client can drive. Refreshing it downloads the current engine and keeps your settings and installed games.',
oldEngineAction: 'Refresh the store',
noPythonTitle: 'Python 3 is required',
noPythonBody: 'The store is a Python program, so Python 3 has to be installed. Install it, then reopen this window.',
pythonLink: 'python.org/downloads',
paths: 'Where things go',
openStoreFolder: 'Open the store folder',
openMenuFolder: 'Open the menu folder',
busy: 'Working…',
failed: 'failed',
removed: 'removed',
upToDate: 'Everything is up to date.',
of: 'of'
},
hu: {
appName: 'WarpEngine Store',
syncAll: 'Mind telepítése',
refresh: 'Frissítés',
install: 'Telepítés',
update: 'Frissítés',
play: 'Indítás',
open: 'Megnyitás',
remove: 'Eltávolítás',
installed: 'telepítve',
native: 'natív',
hosted: 'hosztolt',
hostedHint: 'A böngészőben nyílik meg — internet kell hozzá',
nativeHint: 'Erre a gépre telepítve — internet nélkül is megy',
updateAvailable: 'frissítés elérhető',
log: 'Napló',
noGames: 'Nincs telepíthető cím a katalógusban.',
setupTitle: 'A store beállítása',
setupBody: 'A store motorja még nincs ezen a gépen. Most letölthető — ugyanazok a fájlok, ugyanabba a könyvtárba, ahová a shell-telepítő tenné.',
setupAction: 'Store letöltése',
setupWorking: 'Beállítás…',
oldEngineTitle: 'A store frissítésre vár',
oldEngineBody: 'A gépen lévő store-motor régebbi, mint amit ez a kliens vezérelni tud. A frissítés letölti a mostani motort, a beállításaid és a telepített játékok pedig megmaradnak.',
oldEngineAction: 'Store frissítése',
noPythonTitle: 'Python 3 kell hozzá',
noPythonBody: 'A store egy Python program, tehát Python 3 kell a gépre. Telepítsd, majd nyisd meg újra ezt az ablakot.',
pythonLink: 'python.org/downloads',
paths: 'Hova kerül',
openStoreFolder: 'Store könyvtár megnyitása',
openMenuFolder: 'Menü könyvtár megnyitása',
busy: 'Dolgozom…',
failed: 'hiba',
removed: 'eltávolítva',
upToDate: 'Minden naprakész.',
of: '/'
}
}
const FALLBACK = 'en'
function pick (locale) {
const short = String(locale || '').slice(0, 2).toLowerCase()
return STRINGS[short] ? short : FALLBACK
}
function dict (locale) {
return STRINGS[pick(locale)]
}
module.exports = { FALLBACK, STRINGS, dict, pick, languages: Object.keys(STRINGS) }
+228
View File
@@ -0,0 +1,228 @@
'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
}
function findStore () {
return findStores()[0] || null
}
/** 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, engineVersion, findPython,
findStore, findStores, list, parseVersion, paths, purge, remove, run, storeRoots, sync
}