diff --git a/Makefile b/Makefile index 936ff08..a870b61 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index a854ba1..4feab58 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index bef0d00..c7dc855 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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. diff --git a/lib/i18n.js b/lib/i18n.js index c1192bd..ba81c23 100644 --- a/lib/i18n.js +++ b/lib/i18n.js @@ -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', diff --git a/lib/store.js b/lib/store.js index 9c86590..b04d2e4 100644 --- a/lib/store.js +++ b/lib/store.js @@ -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 } diff --git a/main.js b/main.js index d9a784f..073398c 100644 --- a/main.js +++ b/main.js @@ -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 } })) /** diff --git a/package.json b/package.json index 6bc301a..a8cde22 100644 --- a/package.json +++ b/package.json @@ -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 ", diff --git a/preload.js b/preload.js index 5419ae7..78ee99e 100644 --- a/preload.js +++ b/preload.js @@ -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), diff --git a/renderer/app.js b/renderer/app.js index b403920..5434629 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -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 diff --git a/renderer/index.html b/renderer/index.html index 9a3e4a2..5e06a68 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -11,6 +11,9 @@
+
WarpEngine Store @@ -18,35 +21,64 @@
- - -
- - - -
- - - - + diff --git a/renderer/style.css b/renderer/style.css index 32b25ec..d9768cd 100644 --- a/renderer/style.css +++ b/renderer/style.css @@ -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; diff --git a/scripts/release.sh b/scripts/release.sh index 9e8aca8..4636635 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -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 "