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
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+105
View File
@@ -0,0 +1,105 @@
# warp-engine-desktop-gui — a window for the desktop store
A graphical client for
[`warp-engine-desktop-store`](https://git.teletypegames.org/stores/warp-engine-desktop-store):
the catalog as a grid of cards, one click to install a title into your own
application menu, one to play it, one to remove it. Linux, macOS and Windows.
The CLI stays the product; this is its front door. Every action here runs
`desktop_store.py`, so there is one catalog logic, one state file and one delete
guard — the window never touches the filesystem itself.
It is also **the Windows install path**. The store's own installer is
`curl … | sh`, which Windows does not have; this app downloads the store engine
itself, into the same folder the shell installer would use.
## What it needs
- **Python 3** on the machine, because the store is a Python program. The app
looks for `python3`, `python` and `py -3`, and says so plainly if none answer.
- Nothing else at runtime. No Node, no package manager, no admin rights: the
store installs under your own user account.
## Install
Grab the package for your machine from the
[releases](https://git.teletypegames.org/stores/warp-engine-desktop-gui/releases)
and open it. On first run, if there is no store on the machine yet, the window
offers to download one — that is the whole setup.
The macOS build is **not signed or notarised**, so the first open needs
*right-click ▸ Open* (or *System Settings ▸ Privacy & Security*). Nothing the
store itself downloads is affected: those files are fetched by Python, which does
not set the quarantine flag.
## Use
- **Install all** fetches everything the catalog offers for this machine.
- A card's button is **Install**, **Update**, or **Play** / **Open** once it is
there. **Remove** takes a title back out.
- Each card says whether it is **native** — unpacked and run locally, works
offline — or **hosted**: a browser build the catalog serves rather than
packages, so its entry opens a page and needs the network.
- The **Log** drawer at the bottom carries the store's own output verbatim, and
next to it are buttons that open the two folders everything lands in.
- The language follows the system and can be switched; **English and Hungarian**.
Anything installed from the window is a normal menu entry, so it also shows up in
your launcher, Dock or Start menu — the app does not have to be running to play.
## Development
```sh
npm install
npm start # the window, against whatever store is installed
npm run smoke # the bridge only: no window, no Electron
npm run uitest # loads the window once and reports what rendered
npm run dist:mac # or dist:win / dist:linux
```
**Node 22 or newer is needed to install**, not to run: Electron's own installer
is ESM-only, and older Node cannot `require()` it. The packaged app carries its
own runtime.
Both test scripts accept a sandbox store instead of the real one, which is how
this repository is tested without touching a working installation:
```sh
STORE_ROOT=/tmp/sandbox-root npm start
SMOKE_HOME=/tmp/sandbox-root/ttg-desktop npm run smoke
```
### How it is put together
| File | What it does |
|---|---|
| `main.js` | the window, the IPC, and the one-call-at-a-time guard |
| `preload.js` | the entire surface the renderer gets — no Node reaches it |
| `lib/store.js` | finds the store and Python, runs the CLI, parses its JSON |
| `lib/bootstrap.js` | downloads the engine, the shared core and a config |
| `lib/i18n.js` | the two string tables |
| `renderer/` | plain HTML, CSS and JS — no framework, no build step |
`contextIsolation` is on, `nodeIntegration` off, `sandbox` on, and the page
carries a CSP that allows only its own script and stylesheet plus images over
HTTPS. Links open in the real browser; the window itself never navigates.
`lib/store.js` talks to the CLI through `--json`, which puts data on stdout and
the human-readable log on stderr. That flag arrived with engine **1.1.0**, and the
client checks: an older store is met with an offer to refresh it rather than a
failed call.
The bridge keeps an `ENGINES` list with one entry today. The RetroArch store has
the same command shape, so a second entry is the whole change needed to drive it
too — that is why the indirection is there.
## Verified, and not
Exercised on macOS (arm64): the store is discovered, the catalog lists, a sync
installs, the window renders the installed state, and `npm run uitest` passes with
the grid rendered and both languages in the picker. The bootstrap download was run
into an empty directory and the resulting store answered the bridge.
**Not tried on Linux or Windows.** The paths and the launch behaviour are written
for them, and the store CLI itself has the same gap — `.desktop` and `.lnk`
launchers have been generated and read, but nobody has clicked one.
+82
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+242
View File
@@ -0,0 +1,242 @@
'use strict'
// Main process: one window, and the IPC that lets it drive the store CLI.
//
// The renderer gets no Node access at all (contextIsolation on, nodeIntegration
// off, sandbox on); everything it can do is in preload.js and handled here.
const { app, BrowserWindow, ipcMain, shell, dialog } = require('electron')
const fs = require('node:fs')
const path = require('node:path')
const { spawn } = require('node:child_process')
const store = require('./lib/store')
const bootstrap = require('./lib/bootstrap')
const i18n = require('./lib/i18n')
let win = null
let current = null // the store we are driving
let busy = false // one CLI call at a time
const prefsFile = () => path.join(app.getPath('userData'), 'prefs.json')
function loadPrefs () {
try {
return JSON.parse(fs.readFileSync(prefsFile(), 'utf8'))
} catch {
return {}
}
}
function savePrefs (prefs) {
try {
fs.mkdirSync(path.dirname(prefsFile()), { recursive: true })
fs.writeFileSync(prefsFile(), JSON.stringify(prefs, null, 2))
} catch { /* a lost preference is not worth an error dialog */ }
}
function send (channel, payload) {
if (win && !win.isDestroyed()) win.webContents.send(channel, payload)
}
const hooks = () => ({
onLog: (line) => send('store:log', line),
onLine: (event) => send('store:event', event)
})
/** One CLI call at a time: the store writes files, and two writers would race. */
async function guarded (fn) {
if (busy) throw new Error('busy')
busy = true
send('store:busy', true)
try {
return await fn()
} finally {
busy = false
send('store:busy', false)
}
}
// `--selftest` drives the window once and reports what rendered, so the UI has a
// check that does not need a pair of eyes. It is the only way a renderer error
// would otherwise be noticed: the main process log stays empty.
const SELFTEST = process.argv.includes('--selftest')
async function selftest () {
const result = await win.webContents.executeJavaScript(`(() => ({
cards: document.querySelectorAll('.card').length,
installed: document.querySelectorAll('.card.is-installed').length,
buttons: document.querySelectorAll('.card .actions button').length,
gateVisible: !document.getElementById('gate').hidden,
gateTitle: document.getElementById('gate-title').textContent,
appName: document.getElementById('app-name').textContent,
storeId: document.getElementById('store-id').textContent,
paths: document.getElementById('log-paths').textContent.slice(0, 120),
logLines: document.querySelectorAll('.log-line').length,
locales: [...document.getElementById('locale').options].map((o) => o.value)
}))()`)
console.log(JSON.stringify(result, null, 2))
const good = result.cards > 0 && !result.gateVisible && result.locales.length > 1
console.log(good ? 'SELFTEST OK' : 'SELFTEST FAILED')
app.exit(good ? 0 : 1)
}
function createWindow () {
win = new BrowserWindow({
width: 1040,
height: 720,
minWidth: 760,
minHeight: 520,
backgroundColor: '#11151c',
title: 'WarpEngine Store',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true
}
})
win.loadFile(path.join(__dirname, 'renderer', 'index.html'))
// A renderer error is invisible from here otherwise.
win.webContents.on('console-message', (_event, level, message) => {
if (level >= 2 || SELFTEST) console.log(`[renderer] ${message}`)
})
win.webContents.on('render-process-gone', (_event, details) => {
console.log(`[renderer] gone: ${details.reason}`)
if (SELFTEST) app.exit(1)
})
if (SELFTEST) {
// The first list() has to finish before there is anything to look at.
win.webContents.once('did-finish-load', () => setTimeout(() => {
selftest().catch((err) => { console.log(`SELFTEST ERROR ${err.message}`); app.exit(1) })
}, 6000))
}
// Nothing in this app should ever navigate away or open a second window; a
// link the user clicks goes to their browser instead.
win.webContents.setWindowOpenHandler(({ url }) => {
if (/^https:\/\//.test(url)) shell.openExternal(url)
return { action: 'deny' }
})
win.webContents.on('will-navigate', (event, url) => {
if (url !== win.webContents.getURL()) {
event.preventDefault()
if (/^https:\/\//.test(url)) shell.openExternal(url)
}
})
}
// --- IPC ------------------------------------------------------------------
ipcMain.handle('app:state', () => {
const prefs = loadPrefs()
const python = store.findPython()
current = store.findStore()
// An engine that predates `--json` cannot be driven from a window; the client
// says so and offers to refresh it rather than failing on the first call.
const engine = current ? store.engineVersion(current) : null
return {
locale: i18n.pick(prefs.locale || app.getLocale()),
languages: i18n.languages,
strings: i18n.dict(prefs.locale || app.getLocale()),
python: python ? python.version : null,
store: current ? { id: current.id, home: current.home } : null,
engine: engine ? { text: engine.text, ok: engine.ok } : null,
minEngine: store.MIN_ENGINE.join('.'),
defaultHome: store.defaultHome(),
version: app.getVersion()
}
})
ipcMain.handle('app:setLocale', (_event, locale) => {
const prefs = loadPrefs()
prefs.locale = i18n.pick(locale)
savePrefs(prefs)
return { locale: prefs.locale, strings: i18n.dict(prefs.locale) }
})
ipcMain.handle('store:list', () => guarded(() => store.list(current, hooks())))
ipcMain.handle('store:paths', () => guarded(() => store.paths(current, hooks())))
ipcMain.handle('store:sync', (_event, names) =>
guarded(() => store.sync(current, Array.isArray(names) ? names : [], hooks())))
ipcMain.handle('store:remove', (_event, name) =>
guarded(() => store.remove(current, String(name), hooks())))
ipcMain.handle('store:bootstrap', () => guarded(async () => {
const home = store.defaultHome()
const result = await bootstrap.install(home, { onLog: (line) => send('store:log', line) })
current = { engine: 'desktop', id: path.basename(home).replace(/-desktop$/, ''), ...result }
return { id: current.id, home: current.home }
}))
/**
* Launch what was installed.
*
* A hosted title is a URL, so it goes to the browser. A native one is whatever
* the store recorded: on macOS the app bundle through `open`, elsewhere the
* executable from its own directory — the same working directory the menu entry
* uses, because games load their assets relative to it.
*/
ipcMain.handle('store:launch', async (_event, game) => {
if (!game) return false
if (game.mode === 'web' && game.url) {
await shell.openExternal(game.url)
return true
}
const target = game.menu_entry || game.exe
if (!target || !fs.existsSync(target)) return false
if (process.platform === 'darwin' && target.endsWith('.app')) {
spawn('open', [target], { detached: true, stdio: 'ignore' }).unref()
return true
}
if (process.platform === 'win32' || target.endsWith('.desktop')) {
const error = await shell.openPath(target)
if (!error) return true
}
const exe = game.exe || target
spawn(exe, [], { cwd: path.dirname(exe), detached: true, stdio: 'ignore' }).unref()
return true
})
ipcMain.handle('app:openFolder', async (_event, dir) => {
if (!dir) return false
const error = await shell.openPath(dir)
return !error
})
ipcMain.handle('app:openExternal', async (_event, url) => {
if (!/^https:\/\//.test(String(url))) return false
await shell.openExternal(String(url))
return true
})
// --- lifecycle ------------------------------------------------------------
if (!app.requestSingleInstanceLock()) {
app.quit()
} else {
app.on('second-instance', () => {
if (win) {
if (win.isMinimized()) win.restore()
win.focus()
}
})
app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
process.on('unhandledRejection', (reason) => {
dialog.showErrorBox('WarpEngine Store', String(reason && reason.message ? reason.message : reason))
})
}
+3598
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
{
"name": "warp-engine-desktop-gui",
"productName": "WarpEngine Store",
"version": "1.0.0",
"description": "Graphical client for a WarpEngine desktop store: install the catalog into your own application menu.",
"license": "MIT",
"author": "Teletype Games <games@teletype.hu>",
"homepage": "https://git.teletypegames.org/stores/warp-engine-desktop-gui",
"main": "main.js",
"engines": {
"node": ">=22"
},
"scripts": {
"start": "electron .",
"smoke": "node scripts/smoke.js",
"dist": "electron-builder",
"dist:mac": "electron-builder --mac",
"dist:win": "electron-builder --win",
"dist:linux": "electron-builder --linux",
"uitest": "electron . --selftest"
},
"devDependencies": {
"electron": "^43.4.0",
"electron-builder": "^26.15.3"
},
"build": {
"appId": "org.teletypegames.warpstore.gui",
"productName": "WarpEngine Store",
"files": [
"main.js",
"preload.js",
"lib/**/*",
"renderer/**/*"
],
"mac": {
"category": "public.app-category.games",
"target": [
"dmg",
"zip"
]
},
"win": {
"target": [
"nsis",
"portable"
]
},
"linux": {
"category": "Game",
"target": [
"AppImage",
"deb"
]
}
},
"allowScripts": {
"electron@43.4.0": true
}
}
+26
View File
@@ -0,0 +1,26 @@
'use strict'
// The whole surface the renderer gets. No Node, no fs, no child_process — just
// these calls and two event streams.
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('storeApi', {
state: () => ipcRenderer.invoke('app:state'),
setLocale: (locale) => ipcRenderer.invoke('app:setLocale', locale),
list: () => ipcRenderer.invoke('store:list'),
paths: () => ipcRenderer.invoke('store:paths'),
sync: (names) => ipcRenderer.invoke('store:sync', names),
remove: (name) => ipcRenderer.invoke('store:remove', name),
bootstrap: () => ipcRenderer.invoke('store:bootstrap'),
launch: (game) => ipcRenderer.invoke('store:launch', game),
openFolder: (dir) => ipcRenderer.invoke('app:openFolder', dir),
openExternal: (url) => ipcRenderer.invoke('app:openExternal', url),
// Streams from the running CLI: `log` is a line a person can read, `event` is
// one of the store's JSON progress events.
onLog: (fn) => ipcRenderer.on('store:log', (_e, line) => fn(line)),
onEvent: (fn) => ipcRenderer.on('store:event', (_e, event) => fn(event)),
onBusy: (fn) => ipcRenderer.on('store:busy', (_e, value) => fn(value))
})
+321
View File
@@ -0,0 +1,321 @@
'use strict'
// The whole renderer. No framework and no build step: the app is a grid of
// cards, and every action is one call over the bridge in preload.js.
const api = window.storeApi
const el = (id) => document.getElementById(id)
let T = {} // the active string table
let games = []
let paths = null
let busy = false
let plan = null // { total, done } while a sync is running
// --- helpers --------------------------------------------------------------
function text (node, value) {
node.textContent = value == null ? '' : String(value)
}
function imageUrl (game) {
if (!game.image_url) return null
if (/^https?:\/\//.test(game.image_url)) return game.image_url
const base = (paths && paths.store && paths.store.base_url) || ''
return base ? `${base}${game.image_url}` : null
}
function logLine (line) {
const box = el('log-lines')
const row = document.createElement('div')
row.className = 'log-line'
text(row, line)
box.appendChild(row)
while (box.childElementCount > 400) box.removeChild(box.firstChild)
box.scrollTop = box.scrollHeight
}
function setBusy (value) {
busy = value
for (const node of document.querySelectorAll('button')) {
if (node.id === 'log-toggle') continue
node.disabled = value
}
const progress = el('progress')
if (!value) {
progress.hidden = true
plan = null
}
}
function showProgress (label) {
const progress = el('progress')
progress.hidden = false
text(progress, label)
}
// --- the card grid --------------------------------------------------------
function card (game) {
const node = document.createElement('article')
node.className = 'card'
if (game.installed) node.classList.add('is-installed')
const art = document.createElement('div')
art.className = 'art'
const src = imageUrl(game)
if (src) {
const img = document.createElement('img')
img.src = src
img.alt = ''
img.loading = 'lazy'
art.appendChild(img)
} else {
const glyph = document.createElement('span')
glyph.className = 'art-glyph'
text(glyph, game.title.slice(0, 1).toUpperCase())
art.appendChild(glyph)
}
node.appendChild(art)
const body = document.createElement('div')
body.className = 'body'
const title = document.createElement('h2')
text(title, game.title)
body.appendChild(title)
const meta = document.createElement('div')
meta.className = 'meta'
const mode = document.createElement('span')
mode.className = `badge badge-${game.mode}`
text(mode, game.mode === 'web' ? T.hosted : T.native)
mode.title = game.mode === 'web' ? T.hostedHint : T.nativeHint
meta.appendChild(mode)
const platform = document.createElement('span')
platform.className = 'badge badge-plain'
text(platform, game.platform)
meta.appendChild(platform)
const version = document.createElement('span')
version.className = 'version'
text(version, game.installed && game.installed_version
? `${game.installed_version} · ${T.installed}`
: game.version)
meta.appendChild(version)
body.appendChild(meta)
if (game.desc) {
const desc = document.createElement('p')
desc.className = 'desc'
text(desc, game.desc)
body.appendChild(desc)
}
const actions = document.createElement('div')
actions.className = 'actions'
if (game.installed && !game.update_available) {
const play = document.createElement('button')
play.className = 'btn btn-primary'
text(play, game.mode === 'web' ? T.open : T.play)
play.addEventListener('click', () => api.launch(game))
actions.appendChild(play)
} else {
const install = document.createElement('button')
install.className = 'btn btn-primary'
text(install, game.update_available ? T.update : T.install)
install.addEventListener('click', () => runSync([game.name]))
actions.appendChild(install)
}
if (game.installed) {
const remove = document.createElement('button')
remove.className = 'btn btn-ghost'
text(remove, T.remove)
remove.addEventListener('click', () => runRemove(game.name))
actions.appendChild(remove)
}
body.appendChild(actions)
node.appendChild(body)
return node
}
function renderGrid () {
const grid = el('grid')
grid.replaceChildren(...games.map(card))
grid.hidden = games.length === 0
const empty = el('empty')
empty.hidden = games.length !== 0
text(empty, T.noGames)
}
function renderPaths () {
const box = el('log-paths')
box.replaceChildren()
if (!paths) return
const line = document.createElement('div')
line.className = 'paths-line'
text(line, `${T.paths}: ${paths.store_folder} · ${paths.menu_group}`)
box.appendChild(line)
for (const [label, dir] of [[T.openStoreFolder, paths.store_folder],
[T.openMenuFolder, paths.menu_group]]) {
const button = document.createElement('button')
button.className = 'btn btn-tiny'
text(button, label)
button.addEventListener('click', () => api.openFolder(dir))
box.appendChild(button)
}
}
// --- actions --------------------------------------------------------------
async function refresh () {
try {
const result = await api.list()
games = result.games || []
paths = result.paths || paths
renderGrid()
renderPaths()
for (const reason of result.skipped || []) logLine(`skipped ${reason}`)
} catch (err) {
logLine(String(err && err.message ? err.message : err))
}
}
async function runSync (names) {
try {
await api.sync(names || [])
} catch (err) {
logLine(String(err && err.message ? err.message : err))
}
await refresh()
}
async function runRemove (name) {
try {
await api.remove(name)
} catch (err) {
logLine(String(err && err.message ? err.message : err))
}
await refresh()
}
// --- gate: no python, or no store yet -------------------------------------
function showGate (title, body, action, link) {
el('grid').hidden = true
el('empty').hidden = true
const gate = el('gate')
gate.hidden = false
text(el('gate-title'), title)
text(el('gate-body'), body)
const button = el('gate-action')
button.hidden = !action
if (action) {
text(button, action.label)
button.onclick = action.onClick
}
const anchor = el('gate-link')
anchor.hidden = !link
if (link) {
text(anchor, link.label)
anchor.onclick = (event) => { event.preventDefault(); api.openExternal(link.url) }
}
}
function hideGate () {
el('gate').hidden = true
}
// --- boot -----------------------------------------------------------------
function applyStrings (strings) {
T = strings
text(el('app-name'), T.appName)
text(el('sync-all'), T.syncAll)
text(el('refresh'), T.refresh)
text(el('log-toggle'), T.log)
renderPaths()
if (games.length) renderGrid()
}
async function boot () {
const state = await api.state()
applyStrings(state.strings)
const select = el('locale')
select.replaceChildren(...state.languages.map((code) => {
const option = document.createElement('option')
option.value = code
option.textContent = code.toUpperCase()
if (code === state.locale) option.selected = true
return option
}))
select.addEventListener('change', async () => {
const next = await api.setLocale(select.value)
applyStrings(next.strings)
})
if (!state.python) {
showGate(T.noPythonTitle, T.noPythonBody, null,
{ label: T.pythonLink, url: 'https://www.python.org/downloads/' })
return
}
const setUpStore = async () => {
showProgress(T.setupWorking)
try {
await api.bootstrap()
hideGate()
await refresh()
} catch (err) {
logLine(String(err && err.message ? err.message : err))
}
}
if (!state.store) {
showGate(T.setupTitle, `${T.setupBody}\n\n${state.defaultHome}`,
{ label: T.setupAction, onClick: async () => { await setUpStore(); await runSync([]) } })
return
}
if (state.engine && !state.engine.ok) {
showGate(T.oldEngineTitle,
`${T.oldEngineBody}\n\n${state.engine.text}${state.minEngine}`,
{ label: T.oldEngineAction, onClick: setUpStore })
return
}
text(el('store-id'), state.store.id)
hideGate()
await refresh()
}
el('sync-all').addEventListener('click', () => runSync([]))
el('refresh').addEventListener('click', () => refresh())
el('log-toggle').addEventListener('click', () => {
const box = el('log-lines')
box.hidden = !box.hidden
el('log-toggle').setAttribute('aria-expanded', String(!box.hidden))
})
api.onLog(logLine)
api.onBusy(setBusy)
api.onEvent((event) => {
if (event.event === 'plan') {
plan = { total: event.count, done: 0 }
showProgress(`0 ${T.of} ${event.count}`)
} else if (event.event === 'begin' && plan) {
showProgress(`${plan.done + 1} ${T.of} ${plan.total} · ${event.title}`)
} else if (event.event === 'installed' && plan) {
plan.done += 1
logLine(`${event.title}${event.changed ? T.installed : T.upToDate}`)
} else if (event.event === 'failed') {
logLine(`${event.name}: ${T.failed}${event.error}`)
} else if (event.event === 'removed') {
logLine(`${event.name}${T.removed}`)
}
})
boot()
+49
View File
@@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<!-- Nothing is loaded from the network except box art, and no inline code
runs: the app ships its own script and stylesheet. -->
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' https: data:; font-src 'self'; connect-src 'none'">
<title>WarpEngine Store</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="bar">
<div class="bar-title">
<span class="logo" aria-hidden="true"></span>
<span id="app-name">WarpEngine Store</span>
<span class="store-id" id="store-id"></span>
</div>
<div class="bar-actions">
<span class="progress" id="progress" hidden></span>
<button id="sync-all" class="btn btn-primary" disabled></button>
<button id="refresh" class="btn" disabled></button>
<select id="locale" class="select" aria-label="Language"></select>
</div>
</header>
<!-- Shown instead of the grid when there is nothing to drive yet. -->
<section id="gate" class="gate" hidden>
<h1 id="gate-title"></h1>
<p id="gate-body"></p>
<div class="gate-actions">
<button id="gate-action" class="btn btn-primary" hidden></button>
<a id="gate-link" class="link" href="#" hidden></a>
</div>
</section>
<main id="grid" class="grid" hidden></main>
<section id="empty" class="empty" hidden></section>
<footer class="log">
<button id="log-toggle" class="log-toggle" aria-expanded="false"></button>
<div class="log-lines" id="log-lines" hidden></div>
<div class="log-paths" id="log-paths"></div>
</footer>
<script src="app.js"></script>
</body>
</html>
+188
View File
@@ -0,0 +1,188 @@
/* One dark theme, no assets: the box art is the only image the app loads. */
:root {
--bg: #11151c;
--panel: #182029;
--panel-2: #1e2732;
--line: #2a3440;
--ink: #e8eef5;
--ink-dim: #93a4b8;
--accent: #37b98a;
--accent-ink: #05130d;
--warn: #e0a44a;
--radius: 12px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, sans-serif;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/* --- top bar ------------------------------------------------------------ */
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 18px;
background: var(--panel);
border-bottom: 1px solid var(--line);
flex: none;
}
.bar-title { display: flex; align-items: baseline; gap: 10px; font-weight: 700; }
.logo { color: var(--accent); font-size: 18px; }
.store-id {
font-weight: 500;
font-size: 12px;
color: var(--ink-dim);
border: 1px solid var(--line);
border-radius: 999px;
padding: 1px 8px;
}
.bar-actions { display: flex; align-items: center; gap: 8px; }
.progress { color: var(--ink-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
/* --- controls ----------------------------------------------------------- */
.btn {
font: inherit;
font-weight: 600;
color: var(--ink);
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 8px;
padding: 7px 14px;
cursor: pointer;
transition: background .15s, border-color .15s, transform .05s;
}
.btn:hover:not(:disabled) { background: #26313e; border-color: #3a4757; }
.btn:active:not(:disabled) { transform: scale(.97); }
.btn:disabled { opacity: .45; cursor: default; }
.btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
.btn-primary:hover:not(:disabled) { background: #45cd9b; }
.btn-ghost { background: transparent; color: var(--ink-dim); }
.btn-tiny { padding: 3px 9px; font-size: 12px; font-weight: 500; }
.select {
font: inherit;
color: var(--ink);
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 8px;
padding: 6px 8px;
}
.link { color: var(--accent); cursor: pointer; text-decoration: underline; }
/* --- gate (no python, or no store yet) ---------------------------------- */
.gate {
margin: auto;
max-width: 520px;
padding: 28px;
text-align: center;
}
.gate h1 { font-size: 20px; margin: 0 0 10px; }
.gate p { color: var(--ink-dim); white-space: pre-line; margin: 0 0 20px; word-break: break-all; }
.gate-actions { display: flex; gap: 14px; justify-content: center; align-items: center; }
/* --- the grid ----------------------------------------------------------- */
.grid {
flex: 1;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 14px;
padding: 18px;
align-content: start;
}
.empty { margin: auto; color: var(--ink-dim); }
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
display: flex;
flex-direction: column;
}
.card.is-installed { border-color: #2f5a49; }
.art {
aspect-ratio: 4 / 3;
background: #0d1117;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.art img { width: 100%; height: 100%; object-fit: cover; }
.art-glyph { font-size: 44px; font-weight: 700; color: #263341; }
.body { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.body h2 { font-size: 15px; margin: 0; }
.meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.badge {
font-size: 11px;
font-weight: 600;
border-radius: 999px;
padding: 2px 8px;
border: 1px solid var(--line);
color: var(--ink-dim);
}
.badge-app { color: var(--accent); border-color: #2f5a49; }
.badge-web { color: var(--warn); border-color: #5a4a2f; }
.version { font-size: 12px; color: var(--ink-dim); margin-left: auto; }
.desc {
margin: 0;
font-size: 12.5px;
color: var(--ink-dim);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.actions { display: flex; gap: 8px; margin-top: auto; }
/* --- log ---------------------------------------------------------------- */
.log {
flex: none;
background: var(--panel);
border-top: 1px solid var(--line);
padding: 8px 18px 10px;
}
.log-toggle {
font: inherit;
font-size: 12px;
font-weight: 600;
color: var(--ink-dim);
background: none;
border: 0;
padding: 0 0 4px;
cursor: pointer;
}
.log-lines {
max-height: 150px;
overflow-y: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11.5px;
color: var(--ink-dim);
background: #0d1117;
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px 10px;
margin-bottom: 6px;
}
.log-line { white-space: pre-wrap; word-break: break-all; }
.log-paths { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.paths-line {
font-size: 11.5px;
color: var(--ink-dim);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
word-break: break-all;
flex: 1;
min-width: 200px;
}
+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
})