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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+35
-3
@@ -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,12 +21,39 @@
|
||||
</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>
|
||||
|
||||
<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>
|
||||
@@ -47,6 +77,8 @@
|
||||
<div class="log-lines" id="log-lines" hidden></div>
|
||||
<div class="log-paths" id="log-paths"></div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
+136
-5
@@ -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
@@ -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 "
|
||||
|
||||
Reference in New Issue
Block a user