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:
Vendored
+82
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user