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
|
publish: ## Upload the packages already in dist/ to the Gitea release
|
||||||
@TAG=$(TAG) $(SCRIPTS)/release.sh
|
@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
|
clean: ## Remove the built packages
|
||||||
rm -rf dist
|
rm -rf dist
|
||||||
|
|||||||
@@ -95,15 +95,44 @@ panel — and not a release of this app.
|
|||||||
|
|
||||||
## Use
|
## Use
|
||||||
|
|
||||||
- **Install all** fetches everything the catalog offers for this machine.
|
Everything that is not a title lives in the **side menu** on the left, and the
|
||||||
- A card's button is **Install**, **Update**, or **Play** / **Open** once it is
|
`☰` button in the bar folds it away — the state is remembered between runs.
|
||||||
there. **Remove** takes a title back out.
|
|
||||||
- Each card says whether it is **native** — unpacked and run locally, works
|
- **Stores** lists every store on this machine, the open one marked. Clicking
|
||||||
offline — or **hosted**: a browser build the catalog serves rather than
|
another switches to it: the grid, the categories and the folders all follow, and
|
||||||
packages, so its entry opens a page and needs the network.
|
the client reopens on that store next time. Two stores installed from the same
|
||||||
- The **Log** drawer at the bottom carries the store's own output verbatim, and
|
catalog into different folders show their folder instead of their id, because
|
||||||
next to it are buttons that open the two folders everything lands in.
|
the id would not tell them apart. **Add a store…** brings up the registry
|
||||||
- The language follows the system and can be switched; **English and Hungarian**.
|
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
|
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.
|
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 start` | run the app against whatever store is installed |
|
||||||
| `make smoke` | drive the store bridge with no window at all |
|
| `make smoke` | drive the store bridge with no window at all |
|
||||||
| `make uitest` | load the window once and report what rendered |
|
| `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 test` | both checks |
|
||||||
| `make dist` | package for this machine (`dist-mac`, `dist-win`, `dist-linux` to pick) |
|
| `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 |
|
| `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
|
and both languages in the picker. The bootstrap download was run into an empty
|
||||||
directory and the resulting store answered the bridge.
|
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 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`
|
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
|
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
|
# WarpEngine Store 1.2.0
|
||||||
— `GET /api/stores` — and installs what comes back: one record and there is
|
|
||||||
nothing to decide, several and the setup screen shows a picker.
|
|
||||||
|
|
||||||
Adding a store is now a database row on the site, maintained from its admin panel,
|
**A side menu.** Everything that is not a title moved out of the bar into a menu on
|
||||||
rather than a release of this app.
|
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
|
**Switching stores.** With more than one store installed, clicking another in the
|
||||||
on how that store behaves: platforms, statuses, where things land.
|
menu opens it: the grid, the categories and the folders follow, and the client
|
||||||
- **`catalogUrl`** and **`name`** override the config's `store.base_url` and
|
reopens on the store last used. Two stores installed from the same catalog into
|
||||||
`store.name` — the registry is what says which catalog a store is *for*.
|
different folders are told apart by their folder, since their id is identical.
|
||||||
- **the store id**, from the repository name: `ttg-desktop-store` becomes `ttg`.
|
|
||||||
|
|
||||||
A store repository with no `config.json` still installs. The engine merges
|
While the store is working, the menu, the log drawer and the filters keep working —
|
||||||
whatever it is handed onto its own defaults, so the client writes a three-field
|
only what would start a second call is disabled.
|
||||||
config and the store behaves like the default one pointed at that catalog.
|
|
||||||
|
|
||||||
`STORES_API` overrides the registry address, which is the one thing about a
|
**The grid was broken, and now is not.** Its rows split the window's height evenly
|
||||||
particular site left in the client.
|
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
|
### Opening it on macOS
|
||||||
|
|
||||||
@@ -39,8 +47,12 @@ Linux packages need a build on those platforms (`make dist-win` / `dist-linux`).
|
|||||||
|
|
||||||
### Verified
|
### Verified
|
||||||
|
|
||||||
Against a local endpoint serving the same payload the site returns, with two
|
`SELFTEST_SHOT=shot.png npm run uitest` has the window photograph itself, which is
|
||||||
records — one store whose repository has a `config.json` and one without. Both
|
how the collapsed rows were found and how the fix was confirmed — in English and in
|
||||||
installed, and the engine listed all ten titles with the synthesised config. The
|
Hungarian, with the menu open and closed.
|
||||||
window was checked on both outcomes: the grid with a store present, the setup gate
|
|
||||||
with a populated picker when there is none.
|
`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',
|
nativeHint: 'Installed on this machine — works offline',
|
||||||
updateAvailable: 'update available',
|
updateAvailable: 'update available',
|
||||||
log: 'Log',
|
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.',
|
noGames: 'No installable titles in the catalog.',
|
||||||
|
noMatch: 'Nothing in this category.',
|
||||||
setupTitle: 'Set up a store',
|
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.',
|
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',
|
setupAction: 'Download the store',
|
||||||
@@ -62,7 +76,21 @@ const STRINGS = {
|
|||||||
nativeHint: 'Erre a gépre telepítve — internet nélkül is megy',
|
nativeHint: 'Erre a gépre telepítve — internet nélkül is megy',
|
||||||
updateAvailable: 'frissítés elérhető',
|
updateAvailable: 'frissítés elérhető',
|
||||||
log: 'Napló',
|
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.',
|
noGames: 'Nincs telepíthető cím a katalógusban.',
|
||||||
|
noMatch: 'Ebben a kategóriában nincs semmi.',
|
||||||
setupTitle: 'Store beállítása',
|
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é.',
|
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',
|
setupAction: 'Store letöltése',
|
||||||
|
|||||||
+34
-4
@@ -65,8 +65,37 @@ function findStores () {
|
|||||||
return found
|
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. */
|
/** Where a store would be installed if there is none yet. */
|
||||||
@@ -223,6 +252,7 @@ function purge (store, hooks) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
ENGINES, MIN_ENGINE, StoreError, atLeast, defaultHome, engineVersion, findPython,
|
ENGINES, MIN_ENGINE, StoreError, atLeast, defaultHome, describe, engineVersion,
|
||||||
findStore, findStores, list, parseVersion, paths, purge, remove, run, storeRoots, sync
|
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,
|
gateAction: document.getElementById('gate-action').textContent,
|
||||||
appName: document.getElementById('app-name').textContent,
|
appName: document.getElementById('app-name').textContent,
|
||||||
storeId: document.getElementById('store-id').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),
|
paths: document.getElementById('log-paths').textContent.slice(0, 120),
|
||||||
logLines: document.querySelectorAll('.log-line').length,
|
logLines: document.querySelectorAll('.log-line').length,
|
||||||
locales: [...document.getElementById('locale').options].map((o) => o.value)
|
locales: [...document.getElementById('locale').options].map((o) => o.value)
|
||||||
}))()`)
|
}))()`)
|
||||||
console.log(JSON.stringify(result, null, 2))
|
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 && (
|
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))
|
(result.gateVisible && result.gateChoices.length > 0 && result.gateAction))
|
||||||
console.log(good ? 'SELFTEST OK' : 'SELFTEST FAILED')
|
const switchGood = switched === null || (
|
||||||
app.exit(good ? 0 : 1)
|
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 () {
|
function createWindow () {
|
||||||
@@ -145,7 +196,10 @@ function createWindow () {
|
|||||||
ipcMain.handle('app:state', () => {
|
ipcMain.handle('app:state', () => {
|
||||||
const prefs = loadPrefs()
|
const prefs = loadPrefs()
|
||||||
const python = store.findPython()
|
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
|
// 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.
|
// says so and offers to refresh it rather than failing on the first call.
|
||||||
const engine = current ? store.engineVersion(current) : null
|
const engine = current ? store.engineVersion(current) : null
|
||||||
@@ -153,8 +207,10 @@ ipcMain.handle('app:state', () => {
|
|||||||
locale: i18n.pick(prefs.locale || app.getLocale()),
|
locale: i18n.pick(prefs.locale || app.getLocale()),
|
||||||
languages: i18n.languages,
|
languages: i18n.languages,
|
||||||
strings: i18n.dict(prefs.locale || app.getLocale()),
|
strings: i18n.dict(prefs.locale || app.getLocale()),
|
||||||
|
nav: prefs.nav !== false,
|
||||||
python: python ? python.version : null,
|
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,
|
engine: engine ? { text: engine.text, ok: engine.ok } : null,
|
||||||
minEngine: store.MIN_ENGINE.join('.'),
|
minEngine: store.MIN_ENGINE.join('.'),
|
||||||
registryUrl: bootstrap.REGISTRY_URL,
|
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) => {
|
ipcMain.handle('app:setLocale', (_event, locale) => {
|
||||||
const prefs = loadPrefs()
|
const prefs = loadPrefs()
|
||||||
prefs.locale = i18n.pick(locale)
|
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 home = bootstrap.homeFor(chosen, store.storeRoots()[0])
|
||||||
const result = await bootstrap.install(home, chosen, { onLog: (line) => send('store:log', line) })
|
const result = await bootstrap.install(home, chosen, { onLog: (line) => send('store:log', line) })
|
||||||
current = { engine: 'desktop', ...result }
|
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",
|
"name": "warp-engine-desktop-gui",
|
||||||
"productName": "WarpEngine Store",
|
"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.",
|
"description": "Graphical client for a WarpEngine desktop store: install the catalog into your own application menu.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Teletype Games <games@teletype.hu>",
|
"author": "Teletype Games <games@teletype.hu>",
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ const { contextBridge, ipcRenderer } = require('electron')
|
|||||||
contextBridge.exposeInMainWorld('storeApi', {
|
contextBridge.exposeInMainWorld('storeApi', {
|
||||||
state: () => ipcRenderer.invoke('app:state'),
|
state: () => ipcRenderer.invoke('app:state'),
|
||||||
setLocale: (locale) => ipcRenderer.invoke('app:setLocale', locale),
|
setLocale: (locale) => ipcRenderer.invoke('app:setLocale', locale),
|
||||||
|
setNav: (open) => ipcRenderer.invoke('app:setNav', open),
|
||||||
|
|
||||||
list: () => ipcRenderer.invoke('store:list'),
|
list: () => ipcRenderer.invoke('store:list'),
|
||||||
paths: () => ipcRenderer.invoke('store:paths'),
|
paths: () => ipcRenderer.invoke('store:paths'),
|
||||||
sync: (names) => ipcRenderer.invoke('store:sync', names),
|
sync: (names) => ipcRenderer.invoke('store:sync', names),
|
||||||
remove: (name) => ipcRenderer.invoke('store:remove', name),
|
remove: (name) => ipcRenderer.invoke('store:remove', name),
|
||||||
|
use: (home) => ipcRenderer.invoke('store:use', home),
|
||||||
registry: () => ipcRenderer.invoke('store:registry'),
|
registry: () => ipcRenderer.invoke('store:registry'),
|
||||||
bootstrap: (store) => ipcRenderer.invoke('store:bootstrap', store),
|
bootstrap: (store) => ipcRenderer.invoke('store:bootstrap', store),
|
||||||
launch: (game) => ipcRenderer.invoke('store:launch', game),
|
launch: (game) => ipcRenderer.invoke('store:launch', game),
|
||||||
|
|||||||
+243
-44
@@ -1,16 +1,22 @@
|
|||||||
'use strict'
|
'use strict'
|
||||||
// The whole renderer. No framework and no build step: the app is a grid of
|
// The whole renderer. No framework and no build step: a side menu on the left
|
||||||
// cards, and every action is one call over the bridge in preload.js.
|
// 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 api = window.storeApi
|
||||||
const el = (id) => document.getElementById(id)
|
const el = (id) => document.getElementById(id)
|
||||||
|
|
||||||
let T = {} // the active string table
|
let T = {} // the active string table
|
||||||
|
let state = null // what the main process knows: stores, python, engine
|
||||||
let games = []
|
let games = []
|
||||||
let paths = null
|
let paths = null
|
||||||
let busy = false
|
let busy = false
|
||||||
let plan = null // { total, done } while a sync is running
|
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 --------------------------------------------------------------
|
// --- helpers --------------------------------------------------------------
|
||||||
|
|
||||||
function text (node, value) {
|
function text (node, value) {
|
||||||
@@ -34,10 +40,14 @@ function logLine (line) {
|
|||||||
box.scrollTop = box.scrollHeight
|
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) {
|
function setBusy (value) {
|
||||||
busy = value
|
busy = value
|
||||||
for (const node of document.querySelectorAll('button')) {
|
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
|
node.disabled = value
|
||||||
}
|
}
|
||||||
const progress = el('progress')
|
const progress = el('progress')
|
||||||
@@ -53,6 +63,136 @@ function showProgress (label) {
|
|||||||
text(progress, 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 --------------------------------------------------------
|
// --- the card grid --------------------------------------------------------
|
||||||
|
|
||||||
function card (game) {
|
function card (game) {
|
||||||
@@ -70,6 +210,8 @@ function card (game) {
|
|||||||
img.loading = 'lazy'
|
img.loading = 'lazy'
|
||||||
art.appendChild(img)
|
art.appendChild(img)
|
||||||
} else {
|
} 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')
|
const glyph = document.createElement('span')
|
||||||
glyph.className = 'art-glyph'
|
glyph.className = 'art-glyph'
|
||||||
text(glyph, game.title.slice(0, 1).toUpperCase())
|
text(glyph, game.title.slice(0, 1).toUpperCase())
|
||||||
@@ -141,12 +283,14 @@ function card (game) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderGrid () {
|
function renderGrid () {
|
||||||
|
const shown = games.filter(matches)
|
||||||
const grid = el('grid')
|
const grid = el('grid')
|
||||||
grid.replaceChildren(...games.map(card))
|
grid.replaceChildren(...shown.map(card))
|
||||||
grid.hidden = games.length === 0
|
grid.hidden = shown.length === 0
|
||||||
|
grid.scrollTop = 0
|
||||||
const empty = el('empty')
|
const empty = el('empty')
|
||||||
empty.hidden = games.length !== 0
|
empty.hidden = shown.length !== 0
|
||||||
text(empty, T.noGames)
|
text(empty, games.length === 0 ? T.noGames : T.noMatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPaths () {
|
function renderPaths () {
|
||||||
@@ -175,6 +319,7 @@ async function refresh () {
|
|||||||
const result = await api.list()
|
const result = await api.list()
|
||||||
games = result.games || []
|
games = result.games || []
|
||||||
paths = result.paths || paths
|
paths = result.paths || paths
|
||||||
|
renderCats()
|
||||||
renderGrid()
|
renderGrid()
|
||||||
renderPaths()
|
renderPaths()
|
||||||
for (const reason of result.skipped || []) logLine(`skipped ${reason}`)
|
for (const reason of result.skipped || []) logLine(`skipped ${reason}`)
|
||||||
@@ -201,7 +346,37 @@ async function runRemove (name) {
|
|||||||
await refresh()
|
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) {
|
function showGate (title, body, action, link, choices) {
|
||||||
el('grid').hidden = true
|
el('grid').hidden = true
|
||||||
@@ -244,6 +419,44 @@ function hideGate () {
|
|||||||
el('gate').hidden = true
|
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 -----------------------------------------------------------------
|
// --- boot -----------------------------------------------------------------
|
||||||
|
|
||||||
function applyStrings (strings) {
|
function applyStrings (strings) {
|
||||||
@@ -252,13 +465,26 @@ function applyStrings (strings) {
|
|||||||
text(el('sync-all'), T.syncAll)
|
text(el('sync-all'), T.syncAll)
|
||||||
text(el('refresh'), T.refresh)
|
text(el('refresh'), T.refresh)
|
||||||
text(el('log-toggle'), T.log)
|
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()
|
renderPaths()
|
||||||
if (games.length) renderGrid()
|
if (state) renderStores()
|
||||||
|
if (games.length) {
|
||||||
|
renderCats()
|
||||||
|
renderGrid()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function boot () {
|
async function boot () {
|
||||||
const state = await api.state()
|
state = await api.state()
|
||||||
applyStrings(state.strings)
|
applyStrings(state.strings)
|
||||||
|
setNav(state.nav !== false)
|
||||||
|
renderStores()
|
||||||
|
|
||||||
const select = el('locale')
|
const select = el('locale')
|
||||||
select.replaceChildren(...state.languages.map((code) => {
|
select.replaceChildren(...state.languages.map((code) => {
|
||||||
@@ -279,46 +505,13 @@ async function boot () {
|
|||||||
return
|
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) {
|
if (!state.store) {
|
||||||
await offerStores()
|
await offerStores()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.engine && !state.engine.ok) {
|
if (state.engine && !state.engine.ok) {
|
||||||
showGate(T.oldEngineTitle,
|
showOldEngineGate()
|
||||||
`${T.oldEngineBody}\n\n${state.engine.text} → ${state.minEngine}`,
|
|
||||||
{ label: T.oldEngineAction, onClick: offerStores })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,6 +522,12 @@ async function boot () {
|
|||||||
|
|
||||||
el('sync-all').addEventListener('click', () => runSync([]))
|
el('sync-all').addEventListener('click', () => runSync([]))
|
||||||
el('refresh').addEventListener('click', () => refresh())
|
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', () => {
|
el('log-toggle').addEventListener('click', () => {
|
||||||
const box = el('log-lines')
|
const box = el('log-lines')
|
||||||
box.hidden = !box.hidden
|
box.hidden = !box.hidden
|
||||||
|
|||||||
+57
-25
@@ -11,6 +11,9 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="bar">
|
<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">
|
<div class="bar-title">
|
||||||
<span class="logo" aria-hidden="true">▚</span>
|
<span class="logo" aria-hidden="true">▚</span>
|
||||||
<span id="app-name">WarpEngine Store</span>
|
<span id="app-name">WarpEngine Store</span>
|
||||||
@@ -18,35 +21,64 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="bar-actions">
|
<div class="bar-actions">
|
||||||
<span class="progress" id="progress" hidden></span>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Shown instead of the grid when there is nothing to drive yet. -->
|
<div class="shell">
|
||||||
<section id="gate" class="gate" hidden>
|
<!-- The side menu: which store, what to do with it, and what to look at.
|
||||||
<h1 id="gate-title"></h1>
|
Collapsed with the button in the bar; the choice is remembered. -->
|
||||||
<p id="gate-body"></p>
|
<aside class="side" id="side">
|
||||||
<div class="gate-actions">
|
<section class="side-block">
|
||||||
<label id="gate-choice" class="gate-choice" hidden>
|
<h2 class="side-head" id="head-stores"></h2>
|
||||||
<span id="gate-choice-label"></span>
|
<div class="store-list" id="store-list"></div>
|
||||||
<select id="gate-select" class="select"></select>
|
<button id="add-store" class="btn btn-ghost btn-wide"></button>
|
||||||
</label>
|
</section>
|
||||||
<button id="gate-action" class="btn btn-primary" hidden></button>
|
|
||||||
<a id="gate-link" class="link" href="#" hidden></a>
|
<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>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<main id="grid" class="grid" hidden></main>
|
|
||||||
|
|
||||||
<section id="empty" class="empty" hidden></section>
|
|
||||||
|
|
||||||
<footer class="log">
|
|
||||||
<button id="log-toggle" class="log-toggle" aria-expanded="false"></button>
|
|
||||||
<div class="log-lines" id="log-lines" hidden></div>
|
|
||||||
<div class="log-paths" id="log-paths"></div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+136
-5
@@ -25,18 +25,138 @@ body {
|
|||||||
overflow: hidden;
|
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 ------------------------------------------------------------ */
|
/* --- top bar ------------------------------------------------------------ */
|
||||||
.bar {
|
.bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
padding: 12px 18px;
|
padding: 10px 18px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
flex: none;
|
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; }
|
.logo { color: var(--accent); font-size: 18px; }
|
||||||
.store-id {
|
.store-id {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -95,12 +215,20 @@ body {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: grid;
|
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;
|
gap: 14px;
|
||||||
padding: 18px;
|
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;
|
align-content: start;
|
||||||
}
|
}
|
||||||
.empty { margin: auto; color: var(--ink-dim); }
|
.empty { margin: auto; color: var(--ink-dim); }
|
||||||
|
.gate { overflow-y: auto; }
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@@ -113,7 +241,10 @@ body {
|
|||||||
.card.is-installed { border-color: #2f5a49; }
|
.card.is-installed { border-color: #2f5a49; }
|
||||||
|
|
||||||
.art {
|
.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;
|
background: #0d1117;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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"
|
command -v python3 >/dev/null 2>&1 || die "python3 is required"
|
||||||
[ -f package.json ] || die "run this from the repository root"
|
[ -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}"
|
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
|
# The repository is whatever this checkout pushes to, so a fork publishes to the
|
||||||
# fork without editing anything.
|
# 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$##')}"
|
sed -e 's#.*[:/]\([^/]*/[^/]*\)$#\1#' -e 's#\.git$##')}"
|
||||||
[ -n "$REPO" ] || die "cannot work out the Gitea repo — set REPO=owner/name"
|
[ -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
|
if [ "$#" -gt 0 ]; then
|
||||||
ASSETS="$*"
|
for given in "$@"; do printf '%s\n' "$given"; done > "$LIST"
|
||||||
else
|
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' \) \
|
\( -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
|
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"
|
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 ----------------------------------------------------
|
# --- 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"
|
say "the release already exists"
|
||||||
else
|
else
|
||||||
say "creating the release"
|
say "creating the release: $TITLE"
|
||||||
if [ -f "$NOTES" ]; then
|
if [ -f "$NOTES" ]; then
|
||||||
tea releases create --login "$LOGIN" --repo "$REPO" --tag "$TAG" \
|
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\"]}")')" \
|
--title "$TITLE" --note-file "$NOTES" >/dev/null
|
||||||
--note-file "$NOTES" >/dev/null
|
|
||||||
else
|
else
|
||||||
say "no $NOTES — the release gets a one-line note"
|
say "no $NOTES — the release gets a one-line note"
|
||||||
tea releases create --login "$LOGIN" --repo "$REPO" --tag "$TAG" \
|
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\"]}")')" \
|
--title "$TITLE" --note "Packages built from $TAG." >/dev/null
|
||||||
--note "Packages built from $TAG." >/dev/null
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
RELEASE_ID="$(tea api "/repos/$REPO/releases/tags/$TAG" |
|
RELEASE_ID="$(release_id)"
|
||||||
python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
|
[ -n "$RELEASE_ID" ] || die "the release $TAG could not be created or found"
|
||||||
|
|
||||||
# --- the attachments -------------------------------------------------------
|
# --- the attachments -------------------------------------------------------
|
||||||
for asset in $ASSETS; do
|
while IFS= read -r asset; do
|
||||||
|
[ -n "$asset" ] || continue
|
||||||
[ -f "$asset" ] || die "no such file: $asset"
|
[ -f "$asset" ] || die "no such file: $asset"
|
||||||
name="$(basename "$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")"
|
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"
|
say "uploading $name ($size) — large packages take a few minutes"
|
||||||
tea releases assets create --login "$LOGIN" --repo "$REPO" "$TAG" "$asset" >/dev/null
|
tea releases assets create --login "$LOGIN" --repo "$REPO" "$TAG" "$asset" >/dev/null
|
||||||
done
|
done < "$LIST"
|
||||||
|
|
||||||
say "done:"
|
say "done:"
|
||||||
tea api "/repos/$REPO/releases/$RELEASE_ID" | python3 -c "
|
tea api "/repos/$REPO/releases/$RELEASE_ID" | python3 -c "
|
||||||
|
|||||||
Reference in New Issue
Block a user