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