Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd5361e222 |
@@ -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
|
||||
|
||||
+57
-25
@@ -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
@@ -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