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
+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;
}