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
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env node
'use strict'
// Drives the bridge without Electron: no window, no packaging, just the part
// that talks to the store. This is where an integration mistake shows up first,
// so it is the check to run after touching lib/store.js or the CLI.
//
// npm run smoke the store installed on this machine
// SMOKE_HOME=/path/to/store-home npm run smoke a sandbox store
const path = require('node:path')
const fs = require('node:fs')
const store = require('../lib/store')
const i18n = require('../lib/i18n')
function ok (label, value) {
console.log(` ok ${label}${value === undefined ? '' : `: ${value}`}`)
}
function bad (label, value) {
console.log(` FAIL ${label}${value === undefined ? '' : `: ${value}`}`)
process.exitCode = 1
}
async function main () {
console.log('warp-engine-desktop-gui smoke test')
const python = store.findPython()
if (python) ok('python', python.version)
else return bad('python', 'not found — the store cannot run')
for (const lang of i18n.languages) {
const dict = i18n.dict(lang)
const missing = Object.keys(i18n.STRINGS[i18n.FALLBACK]).filter((k) => !dict[k])
if (missing.length) bad(`strings:${lang}`, `missing ${missing.join(', ')}`)
else ok(`strings:${lang}`, `${Object.keys(dict).length} keys`)
}
let target = null
if (process.env.SMOKE_HOME) {
const home = path.resolve(process.env.SMOKE_HOME)
target = {
engine: 'desktop',
id: path.basename(home).replace(/-desktop$/, ''),
home,
script: path.join(home, 'desktop_store.py'),
config: path.join(home, 'config.json')
}
for (const file of [target.script, target.config]) {
if (!fs.existsSync(file)) return bad('SMOKE_HOME', `${file} is missing`)
}
ok('store (SMOKE_HOME)', target.home)
} else {
const stores = store.findStores()
if (!stores.length) {
console.log(' skip no store installed — run the app once, or set SMOKE_HOME')
console.log(` it would be installed in ${store.defaultHome()}`)
return
}
target = stores[0]
ok('store found', `${target.id} in ${target.home}`)
}
const logs = []
const paths = await store.paths(target, { onLog: (l) => logs.push(l) })
if (paths.os && paths.store_folder) ok('paths', `${paths.os}${paths.store_folder}`)
else bad('paths', JSON.stringify(paths))
const listing = await store.list(target, { onLog: (l) => logs.push(l) })
const games = listing.games || []
if (!games.length) return bad('list', 'no games came back')
const modes = games.reduce((acc, g) => {
acc[g.mode] = (acc[g.mode] || 0) + 1
return acc
}, {})
ok('list', `${games.length} titles (${Object.entries(modes).map(([m, n]) => `${m}:${n}`).join(', ')})`)
const required = ['name', 'title', 'platform', 'version', 'mode', 'kind', 'installed', 'update_available']
const broken = games.filter((g) => required.some((k) => g[k] === undefined))
if (broken.length) bad('game shape', `${broken.length} entries miss a field`)
else ok('game shape', required.join(', '))
const hosted = games.filter((g) => g.mode === 'web')
if (hosted.length && !hosted.every((g) => /^https?:\/\//.test(g.url || ''))) {
bad('hosted urls', 'a web title has no usable url')
} else if (hosted.length) {
ok('hosted urls', hosted[0].url)
}
const installed = games.filter((g) => g.installed)
ok('installed', `${installed.length} of ${games.length}`)
if (installed.length) {
const withTarget = installed.filter((g) => g.menu_entry || g.exe || g.url)
if (withTarget.length !== installed.length) bad('launch targets', 'an installed title has nothing to launch')
else ok('launch targets', 'every installed title has one')
}
if (logs.length) ok('stderr log', `${logs.length} lines (kept off stdout)`)
}
main().catch((err) => {
console.log(` FAIL ${err && err.code ? err.code : 'error'}: ${err && err.message ? err.message : err}`)
process.exitCode = 1
})