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