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>
104 lines
3.9 KiB
JavaScript
104 lines
3.9 KiB
JavaScript
#!/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
|
|
})
|