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