diff --git a/README.md b/README.md index 1a5c97f..a854ba1 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ It is also **the Windows install path**. The store's own installer is `curl … | sh`, which Windows does not have; this app downloads the store engine itself, into the same folder the shell installer would use. +Which store it installs is not baked in: the client asks a registry — `GET +/api/stores` on the site — and each record says what the store is called, which +catalog it serves and where its configuration lives. + ## What it needs - **Python 3** on the machine, because the store is a Python program. The app @@ -49,6 +53,46 @@ so there was no resource seal and Gatekeeper refused it outright rather than asking. `scripts/after-pack.js` signs the bundle during the build now, and the result verifies as `valid on disk`. +## Which store it installs + +On first run the client fetches the registry and offers what it finds. One store +and there is nothing to decide; several and the setup screen shows a picker. + +```json +[ + { + "name": "Teletype Games", + "catalogUrl": "https://teletypegames.org", + "storeRepositoryUrl": "https://git.teletypegames.org/stores/ttg-desktop-store" + } +] +``` + +From a record the client works out the rest: + +- **`storeRepositoryUrl`** → the store's `config.json`, read from + `…/raw/branch/master/config.json`. That file is the authority on how the store + behaves: which platforms, which statuses, where things land. +- **`catalogUrl` and `name`** override the config's own `store.base_url` and + `store.name`. The registry says which catalog this store is *for*, so it wins. +- **the store id** — which names the store home and the folder games land in — + comes from the repository name: `ttg-desktop-store` becomes `ttg`. A + `config.json` that sets its own id keeps it. + +A repository **without** a `config.json` still works. 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. + +The registry address is the single thing about a particular site left in the +client, and `STORES_API` overrides it: + +```sh +STORES_API=http://127.0.0.1:8731/stores npm start +``` + +Adding a store is therefore a database row on the site — see its ActiveAdmin +panel — and not a release of this app. + ## Use - **Install all** fetches everything the catalog offers for this machine. @@ -131,7 +175,7 @@ SMOKE_HOME=/tmp/sandbox-root/ttg-desktop npm run smoke | `main.js` | the window, the IPC, and the one-call-at-a-time guard | | `preload.js` | the entire surface the renderer gets — no Node reaches it | | `lib/store.js` | finds the store and Python, runs the CLI, parses its JSON | -| `lib/bootstrap.js` | downloads the engine, the shared core and a config | +| `lib/bootstrap.js` | reads the registry, then downloads the engine, the core and a config | | `lib/i18n.js` | the two string tables | | `renderer/` | plain HTML, CSS and JS — no framework, no build step | | `Makefile` | the named sequences; no logic of its own beyond the release | @@ -159,6 +203,13 @@ 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 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 +synthesised config. `npm run uitest` was run twice — with a store present it shows +the grid, with none it shows the setup gate and its picker carries both names — +and once more with the registry unreachable, which produces the retry gate. + The signing was measured rather than assumed, by setting the quarantine flag on a copy unzipped from the release artifact: `codesign --verify --deep --strict` is clean, and `syspolicy_check` reports only the expected *"adhoc signed"* warning. diff --git a/lib/bootstrap.js b/lib/bootstrap.js index f8c6bec..ecf1294 100644 --- a/lib/bootstrap.js +++ b/lib/bootstrap.js @@ -1,36 +1,50 @@ 'use strict' -// Setting up the store when there is none yet. +// Setting up a store when there is none yet. // // This is the reason the client exists on Windows at all: the shell installer is // `curl … | sh`, which Windows does not have. The three files it would place are // downloaded here instead, into the very same store home — so the CLI and the // client stay one installation, and running install.sh afterwards only adds the // launcher script. +// +// Which store, though, is not baked in. The client asks a registry — `GET +// /api/stores` on the site — and each record says what the store is called, which +// catalog it serves and where its configuration lives. A different site, or a +// second store on ours, needs no change here. The registry address is the one +// address the client does know, and even that is overridable. const fs = require('node:fs') const https = require('node:https') +const http = require('node:http') const path = require('node:path') -const FORGE = 'https://git.teletypegames.org' -const SOURCES = { - engine: `${FORGE}/stores/warp-engine-desktop-store/raw/branch/master/desktop_store.py`, - core: `${FORGE}/engines/warpstore/raw/branch/master/warpstore.py`, - config: `${FORGE}/stores/ttg-desktop-store/raw/branch/master/config.json` +// Where the engine itself comes from. Not part of the registry: this is the +// client's own machinery, the same for every store it can drive. +const FORGE = process.env.FORGE_BASE || 'https://git.teletypegames.org' +const ENGINE_SOURCES = { + 'desktop_store.py': `${FORGE}/stores/warp-engine-desktop-store/raw/branch/master/desktop_store.py`, + 'warpstore.py': `${FORGE}/engines/warpstore/raw/branch/master/warpstore.py` } +// The registry. One address, and the only thing about a particular site left in +// the client. +const REGISTRY_URL = process.env.STORES_API || 'https://teletypegames.org/api/stores' + /** GET a URL as a string, following redirects — a moved repo answers 301. */ function fetchText (url, redirects = 5) { return new Promise((resolve, reject) => { - const request = https.get(url, { headers: { 'User-Agent': 'warp-engine-desktop-gui' } }, (res) => { + const client = url.startsWith('http://') ? http : https + const request = client.get(url, { headers: { 'User-Agent': 'warp-engine-desktop-gui' } }, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume() if (redirects <= 0) return reject(new Error(`too many redirects for ${url}`)) - const next = new URL(res.headers.location, url).toString() - return fetchText(next, redirects - 1).then(resolve, reject) + return fetchText(new URL(res.headers.location, url).toString(), redirects - 1).then(resolve, reject) } if (res.statusCode !== 200) { res.resume() - return reject(new Error(`${url} answered ${res.statusCode}`)) + const error = new Error(`${url} answered ${res.statusCode}`) + error.statusCode = res.statusCode + return reject(error) } let body = '' res.setEncoding('utf8') @@ -43,40 +57,103 @@ function fetchText (url, redirects = 5) { } /** - * Download the engine, the shared core and the store's config into `home`. + * The stores this client can install, from the site's registry. + * + * Each record carries a name, the catalog it serves and the repository holding + * its config. Anything without those three is dropped rather than half-used. + */ +async function registry (url = REGISTRY_URL) { + const body = await fetchText(url) + const rows = JSON.parse(body) + if (!Array.isArray(rows)) throw new Error(`${url} did not answer with a list of stores`) + return rows + .map((row) => ({ + name: String(row.name || '').trim(), + catalogUrl: String(row.catalogUrl || row.catalog_url || '').trim(), + storeRepositoryUrl: String(row.storeRepositoryUrl || row.store_repository_url || '').trim() + })) + .filter((row) => row.name && row.catalogUrl && row.storeRepositoryUrl) +} + +/** `…/stores/ttg-desktop-store` -> the raw config.json on its default branch. */ +function configUrl (repositoryUrl, branch = 'master') { + return `${repositoryUrl.replace(/\/+$/, '')}/raw/branch/${branch}/config.json` +} + +/** + * A store id from its repository name: `ttg-desktop-store` -> `ttg`. + * + * The id names the store home and the folder the games land in, so it has to be + * short and filesystem-safe. The repository name is the best source we have; the + * store's own config.json overrides it whenever it exists. + */ +function storeId (store) { + const last = store.storeRepositoryUrl.replace(/\/+$/, '').split('/').pop() || '' + const base = last.replace(/-(desktop-)?store$/, '') || store.name + return base.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'store' +} + +/** + * The store's configuration. + * + * Its repository is the authority on how the store behaves — which platforms, + * which statuses, where things land. A repository without a config.json still + * works: the engine merges whatever it is given onto its own defaults, so a + * three-field config is a complete one. + */ +async function storeConfig (store, { onLog = () => {} } = {}) { + const id = storeId(store) + let config = null + try { + onLog(`reading the store config from ${store.storeRepositoryUrl}`) + config = JSON.parse(await fetchText(configUrl(store.storeRepositoryUrl))) + } catch (err) { + if (err.statusCode !== 404) throw err + onLog('no config.json in the store repository — using the engine defaults') + config = { paths: { subfolder: id }, catalog: { statuses: ['released', 'archived', 'demo'] } } + } + // The registry is the authority on identity and on which catalog to read, so + // those two win over whatever the file says. + config.store = { ...(config.store || {}) } + config.store.id = config.store.id || id + config.store.name = store.name + config.store.base_url = store.catalogUrl + return config +} + +/** Where this store's home goes. */ +function homeFor (store, root) { + return path.join(root, `${storeId(store)}-desktop`) +} + +/** + * Install the engine, the shared core and the store's config into `home`. * * `onLog` reports each step, because on a slow line this takes a few seconds and - * silence looks like a hang. An existing config is left alone: a store that is - * already set up keeps its settings. + * silence looks like a hang. */ -async function install (home, { onLog = () => {} } = {}) { +async function install (home, store, { onLog = () => {} } = {}) { + if (!store) throw new Error('no store was chosen') fs.mkdirSync(home, { recursive: true }) - const wrote = [] - for (const [name, file] of [['engine', 'desktop_store.py'], ['core', 'warpstore.py']]) { + for (const [file, url] of Object.entries(ENGINE_SOURCES)) { onLog(`downloading ${file}`) - const body = await fetchText(SOURCES[name]) + const body = await fetchText(url) if (!body.startsWith('#!/usr/bin/env python3')) { throw new Error(`${file} does not look like the store engine — refusing to install it`) } - const dest = path.join(home, file) - fs.writeFileSync(dest, body, { mode: 0o755 }) - wrote.push(dest) + fs.writeFileSync(path.join(home, file), body, { mode: 0o755 }) } - const config = path.join(home, 'config.json') - if (fs.existsSync(config)) { - onLog('keeping the config already in place') - } else { - onLog('downloading config.json') - const body = await fetchText(SOURCES.config) - JSON.parse(body) // a broken config would fail later and less clearly - fs.writeFileSync(config, body) - wrote.push(config) - } + const configPath = path.join(home, 'config.json') + const config = await storeConfig(store, { onLog }) + fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) - onLog(`the store is set up in ${home}`) - return { home, config, script: path.join(home, 'desktop_store.py'), wrote } + onLog(`${store.name} is set up in ${home}`) + return { home, config: configPath, script: path.join(home, 'desktop_store.py'), id: config.store.id } } -module.exports = { FORGE, SOURCES, fetchText, install } +module.exports = { + ENGINE_SOURCES, FORGE, REGISTRY_URL, + configUrl, fetchText, homeFor, install, registry, storeConfig, storeId +} diff --git a/lib/i18n.js b/lib/i18n.js index be3b6c7..c1192bd 100644 --- a/lib/i18n.js +++ b/lib/i18n.js @@ -23,10 +23,14 @@ const STRINGS = { updateAvailable: 'update available', log: 'Log', noGames: 'No installable titles in the catalog.', - setupTitle: 'Set up the store', - setupBody: 'The store engine is not on this machine yet. It can be downloaded now — the same files the shell installer would place, in the same folder.', + 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', setupWorking: 'Setting up…', + setupChoose: 'Store', + registryFailed: 'The list of stores could not be fetched', + registryEmpty: 'The list of stores came back empty. Nothing to install from yet.', + registryRetry: 'Try again', oldEngineTitle: 'The store needs refreshing', oldEngineBody: 'The store engine on this machine is older than this client can drive. Refreshing it downloads the current engine and keeps your settings and installed games.', oldEngineAction: 'Refresh the store', @@ -59,10 +63,14 @@ const STRINGS = { updateAvailable: 'frissítés elérhető', log: 'Napló', noGames: 'Nincs telepíthető cím a katalógusban.', - setupTitle: 'A store beállítása', - setupBody: 'A store motorja még nincs ezen a gépen. Most letölthető — ugyanazok a fájlok, ugyanabba a könyvtárba, ahová a shell-telepítő tenné.', + 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', setupWorking: 'Beállítás…', + setupChoose: 'Store', + registryFailed: 'A store-ok listája nem érhető el', + registryEmpty: 'A store-ok listája üresen jött vissza. Egyelőre nincs miből telepíteni.', + registryRetry: 'Újra', oldEngineTitle: 'A store frissítésre vár', oldEngineBody: 'A gépen lévő store-motor régebbi, mint amit ez a kliens vezérelni tud. A frissítés letölti a mostani motort, a beállításaid és a telepített játékok pedig megmaradnak.', oldEngineAction: 'Store frissítése', diff --git a/main.js b/main.js index 3a412b6..d9a784f 100644 --- a/main.js +++ b/main.js @@ -74,6 +74,8 @@ async function selftest () { buttons: document.querySelectorAll('.card .actions button').length, gateVisible: !document.getElementById('gate').hidden, gateTitle: document.getElementById('gate-title').textContent, + gateChoices: [...document.getElementById('gate-select').options].map((o) => o.text), + gateAction: document.getElementById('gate-action').textContent, appName: document.getElementById('app-name').textContent, storeId: document.getElementById('store-id').textContent, paths: document.getElementById('log-paths').textContent.slice(0, 120), @@ -81,7 +83,11 @@ async function selftest () { locales: [...document.getElementById('locale').options].map((o) => o.value) }))()`) console.log(JSON.stringify(result, null, 2)) - const good = result.cards > 0 && !result.gateVisible && result.locales.length > 1 + // 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. + const good = result.locales.length > 1 && ( + (result.cards > 0 && !result.gateVisible) || + (result.gateVisible && result.gateChoices.length > 0 && result.gateAction)) console.log(good ? 'SELFTEST OK' : 'SELFTEST FAILED') app.exit(good ? 0 : 1) } @@ -151,7 +157,8 @@ ipcMain.handle('app:state', () => { store: current ? { id: current.id, home: current.home } : null, engine: engine ? { text: engine.text, ok: engine.ok } : null, minEngine: store.MIN_ENGINE.join('.'), - defaultHome: store.defaultHome(), + registryUrl: bootstrap.REGISTRY_URL, + storeRoot: store.storeRoots()[0], version: app.getVersion() } }) @@ -172,11 +179,23 @@ ipcMain.handle('store:sync', (_event, names) => ipcMain.handle('store:remove', (_event, name) => guarded(() => store.remove(current, String(name), hooks()))) -ipcMain.handle('store:bootstrap', () => guarded(async () => { - const home = store.defaultHome() - const result = await bootstrap.install(home, { onLog: (line) => send('store:log', line) }) - current = { engine: 'desktop', id: path.basename(home).replace(/-desktop$/, ''), ...result } - return { id: current.id, home: current.home } +// The stores this client can install, from the site's registry rather than from +// anything baked in here. A separate call because it needs the network: the first +// window paints without waiting for it. +ipcMain.handle('store:registry', async () => { + try { + return { stores: await bootstrap.registry() } + } catch (err) { + return { stores: [], error: err.message, url: bootstrap.REGISTRY_URL } + } +}) + +ipcMain.handle('store:bootstrap', (_event, chosen) => guarded(async () => { + if (!chosen) throw new Error('no store was chosen') + 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 } })) /** diff --git a/package.json b/package.json index d1832f2..6bc301a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "warp-engine-desktop-gui", "productName": "WarpEngine Store", - "version": "1.0.1", + "version": "1.1.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 f03ef59..5419ae7 100644 --- a/preload.js +++ b/preload.js @@ -12,7 +12,8 @@ contextBridge.exposeInMainWorld('storeApi', { paths: () => ipcRenderer.invoke('store:paths'), sync: (names) => ipcRenderer.invoke('store:sync', names), remove: (name) => ipcRenderer.invoke('store:remove', name), - bootstrap: () => ipcRenderer.invoke('store:bootstrap'), + registry: () => ipcRenderer.invoke('store:registry'), + bootstrap: (store) => ipcRenderer.invoke('store:bootstrap', store), launch: (game) => ipcRenderer.invoke('store:launch', game), openFolder: (dir) => ipcRenderer.invoke('app:openFolder', dir), diff --git a/renderer/app.js b/renderer/app.js index a66bef9..b403920 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -203,18 +203,34 @@ async function runRemove (name) { // --- gate: no python, or no store yet ------------------------------------- -function showGate (title, body, action, link) { +function showGate (title, body, action, link, choices) { el('grid').hidden = true el('empty').hidden = true const gate = el('gate') gate.hidden = false text(el('gate-title'), title) text(el('gate-body'), body) + + // Only shown when the registry offers more than one store; with a single one + // there is nothing to decide. + const choice = el('gate-choice') + const select = el('gate-select') + choice.hidden = !choices || choices.length < 2 + if (!choice.hidden) { + text(el('gate-choice-label'), T.setupChoose) + select.replaceChildren(...choices.map((store, index) => { + const option = document.createElement('option') + option.value = String(index) + option.textContent = store.name + return option + })) + } + const button = el('gate-action') button.hidden = !action if (action) { text(button, action.label) - button.onclick = action.onClick + button.onclick = () => action.onClick(choices ? choices[Number(select.value) || 0] : undefined) } const anchor = el('gate-link') anchor.hidden = !link @@ -263,10 +279,10 @@ async function boot () { return } - const setUpStore = async () => { + const setUpStore = async (chosen) => { showProgress(T.setupWorking) try { - await api.bootstrap() + await api.bootstrap(chosen) hideGate() await refresh() } catch (err) { @@ -274,16 +290,35 @@ async function boot () { } } + // 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) { - showGate(T.setupTitle, `${T.setupBody}\n\n${state.defaultHome}`, - { label: T.setupAction, onClick: async () => { await setUpStore(); await runSync([]) } }) + 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: setUpStore }) + { label: T.oldEngineAction, onClick: offerStores }) return } diff --git a/renderer/index.html b/renderer/index.html index 7bf729f..9a3e4a2 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -29,6 +29,10 @@

+
diff --git a/renderer/style.css b/renderer/style.css index d0b324d..32b25ec 100644 --- a/renderer/style.css +++ b/renderer/style.css @@ -87,7 +87,8 @@ body { } .gate h1 { font-size: 20px; margin: 0 0 10px; } .gate p { color: var(--ink-dim); white-space: pre-line; margin: 0 0 20px; word-break: break-all; } -.gate-actions { display: flex; gap: 14px; justify-content: center; align-items: center; } +.gate-actions { display: flex; gap: 14px; justify-content: center; align-items: center; flex-wrap: wrap; } +.gate-choice { display: inline-flex; align-items: center; gap: 8px; color: var(--ink-dim); font-size: 13px; } /* --- the grid ----------------------------------------------------------- */ .grid { diff --git a/scripts/smoke.js b/scripts/smoke.js index b24bedb..007c970 100644 --- a/scripts/smoke.js +++ b/scripts/smoke.js @@ -10,6 +10,7 @@ const path = require('node:path') const fs = require('node:fs') const store = require('../lib/store') +const bootstrap = require('../lib/bootstrap') const i18n = require('../lib/i18n') function ok (label, value) { @@ -34,6 +35,30 @@ async function main () { else ok(`strings:${lang}`, `${Object.keys(dict).length} keys`) } + // The registry is what decides which stores exist, so it is checked before + // anything that depends on one being installed. + try { + const stores = await bootstrap.registry() + if (!stores.length) bad('registry', `${bootstrap.REGISTRY_URL} returned no stores`) + else { + ok('registry', `${stores.length} store(s) from ${bootstrap.REGISTRY_URL}`) + for (const store of stores) { + ok(` ${store.name}`, `${store.catalogUrl} · ${bootstrap.storeId(store)}`) + const url = bootstrap.configUrl(store.storeRepositoryUrl) + try { + const config = JSON.parse(await bootstrap.fetchText(url)) + ok(' config.json', `${Object.keys(config).length} sections`) + } catch (err) { + // Not fatal: the engine merges onto its defaults, so a store without a + // config file still installs. + ok(' config.json', `absent (${err.statusCode || err.message}) — defaults would be used`) + } + } + } + } catch (err) { + bad('registry', `${bootstrap.REGISTRY_URL}: ${err.message}`) + } + let target = null if (process.env.SMOKE_HOME) { const home = path.resolve(process.env.SMOKE_HOME)