Ask a registry which store to install

The client had our store's config URL compiled into it, which meant a second store
— or anybody else's site — needed a release of this app. It now asks
`GET /api/stores` and installs what the site offers: one record and there is
nothing to decide, several and the setup screen shows a picker.

From a record the client works the rest out. `storeRepositoryUrl` gives the
`config.json` to read, and that file stays the authority on how the store behaves;
`catalogUrl` and `name` override its `store.base_url` and `store.name`, because the
registry is what says which catalog a store is *for*. The store id — which names
the store home and the folder games land in — comes from the repository name, so
`ttg-desktop-store` becomes `ttg`.

A 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. That was worth having
rather than an error, and it is tested.

The registry address is now the single thing about a particular site left in the
client, and `STORES_API` overrides it — which is how this was tested, against a
local endpoint serving the same payload the site returns, with one store that has a
config and one that has not. Both installed; the engine listed all ten titles with
the synthesised config.

`npm run uitest` now passes on either outcome — the grid when a store is present,
the setup gate with a populated picker when there is none — and it reports both, so
the gate cannot silently regress into an empty screen. Run with the registry
unreachable it produces the retry gate, and fails, which is the honest verdict: a
client that cannot reach the registry cannot set anything up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 13:13:36 +02:00
co-authored by Claude Opus 5
parent 85c6d33b05
commit cb8a28b156
10 changed files with 276 additions and 55 deletions
+26 -7
View File
@@ -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 }
}))
/**