1 Commits
Author SHA1 Message Date
mr.zeroandClaude Opus 5 cd5361e222 A side menu, categories, and a store switcher
Everything that is not a title moved out of the bar into a menu on the left that
folds away with the button in the bar; the state is remembered between runs. It
carries the stores on this machine, the two actions, the categories and the
language.

Categories narrow the grid one at a time, with counts: the state of a title on
this machine, then a row per platform and per kind. The axes are built from what
the catalog contains — a WarpEngine catalog has no genre — and a category that
disappears under you falls back to Everything rather than leaving a blank grid.

With more than one store installed, clicking another in the menu opens it: the
grid, the categories and the folders follow, the choice is remembered, and stores
sharing an id are told apart by their folder. app:state now reports every store,
store:use switches, and the engine is re-checked per store because two stores can
be at different versions.

While the CLI runs, the menu, the log drawer and the filters stay live; only what
would start a second call is disabled.

Fixes the grid, which was broken and passing every check: its implicit rows split
the window's height evenly instead of following their content, so every card came
out 94px tall with its box art collapsed to nothing and its buttons clipped away.
The DOM was intact throughout — ten cards, twenty buttons — which is exactly why
the counts said nothing. Rows are content-sized now, and the art is one band of
one height, with the title's first letter where the catalog has no image.

So the window is looked at and not only counted, `SELFTEST_SHOT` has it
photograph itself; the terminal has no screen-recording permission here. The
selftest also clicks the store that is not open when there are two, and checks
that the bar, the grid and the categories follow.

Also fixes scripts/release.sh, which could not upload a package whose name has a
space in it — "WarpEngine Store-1.1.0-arm64.dmg" does — because the list was one
string split on whitespace. And `make release` cleans dist/ first, so a release
cannot pick up the previous version's artifacts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 14:12:37 +02:00
12 changed files with 735 additions and 134 deletions
+3 -1
View File
@@ -71,7 +71,9 @@ dist-linux: node-check ## Package for Linux
publish: ## Upload the packages already in dist/ to the Gitea release
@TAG=$(TAG) $(SCRIPTS)/release.sh
release: dist publish ## Package for this machine and publish it
# clean first: dist/ keeps earlier builds, and a release should be made of exactly
# what this version produced.
release: clean dist publish ## Package for this machine and publish it
clean: ## Remove the built packages
rm -rf dist
+50 -9
View File
@@ -95,15 +95,44 @@ panel — and not a release of this app.
## 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**.
Everything that is not a title lives in the **side menu** on the left, and the
`☰` button in the bar folds it away — the state is remembered between runs.
- **Stores** lists every store on this machine, the open one marked. Clicking
another switches to it: the grid, the categories and the folders all follow, and
the client reopens on that store next time. Two stores installed from the same
catalog into different folders show their folder instead of their id, because
the id would not tell them apart. **Add a store…** brings up the registry
picker, the same one the first run offers.
- **Actions** holds **Install all**, which fetches everything the catalog offers
for this machine, and **Refresh**, which re-reads the catalog.
- **Categories** narrows the grid, one category at a time, with the count next to
each: *Everything*, *Installed*, *Updates*, *Not installed*, then a row per
**platform** (`godot`, `tic80`, `love`, …) and per **kind** (native or hosted).
The axes are built from what the catalog actually contains — a platform with no
titles is not listed, and a category that disappears under you falls back to
*Everything* rather than leaving an empty grid. There is no genre in a
WarpEngine catalog, so these are the categories there are.
- **Language** follows the system and can be switched; **English and Hungarian**.
In the grid, 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.
Every card carries a band of box art the same height — the first letter of the
title when the catalog has no image — so titles and buttons line up across a row.
Until this was photographed, the grid was quietly broken: the rows split the
window's height evenly instead of following their content, which collapsed the art
to nothing and clipped the buttons out of sight.
While the store is working, only the things that would start a second call are
disabled: the menu, the log drawer and the category filters keep working, because
they change what is on screen and nothing on disk.
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.
@@ -119,6 +148,7 @@ names. `make` on its own lists everything.
| `make start` | run the app against whatever store is installed |
| `make smoke` | drive the store bridge with no window at all |
| `make uitest` | load the window once and report what rendered |
| `SELFTEST_SHOT=shot.png npm run uitest` | the same, and the window photographs itself into that file |
| `make test` | both checks |
| `make dist` | package for this machine (`dist-mac`, `dist-win`, `dist-linux` to pick) |
| `make publish` | upload the packages already in `dist/` to the Gitea release |
@@ -203,6 +233,17 @@ 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.
The grid was checked by looking at it, not only by counting nodes: `SELFTEST_SHOT`
has the window capture itself, which is how the collapsed rows were found — the DOM
had ten cards and twenty buttons all along, and every count passed while the page
showed neither art nor buttons. A screenshot from outside the app is not available
here, so the window takes its own.
The side menu was measured with two stores in one root — a sandbox copy alongside
the real install — and `npm run uitest` clicks the store that is not open and
checks that the bar, the grid and the categories follow. With a single store the
switch is skipped, which is what the normal run reports.
The registry path was exercised against a local endpoint serving the same payload
the site returns, with two records: one store whose repository has a `config.json`
and one without. Both installed, and the engine listed all ten titles with the
+33 -21
View File
@@ -1,24 +1,32 @@
The client no longer carries a store address. It asks the site which stores exist
`GET /api/stores` — and installs what comes back: one record and there is
nothing to decide, several and the setup screen shows a picker.
# WarpEngine Store 1.2.0
Adding a store is now a database row on the site, maintained from its admin panel,
rather than a release of this app.
**A side menu.** Everything that is not a title moved out of the bar into a menu on
the left that folds away with `☰`: the stores on this machine, the two actions, the
categories and the language. Open or closed is remembered between runs.
### What a record gives it
**Categories.** The grid narrows to *Installed*, *Updates* or *Not installed*, to a
platform (`godot`, `tic80`, `love`, …), or to native/hosted titles — one at a time,
each with its count. The axes are built from what the catalog actually contains, so
nothing empty is listed, and a category that disappears under you falls back to
*Everything* rather than leaving a blank grid.
- **`storeRepositoryUrl`** → the store's `config.json`, which stays the authority
on how that store behaves: platforms, statuses, where things land.
- **`catalogUrl`** and **`name`** override the config's `store.base_url` and
`store.name` — the registry is what says which catalog a store is *for*.
- **the store id**, from the repository name: `ttg-desktop-store` becomes `ttg`.
**Switching stores.** With more than one store installed, clicking another in the
menu opens it: the grid, the categories and the folders follow, and the client
reopens on the store last used. Two stores installed from the same catalog into
different folders are told apart by their folder, since their id is identical.
A store repository with no `config.json` still installs. The engine merges
whatever it is handed onto its own defaults, so the client writes a three-field
config and the store behaves like the default one pointed at that catalog.
While the store is working, the menu, the log drawer and the filters keep working —
only what would start a second call is disabled.
`STORES_API` overrides the registry address, which is the one thing about a
particular site left in the client.
**The grid was broken, and now is not.** Its rows split the window's height evenly
rather than following their content, so every card came out 94px tall: the box art
collapsed to nothing and the action buttons were clipped away below the fold. The
DOM was intact the whole time — ten cards, twenty buttons — which is why every
automated count passed. Cards now carry a band of art of one height, with the
title's first letter where the catalog has no image.
Unchanged from 1.1.0: which stores exist is the site's answer (`GET /api/stores`),
not something baked into this app, and `STORES_API` overrides that address.
### Opening it on macOS
@@ -39,8 +47,12 @@ Linux packages need a build on those platforms (`make dist-win` / `dist-linux`).
### Verified
Against a local endpoint serving the same payload the site returns, with two
records — one store whose repository has a `config.json` and one without. Both
installed, and the engine listed all ten titles with the synthesised config. The
window was checked on both outcomes: the grid with a store present, the setup gate
with a populated picker when there is none.
`SELFTEST_SHOT=shot.png npm run uitest` has the window photograph itself, which is
how the collapsed rows were found and how the fix was confirmed — in English and in
Hungarian, with the menu open and closed.
`npm run uitest` loads the window and reports what rendered; with two stores in one
root — a sandbox copy beside the real install — it now also clicks the store that
is not open and checks that the bar, the grid and the categories follow. Both runs
pass, and `npm run smoke` drives the same bridge with no window at all: ten titles,
five native and five hosted, every installed one with something to launch.
+28
View File
@@ -22,7 +22,21 @@ const STRINGS = {
nativeHint: 'Installed on this machine — works offline',
updateAvailable: 'update available',
log: 'Log',
menu: 'Menu',
stores: 'Stores',
addStore: 'Add a store…',
switchFailed: 'That store could not be opened',
actions: 'Actions',
categories: 'Categories',
catAll: 'Everything',
catInstalled: 'Installed',
catUpdates: 'Updates',
catAvailable: 'Not installed',
catPlatform: 'Platform',
catMode: 'Kind',
language: 'Language',
noGames: 'No installable titles in the catalog.',
noMatch: 'Nothing in this category.',
setupTitle: 'Set up a store',
setupBody: 'No store on this machine yet. Pick one and it will be downloaded — the same files the shell installer would place, in the same folder.',
setupAction: 'Download the store',
@@ -62,7 +76,21 @@ const STRINGS = {
nativeHint: 'Erre a gépre telepítve — internet nélkül is megy',
updateAvailable: 'frissítés elérhető',
log: 'Napló',
menu: 'Menü',
stores: 'Store-ok',
addStore: 'Store hozzáadása…',
switchFailed: 'Ez a store nem nyitható meg',
actions: 'Műveletek',
categories: 'Kategóriák',
catAll: 'Minden',
catInstalled: 'Telepítve',
catUpdates: 'Frissítés',
catAvailable: 'Nincs telepítve',
catPlatform: 'Platform',
catMode: 'Fajta',
language: 'Nyelv',
noGames: 'Nincs telepíthető cím a katalógusban.',
noMatch: 'Ebben a kategóriában nincs semmi.',
setupTitle: 'Store beállítása',
setupBody: 'Ezen a gépen még nincs store. Válassz egyet, és letöltöm — ugyanazokat a fájlokat, ugyanabba a könyvtárba, ahová a shell-telepítő tenné.',
setupAction: 'Store letöltése',
+34 -4
View File
@@ -65,8 +65,37 @@ function findStores () {
return found
}
function findStore () {
return findStores()[0] || null
/**
* The store to drive: the one asked for by home if it is still there, otherwise
* the first one found. The client remembers the choice, so a machine with two
* stores reopens on the one last used rather than on whichever sorts first.
*/
function findStore (preferredHome) {
const stores = findStores()
if (preferredHome) {
const wanted = stores.find((store) => store.home === preferredHome)
if (wanted) return wanted
}
return stores[0] || null
}
/**
* The store's own name, from the config the installer wrote. Worth reading here
* rather than waiting for `paths`: the switcher lists every store on the machine,
* and starting a Python process per entry to learn its name would be absurd.
*/
function storeName (store) {
try {
const config = JSON.parse(fs.readFileSync(store.config, 'utf8'))
return (config.store && config.store.name) || store.id
} catch {
return store.id
}
}
/** An installed store as the window needs it. */
function describe (store) {
return store && { id: store.id, home: store.home, engine: store.engine, name: storeName(store) }
}
/** Where a store would be installed if there is none yet. */
@@ -223,6 +252,7 @@ function purge (store, hooks) {
}
module.exports = {
ENGINES, MIN_ENGINE, StoreError, atLeast, defaultHome, engineVersion, findPython,
findStore, findStores, list, parseVersion, paths, purge, remove, run, storeRoots, sync
ENGINES, MIN_ENGINE, StoreError, atLeast, defaultHome, describe, engineVersion,
findPython, findStore, findStores, list, parseVersion, paths, purge, remove, run,
storeName, storeRoots, sync
}
+97 -8
View File
@@ -78,18 +78,69 @@ async function selftest () {
gateAction: document.getElementById('gate-action').textContent,
appName: document.getElementById('app-name').textContent,
storeId: document.getElementById('store-id').textContent,
navOpen: !document.body.classList.contains('nav-closed'),
stores: [...document.querySelectorAll('#store-list .store-row')].map((n) => n.textContent),
categories: [...document.querySelectorAll('#cats .cat')].map((n) => n.textContent),
activeCategory: (document.querySelector('#cats .cat.is-active') || {}).textContent || null,
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))
// Either outcome is a pass: a grid when a store is installed, or the setup gate
// with something to choose from when there is none.
// With two stores on the machine the switcher is the thing most likely to be
// broken without anyone noticing, so the test uses it: click the store that is
// not open and see whether the window follows. Skipped when there is only one,
// which is the normal case — a single store cannot be switched away from.
let switched = null
if (result.stores.length > 1) {
switched = await win.webContents.executeJavaScript(`(async () => {
const other = [...document.querySelectorAll('#store-list .store-row')]
.find((row) => !row.classList.contains('is-active'))
other.click()
await new Promise((done) => setTimeout(done, 8000))
return {
storeId: document.getElementById('store-id').textContent,
active: (document.querySelector('#store-list .store-row.is-active') || {}).textContent || null,
cards: document.querySelectorAll('.card').length,
categories: document.querySelectorAll('#cats .cat').length
}
})()`)
console.log(`switched: ${JSON.stringify(switched)}`)
}
// A layout mistake does not show up in the DOM counts above, so on request the
// window photographs itself — the terminal cannot screenshot it from outside.
if (process.env.SELFTEST_SHOT) {
// capturePage hands back the last painted frame, so a window that is behind
// others — or still loading box art — photographs as a half-drawn page. Focus
// it, wait for the images, then let one frame go by.
win.show()
win.focus()
await win.webContents.executeJavaScript(`(async () => {
await Promise.all([...document.images].map((img) => img.complete
? null
: new Promise((done) => { img.onload = done; img.onerror = done })))
await new Promise((done) => requestAnimationFrame(() => setTimeout(done, 400)))
return document.images.length
})()`)
const image = await win.webContents.capturePage()
fs.writeFileSync(process.env.SELFTEST_SHOT, image.toPNG())
console.log(`shot: ${process.env.SELFTEST_SHOT}`)
}
// Either outcome is a pass: a grid when a store is installed — with the side
// menu populated, which is the part a blank render would silently lose — or the
// setup gate with something to choose from when there is none.
const good = result.locales.length > 1 && (
(result.cards > 0 && !result.gateVisible) ||
(result.cards > 0 && !result.gateVisible &&
result.stores.length > 0 && result.categories.length > 0 && result.activeCategory) ||
(result.gateVisible && result.gateChoices.length > 0 && result.gateAction))
console.log(good ? 'SELFTEST OK' : 'SELFTEST FAILED')
app.exit(good ? 0 : 1)
const switchGood = switched === null || (
switched.storeId && switched.storeId !== result.storeId &&
switched.cards > 0 && switched.categories > 0)
console.log(good && switchGood ? 'SELFTEST OK' : 'SELFTEST FAILED')
app.exit(good && switchGood ? 0 : 1)
}
function createWindow () {
@@ -145,7 +196,10 @@ function createWindow () {
ipcMain.handle('app:state', () => {
const prefs = loadPrefs()
const python = store.findPython()
current = store.findStore()
// Every store on the machine, because the window offers a switch between them —
// and the remembered one wins, so reopening lands where the user left off.
const stores = store.findStores()
current = store.findStore(prefs.store)
// 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
@@ -153,8 +207,10 @@ ipcMain.handle('app:state', () => {
locale: i18n.pick(prefs.locale || app.getLocale()),
languages: i18n.languages,
strings: i18n.dict(prefs.locale || app.getLocale()),
nav: prefs.nav !== false,
python: python ? python.version : null,
store: current ? { id: current.id, home: current.home } : null,
store: store.describe(current),
stores: stores.map(store.describe),
engine: engine ? { text: engine.text, ok: engine.ok } : null,
minEngine: store.MIN_ENGINE.join('.'),
registryUrl: bootstrap.REGISTRY_URL,
@@ -163,6 +219,36 @@ ipcMain.handle('app:state', () => {
}
})
// The side menu's open/closed state is worth keeping between runs; it is the one
// preference the window sets that is not a language.
ipcMain.handle('app:setNav', (_event, open) => {
const prefs = loadPrefs()
prefs.nav = Boolean(open)
savePrefs(prefs)
return prefs.nav
})
/**
* Switch to another installed store.
*
* The choice is remembered, and the engine is checked here rather than in the
* window: two stores on one machine can be at different versions, and the one
* being switched to may be the older one.
*/
ipcMain.handle('store:use', (_event, home) => {
const wanted = store.findStores().find((candidate) => candidate.home === home)
if (!wanted) throw new Error('that store is no longer on this machine')
current = wanted
const prefs = loadPrefs()
prefs.store = wanted.home
savePrefs(prefs)
const engine = store.engineVersion(current)
return {
store: store.describe(current),
engine: engine ? { text: engine.text, ok: engine.ok } : null
}
})
ipcMain.handle('app:setLocale', (_event, locale) => {
const prefs = loadPrefs()
prefs.locale = i18n.pick(locale)
@@ -195,7 +281,10 @@ ipcMain.handle('store:bootstrap', (_event, chosen) => guarded(async () => {
const home = bootstrap.homeFor(chosen, store.storeRoots()[0])
const result = await bootstrap.install(home, chosen, { onLog: (line) => send('store:log', line) })
current = { engine: 'desktop', ...result }
return { id: current.id, home: current.home, name: chosen.name }
const prefs = loadPrefs()
prefs.store = current.home
savePrefs(prefs)
return { ...store.describe(current), name: chosen.name }
}))
/**
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "warp-engine-desktop-gui",
"productName": "WarpEngine Store",
"version": "1.1.0",
"version": "1.2.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>",
+2
View File
@@ -7,11 +7,13 @@ const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('storeApi', {
state: () => ipcRenderer.invoke('app:state'),
setLocale: (locale) => ipcRenderer.invoke('app:setLocale', locale),
setNav: (open) => ipcRenderer.invoke('app:setNav', open),
list: () => ipcRenderer.invoke('store:list'),
paths: () => ipcRenderer.invoke('store:paths'),
sync: (names) => ipcRenderer.invoke('store:sync', names),
remove: (name) => ipcRenderer.invoke('store:remove', name),
use: (home) => ipcRenderer.invoke('store:use', home),
registry: () => ipcRenderer.invoke('store:registry'),
bootstrap: (store) => ipcRenderer.invoke('store:bootstrap', store),
launch: (game) => ipcRenderer.invoke('store:launch', game),
+243 -44
View File
@@ -1,16 +1,22 @@
'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.
// The whole renderer. No framework and no build step: a side menu on the left
// decides what is shown, a grid of cards on the right shows it, 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 state = null // what the main process knows: stores, python, engine
let games = []
let paths = null
let busy = false
let plan = null // { total, done } while a sync is running
// What the grid is narrowed down to. One category at a time on purpose: a matrix
// of filters would need explaining, and a catalog of this size does not earn it.
let filter = { kind: 'group', value: 'all' }
// --- helpers --------------------------------------------------------------
function text (node, value) {
@@ -34,10 +40,14 @@ function logLine (line) {
box.scrollTop = box.scrollHeight
}
// While the CLI runs, anything that would start a second call is disabled. The
// menu toggle, the log drawer and the category filters are not among them: they
// only change what is on screen.
function setBusy (value) {
busy = value
for (const node of document.querySelectorAll('button')) {
if (node.id === 'log-toggle') continue
if (node.id === 'log-toggle' || node.id === 'nav-toggle') continue
if (node.classList.contains('cat')) continue
node.disabled = value
}
const progress = el('progress')
@@ -53,6 +63,136 @@ function showProgress (label) {
text(progress, label)
}
// --- the side menu --------------------------------------------------------
function setNav (open) {
document.body.classList.toggle('nav-closed', !open)
el('nav-toggle').setAttribute('aria-expanded', String(open))
}
function renderStores () {
const box = el('store-list')
const stores = (state && state.stores) || []
const active = state && state.store ? state.store.home : null
// Two stores can carry the same id in different roots — the same catalog
// installed twice. Then the id says nothing and the folder is what tells them
// apart, so that is what the row shows.
const ambiguous = new Set(stores
.filter((store, index) => stores.findIndex((other) => other.id === store.id) !== index)
.map((store) => store.id))
box.replaceChildren(...stores.map((store) => {
const row = document.createElement('button')
row.className = 'store-row'
if (store.home === active) row.classList.add('is-active')
const name = document.createElement('span')
name.className = 'store-row-name'
text(name, store.name)
row.appendChild(name)
const id = document.createElement('span')
id.className = 'store-row-id'
text(id, ambiguous.has(store.id) ? store.home : store.id)
row.appendChild(id)
row.title = store.home
row.addEventListener('click', () => {
if (store.home !== active) switchStore(store.home)
})
return row
}))
el('add-store').hidden = false
}
/**
* The categories, built from what the catalog actually contains.
*
* There is no genre in a WarpEngine catalog, so the useful axes are the state of
* a title on this machine, the platform it was built with, and whether it runs
* here or in a browser. Empty axes are left out rather than shown as zeroes.
*/
function categories () {
const count = (fn) => games.filter(fn).length
const sections = [{
group: null,
items: [
{ kind: 'group', value: 'all', label: T.catAll, count: games.length },
{ kind: 'group', value: 'installed', label: T.catInstalled, count: count((g) => g.installed) },
{ kind: 'group', value: 'updates', label: T.catUpdates, count: count((g) => g.update_available) },
{ kind: 'group', value: 'available', label: T.catAvailable, count: count((g) => !g.installed) }
].filter((item) => item.value === 'all' || item.count > 0)
}]
const platforms = [...new Set(games.map((g) => g.platform).filter(Boolean))].sort()
if (platforms.length > 1) {
sections.push({
group: T.catPlatform,
items: platforms.map((platform) => ({
kind: 'platform', value: platform, label: platform, count: count((g) => g.platform === platform)
}))
})
}
const modes = [...new Set(games.map((g) => g.mode).filter(Boolean))]
if (modes.length > 1) {
sections.push({
group: T.catMode,
items: modes.map((mode) => ({
kind: 'mode', value: mode, label: mode === 'web' ? T.hosted : T.native, count: count((g) => g.mode === mode)
}))
})
}
return sections
}
function matches (game) {
if (filter.kind === 'platform') return game.platform === filter.value
if (filter.kind === 'mode') return game.mode === filter.value
if (filter.value === 'installed') return Boolean(game.installed)
if (filter.value === 'updates') return Boolean(game.update_available)
if (filter.value === 'available') return !game.installed
return true
}
function renderCats () {
const box = el('cats')
const sections = categories()
// A category can vanish under us — the last title of a platform is removed, or
// an update is applied — and a filter matching nothing would look like an empty
// catalog. Falling back to everything is the honest answer.
const known = sections.flatMap((section) => section.items)
.some((item) => item.kind === filter.kind && item.value === filter.value)
if (!known) filter = { kind: 'group', value: 'all' }
const nodes = []
for (const section of sections) {
if (section.group) {
const head = document.createElement('div')
head.className = 'cat-group'
text(head, section.group)
nodes.push(head)
}
for (const item of section.items) {
const button = document.createElement('button')
button.className = 'cat'
if (item.kind === filter.kind && item.value === filter.value) button.classList.add('is-active')
const label = document.createElement('span')
label.className = 'cat-label'
text(label, item.label)
button.appendChild(label)
const count = document.createElement('span')
count.className = 'cat-count'
text(count, item.count)
button.appendChild(count)
button.addEventListener('click', () => {
filter = { kind: item.kind, value: item.value }
renderCats()
renderGrid()
})
nodes.push(button)
}
}
box.replaceChildren(...nodes)
}
// --- the card grid --------------------------------------------------------
function card (game) {
@@ -70,6 +210,8 @@ function card (game) {
img.loading = 'lazy'
art.appendChild(img)
} else {
// No box art in the catalog: the first letter, on the same band an image
// would fill, so a row of cards stays aligned either way.
const glyph = document.createElement('span')
glyph.className = 'art-glyph'
text(glyph, game.title.slice(0, 1).toUpperCase())
@@ -141,12 +283,14 @@ function card (game) {
}
function renderGrid () {
const shown = games.filter(matches)
const grid = el('grid')
grid.replaceChildren(...games.map(card))
grid.hidden = games.length === 0
grid.replaceChildren(...shown.map(card))
grid.hidden = shown.length === 0
grid.scrollTop = 0
const empty = el('empty')
empty.hidden = games.length !== 0
text(empty, T.noGames)
empty.hidden = shown.length !== 0
text(empty, games.length === 0 ? T.noGames : T.noMatch)
}
function renderPaths () {
@@ -175,6 +319,7 @@ async function refresh () {
const result = await api.list()
games = result.games || []
paths = result.paths || paths
renderCats()
renderGrid()
renderPaths()
for (const reason of result.skipped || []) logLine(`skipped ${reason}`)
@@ -201,7 +346,37 @@ async function runRemove (name) {
await refresh()
}
// --- gate: no python, or no store yet -------------------------------------
/** Open another store that is already on this machine. */
async function switchStore (home) {
try {
const next = await api.use(home)
state.store = next.store
state.engine = next.engine
text(el('store-id'), next.store.id)
renderStores()
games = []
paths = null
filter = { kind: 'group', value: 'all' }
if (next.engine && !next.engine.ok) {
renderCats()
showOldEngineGate()
return
}
hideGate()
await refresh()
} catch (err) {
logLine(`${T.switchFailed}: ${err && err.message ? err.message : err}`)
}
}
/** Pick up a store that appeared since the window opened. */
async function reloadState () {
state = await api.state()
text(el('store-id'), state.store ? state.store.id : '')
renderStores()
}
// --- gate: no python, no store yet, or an engine too old ------------------
function showGate (title, body, action, link, choices) {
el('grid').hidden = true
@@ -244,6 +419,44 @@ function hideGate () {
el('gate').hidden = true
}
function showOldEngineGate () {
showGate(T.oldEngineTitle,
`${T.oldEngineBody}\n\n${state.engine.text}${state.minEngine}`,
{ label: T.oldEngineAction, onClick: offerStores })
}
async function setUpStore (chosen) {
showProgress(T.setupWorking)
try {
await api.bootstrap(chosen)
await reloadState()
hideGate()
await refresh()
} catch (err) {
logLine(String(err && err.message ? err.message : err))
}
}
// Which stores exist is the site's answer, not this client's: the registry is
// asked for it, and its records carry the catalog and the config repository.
async function offerStores () {
const result = await api.registry()
if (result.error) {
showGate(`${T.registryFailed}`, `${result.url}\n\n${result.error}`,
{ label: T.registryRetry, onClick: offerStores })
return
}
if (!result.stores.length) {
showGate(T.setupTitle, `${T.registryEmpty}\n\n${result.url}`, null)
return
}
showGate(T.setupTitle,
`${T.setupBody}\n\n${state.storeRoot}`,
{ label: T.setupAction, onClick: async (chosen) => { await setUpStore(chosen); await runSync([]) } },
null,
result.stores)
}
// --- boot -----------------------------------------------------------------
function applyStrings (strings) {
@@ -252,13 +465,26 @@ function applyStrings (strings) {
text(el('sync-all'), T.syncAll)
text(el('refresh'), T.refresh)
text(el('log-toggle'), T.log)
text(el('head-stores'), T.stores)
text(el('head-actions'), T.actions)
text(el('head-cats'), T.categories)
text(el('head-lang'), T.language)
text(el('add-store'), T.addStore)
el('nav-toggle').title = T.menu
el('nav-toggle').setAttribute('aria-label', T.menu)
renderPaths()
if (games.length) renderGrid()
if (state) renderStores()
if (games.length) {
renderCats()
renderGrid()
}
}
async function boot () {
const state = await api.state()
state = await api.state()
applyStrings(state.strings)
setNav(state.nav !== false)
renderStores()
const select = el('locale')
select.replaceChildren(...state.languages.map((code) => {
@@ -279,46 +505,13 @@ async function boot () {
return
}
const setUpStore = async (chosen) => {
showProgress(T.setupWorking)
try {
await api.bootstrap(chosen)
hideGate()
await refresh()
} catch (err) {
logLine(String(err && err.message ? err.message : err))
}
}
// Which stores exist is the site's answer, not this client's: the registry is
// asked for it, and its records carry the catalog and the config repository.
const offerStores = async () => {
const result = await api.registry()
if (result.error) {
showGate(`${T.registryFailed}`, `${result.url}\n\n${result.error}`,
{ label: T.registryRetry, onClick: offerStores })
return
}
if (!result.stores.length) {
showGate(T.setupTitle, `${T.registryEmpty}\n\n${result.url}`, null)
return
}
showGate(T.setupTitle,
`${T.setupBody}\n\n${state.storeRoot}`,
{ label: T.setupAction, onClick: async (chosen) => { await setUpStore(chosen); await runSync([]) } },
null,
result.stores)
}
if (!state.store) {
await offerStores()
return
}
if (state.engine && !state.engine.ok) {
showGate(T.oldEngineTitle,
`${T.oldEngineBody}\n\n${state.engine.text}${state.minEngine}`,
{ label: T.oldEngineAction, onClick: offerStores })
showOldEngineGate()
return
}
@@ -329,6 +522,12 @@ async function boot () {
el('sync-all').addEventListener('click', () => runSync([]))
el('refresh').addEventListener('click', () => refresh())
el('add-store').addEventListener('click', () => offerStores())
el('nav-toggle').addEventListener('click', () => {
const open = document.body.classList.contains('nav-closed')
setNav(open)
api.setNav(open)
})
el('log-toggle').addEventListener('click', () => {
const box = el('log-lines')
box.hidden = !box.hidden
+57 -25
View File
@@ -11,6 +11,9 @@
</head>
<body>
<header class="bar">
<button id="nav-toggle" class="nav-toggle" aria-controls="side" aria-expanded="true">
<span aria-hidden="true"></span>
</button>
<div class="bar-title">
<span class="logo" aria-hidden="true"></span>
<span id="app-name">WarpEngine Store</span>
@@ -18,35 +21,64 @@
</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">
<label id="gate-choice" class="gate-choice" hidden>
<span id="gate-choice-label"></span>
<select id="gate-select" class="select"></select>
</label>
<button id="gate-action" class="btn btn-primary" hidden></button>
<a id="gate-link" class="link" href="#" hidden></a>
<div class="shell">
<!-- The side menu: which store, what to do with it, and what to look at.
Collapsed with the button in the bar; the choice is remembered. -->
<aside class="side" id="side">
<section class="side-block">
<h2 class="side-head" id="head-stores"></h2>
<div class="store-list" id="store-list"></div>
<button id="add-store" class="btn btn-ghost btn-wide"></button>
</section>
<section class="side-block">
<h2 class="side-head" id="head-actions"></h2>
<button id="sync-all" class="btn btn-primary btn-wide" disabled></button>
<button id="refresh" class="btn btn-wide" disabled></button>
</section>
<section class="side-block side-cats">
<h2 class="side-head" id="head-cats"></h2>
<nav class="cats" id="cats"></nav>
</section>
<section class="side-block side-foot">
<label class="side-lang">
<span id="head-lang"></span>
<select id="locale" class="select" aria-label="Language"></select>
</label>
</section>
</aside>
<div class="content">
<!-- 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">
<label id="gate-choice" class="gate-choice" hidden>
<span id="gate-choice-label"></span>
<select id="gate-select" class="select"></select>
</label>
<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>
</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>
</div>
<script src="app.js"></script>
</body>
+136 -5
View File
@@ -25,18 +25,138 @@ body {
overflow: hidden;
}
/* --- shell: the side menu and everything else --------------------------- */
.shell {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden; /* so the collapsed menu is clipped rather than scrolled to */
}
.content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.side {
--side-width: 244px;
width: var(--side-width);
flex: none;
background: var(--panel);
border-right: 1px solid var(--line);
/* The menu itself does not scroll: the category list does. Letting the whole
column scroll made the bottom block — pinned there with margin-top: auto —
sit on top of the overflowing categories. */
overflow: hidden;
padding: 14px 12px 16px;
display: flex;
flex-direction: column;
gap: 18px;
transition: margin-left .16s ease-out;
}
body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
.side-block { display: flex; flex-direction: column; gap: 6px; flex: none; }
.side-cats { flex: 1; min-height: 0; }
.side-foot { padding-top: 12px; border-top: 1px solid var(--line); }
.side-head {
font-size: 11px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--ink-dim);
margin: 0 0 2px 4px;
}
.btn-wide { width: 100%; text-align: left; }
.nav-toggle {
font: inherit;
font-size: 15px;
line-height: 1;
color: var(--ink-dim);
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
}
.nav-toggle:hover { color: var(--ink); border-color: #3a4757; }
/* Store switcher: one row per store on this machine, the open one marked. */
.store-list { display: flex; flex-direction: column; gap: 4px; }
.store-row {
font: inherit;
text-align: left;
color: var(--ink);
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 1px;
}
.store-row:hover:not(:disabled) { background: var(--panel-2); }
.store-row.is-active {
background: var(--panel-2);
border-color: #2f5a49;
}
.store-row .store-row-name { font-weight: 600; }
.store-row .store-row-id {
font-size: 11px;
color: var(--ink-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-row:disabled { opacity: .55; cursor: default; }
/* Categories: what the catalog is filtered down to. */
.cats { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; min-height: 0; }
.cat {
font: inherit;
text-align: left;
color: var(--ink-dim);
background: transparent;
border: 0;
border-radius: 8px;
padding: 5px 10px;
cursor: pointer;
display: flex;
align-items: baseline;
gap: 8px;
}
.cat:hover { background: var(--panel-2); color: var(--ink); }
.cat.is-active { background: var(--panel-2); color: var(--ink); font-weight: 600; }
.cat-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cat-count { margin-left: auto; font-size: 11px; font-variant-numeric: tabular-nums; }
.cat-group {
font-size: 10.5px;
font-weight: 700;
letter-spacing: .07em;
text-transform: uppercase;
color: #6b7d92;
padding: 8px 10px 2px;
}
.side-lang { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-dim); }
.side-lang .select { margin-left: auto; }
/* --- top bar ------------------------------------------------------------ */
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 18px;
gap: 12px;
padding: 10px 18px;
background: var(--panel);
border-bottom: 1px solid var(--line);
flex: none;
}
.bar-title { display: flex; align-items: baseline; gap: 10px; font-weight: 700; }
.bar-title { display: flex; align-items: baseline; gap: 10px; font-weight: 700; margin-right: auto; }
.logo { color: var(--accent); font-size: 18px; }
.store-id {
font-weight: 500;
@@ -95,12 +215,20 @@ body {
flex: 1;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
/* Narrower than it was: the side menu takes 244px off the window, and at 260px
a 1040px window had room for only two columns. */
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 14px;
padding: 18px;
/* Content-sized rows, spelled out. Left to `auto` the implicit rows split the
grid's height evenly — every card came out 94px tall with its box art
collapsed to nothing and its buttons clipped away, which is how the grid
looked before this was measured. */
grid-auto-rows: max-content;
align-content: start;
}
.empty { margin: auto; color: var(--ink-dim); }
.gate { overflow-y: auto; }
.card {
background: var(--panel);
@@ -113,7 +241,10 @@ body {
.card.is-installed { border-color: #2f5a49; }
.art {
aspect-ratio: 4 / 3;
/* One height for every card, art or not, so the titles and the buttons line up
across a row. The images are cropped anyway (object-fit: cover). */
height: 148px;
flex: none;
background: #0d1117;
display: flex;
align-items: center;
+51 -16
View File
@@ -23,8 +23,23 @@ command -v tea >/dev/null 2>&1 || die "tea is not installed — see the devarea
command -v python3 >/dev/null 2>&1 || die "python3 is required"
[ -f package.json ] || die "run this from the repository root"
VERSION="$(python3 -c 'import json; print(json.load(open("package.json"))["version"])')"
# Version and title in one go, through a heredoc rather than a quoted one-liner:
# nesting python quoting inside shell quoting inside a command substitution is how
# this produced an empty title on its first outing.
VERSION="$(python3 - <<'PY'
import json
print(json.load(open("package.json"))["version"])
PY
)"
TITLE="$(python3 - <<'PY'
import json
d = json.load(open("package.json"))
print((d.get("productName") or d["name"]) + " " + d["version"])
PY
)"
TAG="${TAG:-v$VERSION}"
[ -n "$VERSION" ] || die "cannot read the version from package.json"
[ -n "$TITLE" ] || die "cannot work out a release title"
# The repository is whatever this checkout pushes to, so a fork publishes to the
# fork without editing anything.
@@ -32,40 +47,60 @@ REPO="${REPO:-$(git remote get-url origin 2>/dev/null |
sed -e 's#.*[:/]\([^/]*/[^/]*\)$#\1#' -e 's#\.git$##')}"
[ -n "$REPO" ] || die "cannot work out the Gitea repo — set REPO=owner/name"
# What to upload: the arguments, or every package dist/ holds.
# What to upload: the arguments, or the packages in dist/ that belong to *this*
# version. Two things this has to get right:
#
# - the version filter, because dist/ keeps whatever earlier builds left there
# and a release would quietly get the previous version's files attached;
# - the spaces. "WarpEngine Store-1.1.0-arm64.dmg" has one, so the list lives one
# path per line in a file and is read with `while IFS= read -r`. Holding it in
# a single variable and looping over $list splits it on the space.
LIST="$(mktemp)"
trap 'rm -f "$LIST"' EXIT
if [ "$#" -gt 0 ]; then
ASSETS="$*"
for given in "$@"; do printf '%s\n' "$given"; done > "$LIST"
else
ASSETS="$(find "$DIST" -maxdepth 1 -type f \
find "$DIST" -maxdepth 1 -type f -name "*$VERSION*" \
\( -name '*.dmg' -o -name '*-mac.zip' -o -name '*.exe' -o -name '*.AppImage' -o -name '*.deb' \) \
2>/dev/null | sort || true)"
2>/dev/null | sort > "$LIST" || true
fi
[ -n "$ASSETS" ] || die "no packages in $DIST — run 'make dist' first"
[ -s "$LIST" ] || die "no $VERSION packages in $DIST — run 'make dist' first"
say "$REPO $TAG (version $VERSION), login $LOGIN"
# The release id, or empty when there is no such tag. `tea api` exits 0 even for a
# 404 — it answers {"message":"not found"} — so the body is what has to be read.
release_id() {
tea api "/repos/$REPO/releases/tags/$TAG" 2>/dev/null | python3 -c '
import json, sys
try:
print(json.load(sys.stdin).get("id") or "")
except Exception:
pass
'
}
# --- the release itself ----------------------------------------------------
if tea api "/repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then
if [ -n "$(release_id)" ]; then
say "the release already exists"
else
say "creating the release"
say "creating the release: $TITLE"
if [ -f "$NOTES" ]; then
tea releases create --login "$LOGIN" --repo "$REPO" --tag "$TAG" \
--title "$(python3 -c 'import json; d=json.load(open("package.json")); print(f"{d.get(\"productName\") or d[\"name\"]} {d[\"version\"]}")')" \
--note-file "$NOTES" >/dev/null
--title "$TITLE" --note-file "$NOTES" >/dev/null
else
say "no $NOTES — the release gets a one-line note"
tea releases create --login "$LOGIN" --repo "$REPO" --tag "$TAG" \
--title "$(python3 -c 'import json; d=json.load(open("package.json")); print(f"{d.get(\"productName\") or d[\"name\"]} {d[\"version\"]}")')" \
--note "Packages built from $TAG." >/dev/null
--title "$TITLE" --note "Packages built from $TAG." >/dev/null
fi
fi
RELEASE_ID="$(tea api "/repos/$REPO/releases/tags/$TAG" |
python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
RELEASE_ID="$(release_id)"
[ -n "$RELEASE_ID" ] || die "the release $TAG could not be created or found"
# --- the attachments -------------------------------------------------------
for asset in $ASSETS; do
while IFS= read -r asset; do
[ -n "$asset" ] || continue
[ -f "$asset" ] || die "no such file: $asset"
name="$(basename "$asset")"
@@ -85,7 +120,7 @@ for a in json.load(sys.stdin):
size="$(python3 -c "import os,sys; print(f'{os.path.getsize(sys.argv[1])/1e6:.0f} MB')" "$asset")"
say "uploading $name ($size) — large packages take a few minutes"
tea releases assets create --login "$LOGIN" --repo "$REPO" "$TAG" "$asset" >/dev/null
done
done < "$LIST"
say "done:"
tea api "/repos/$REPO/releases/$RELEASE_ID" | python3 -c "