1 Commits
Author SHA1 Message Date
mr.zeroandClaude Opus 5 045c7bf5b7 Read a store's config from the registry record
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
`GET /api/stores` records now carry a `config` field — a store's `config.json` moved
into the record that already said what the store is — and the client applies it
directly. Installing a store no longer depends on a second repository existing and
staying reachable, and a store can be configured from the site's admin alone.

The order is registry config, then a repository's `config.json`, then the engine's
defaults. The middle one is why nothing has to move at once: a registry whose stores
have not been migrated is read exactly as before.

The window cannot supply a config. It is handed stores to show and hands one back to
install, but only as an identity: `RegistryStoreDtoMapper.toModel` drops the config and
`StoreProvisioningService` reads the record again from the registry first. A config
decides where files are written and, through `paths.subfolder`, which subtree the store
may later delete from — not a decision the renderer gets to make, for the same reason a
`GameDto` carries no paths. Tested by installing from a record carrying
`subfolder: "ATTACKER"` and `install_root: "/tmp/pwned"` and finding neither on disk.

A store that has left the registry, or a registry that cannot be re-read, still
installs: it falls back to the engine's defaults rather than refusing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 06:43:39 +02:00
9 changed files with 173 additions and 107 deletions
+35 -21
View File
@@ -62,35 +62,49 @@ and there is nothing to decide; several and the setup screen shows a picker.
```json ```json
[ [
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org", "storeRepositoryUrl": null },
{ {
"name": "Some Other Store", "name": "Teletype Games",
"catalogUrl": "https://games.example.org", "catalogUrl": "https://teletypegames.org",
"storeRepositoryUrl": "https://git.example.org/stores/other-desktop-store" "storeRepositoryUrl": "https://git.teletypegames.org/stores/ttg-desktop-store",
} "config": { "store": { "id": "ttg" }, "paths": { "subfolder": "teletypegames" } }
},
{ "name": "Some Other Store", "catalogUrl": "https://games.example.org",
"storeRepositoryUrl": null, "config": null }
] ]
``` ```
**A store needs no repository of its own.** A name and a catalog are enough: the **A store needs nothing of its own.** A name and a catalog are enough: the store
store engine's built-in defaults already cover the host-to-asset mapping, the engine's built-in defaults already cover the host-to-asset mapping, the install modes,
install modes, the platforms and the behaviour, so what is actually missing from the platforms and the behaviour, so what is actually missing from them is identity — a
them is identity — a slug, a name and a catalog URL — and that is exactly what a slug, a name and a catalog URL — and that is exactly what a registry record carries.
registry record carries. With `storeRepositoryUrl` null the client writes a With `config` and `storeRepositoryUrl` both null the client writes a three-section
three-section config and the store installs. config and the store installs.
From a record the client works out the rest: Where a store's configuration comes from, in the order the client asks:
1. **`config` on the registry record** — the store's own configuration, in the same
shape a store's `config.json` had, because it is that file moved into the registry.
It costs no request: it arrived with the store list.
2. **`storeRepositoryUrl``…/raw/branch/master/config.json`** — the same thing in its
older home, read for a registry whose stores have not moved over yet. A repository
**without** a `config.json` is treated as no repository at all.
3. **the engine's defaults** — when there is neither.
Two fields the record always decides, whatever the config says:
- **the store id** — which names the store home and the folder games land in —
comes from the repository name when there is one (`ttg-desktop-store` becomes
`ttg`), otherwise from the catalog host (`teletypegames.org` becomes
`teletypegames`), otherwise from the display name. A `config.json` that sets its
own id keeps it.
- **`catalogUrl` and `name`** override the config's own `store.base_url` and - **`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. `store.name`. The registry says which catalog this store is *for*, so it wins.
- **`storeRepositoryUrl`**, when given → the store's `config.json`, read from - **the store id** — which names the store home and the folder games land in — comes
`…/raw/branch/master/config.json`. That file stays the authority on how the store from the config's `store.id` when it sets one; otherwise from the repository name
behaves: which platforms, which statuses, where things land. A repository (`ttg-desktop-store` becomes `ttg`), then the catalog host (`teletypegames.org`
**without** a `config.json` is treated as no repository at all. becomes `teletypegames`), then the display name.
**The window never supplies a config.** It is handed stores to show and hands one back
to install, but only as an identity: `RegistryStoreDtoMapper.toModel` drops the config,
and `StoreProvisioningService` reads the record again from the registry before
installing. A config decides where files are written and which subtree the store may
later delete from, so it must not be something the renderer can set — the same rule as
`GameDto` carrying no paths.
What the defaults produce, for a record with no repository: the games land in a What the defaults produce, for a record with no repository: the games land in a
folder named after the store id, and released, archived **and demo** titles are folder named after the store id, and released, archived **and demo** titles are
+34 -62
View File
@@ -1,57 +1,29 @@
# WarpEngine Client 2.0.0 # WarpEngine Client 2.1.0
**The store engine is part of the app. Nothing has to be installed on the machine any **A store's configuration now comes from the registry itself.** `GET /api/stores`
more.** Reading the catalog, choosing which release fits your computer, downloading and records carry a `config` field — the same thing a store's `config.json` held, moved into
unpacking it, writing the menu entry and remembering what went where all happen inside the record that already says what the store is. The client applies it directly, so
the application now. There is no Python to find, no child process, and no JSON protocol installing a store no longer depends on a second repository existing and staying
between the two halves — which is why this is a major version rather than a feature. reachable, and a store can be configured entirely from the site's admin.
What that changes for a person: on Windows and on a fresh Mac the app simply works. The old path still works. Where a record has no `config` but names a repository, the
Before it looked for `python3`, `python` and `py -3`, and where none answered it drew a client reads `…/raw/branch/master/config.json` from it exactly as before, so a registry
screen with a link to python.org instead of a catalog. That screen is gone, along with whose stores have not moved over is unaffected. With neither, the engine's defaults
the one that offered to refresh a store engine too old to drive. carry the store, as they always have. The order is: registry config, then repository
file, then defaults.
**Your existing library is kept.** `config.json` and `state.json` on disk are unchanged — **The window cannot supply a store config.** It is handed stores to show and hands one
the same field names, the same `<scope>:<name>` keys, the same file modes — so a machine back to install, but only as an identity — the config is dropped on the way in, and the
whose games were installed by the shell store keeps them. Opening this version against main process reads the record again from the registry before installing anything. This
such a store lists them as installed, offers no needless update, and a sync reports is not tidiness: a config decides where files are written and, through
*already up to date*. A `version: 1` state file is still migrated on first read. `paths.subfolder`, which subtree the store may later delete from. That is not a decision
the renderer gets to make, for the same reason a `GameDto` carries no paths.
The two Python files an earlier install left in the store folder are removed the next
time that store is set up. Nothing reads them, and a folder that still looks like it
holds the engine invites someone to run it against a state file this app is also writing.
**The client knows which WarpEngine served a catalog.** Every WarpEngine API response
carries a `WarpEngine-Version` header, and the client now reads it, names the version in
the log, and picks the catalog dialect for it. `SUPPORTED_WARP_ENGINE_VERSIONS` lists what
this build was written against — 0.2, 0.3 and 0.4 — and the four cases are all handled:
| The header says | What the client does |
|---|---|
| a supported version | reads the catalog with that version's dialect |
| nothing at all | reads it as the oldest supported version — an engine before 0.4.0 sent no header |
| something older | the same, and says so in the log |
| something newer | tries the newest dialect anyway, warning that titles may be missed |
Adding a version to that list fails the build until somebody says what it reads like, in
the type checker and in the linter both. A new engine version cannot arrive unnoticed.
**Refresh and the language picker are icons.** They sit together at the foot of the side
menu, and the *Actions* heading that used to head a section of one button is gone. Both
carry their name as a tooltip and to a screen reader, and the language picker is still a
real `<select>` underneath — the native dropdown, keyboard and all, with only the glyph
showing.
### Also ### Also
No runtime dependencies, still: the zip reader the installer needs is about 150 lines over The headless check now reports *which* source configured a store — registry, repository
`node:zlib` rather than a package. It restores the executable bit from each entry's or defaults — and names the resulting prune boundary, because "it installed" and "it
external attributes, which is what makes an unpacked game able to start at all, and it installed where I meant" are different claims.
refuses a zip64 archive, an unknown compression method or an entry that would be written
outside its destination rather than guessing.
The repository itself is free of Python too — the Makefile, the CI check and the release
script read `package.json` and the forge's JSON with Node now.
### Opening it on macOS ### Opening it on macOS
@@ -68,19 +40,19 @@ Windows (installer, portable) packages the pipeline builds when the tag is pushe
### Verified ### Verified
`make check` is clean: typecheck, lint, the headless smoke test and the window self-test. `make check` is clean: typecheck, lint, the headless smoke test and the window
self-test.
The new engine was measured against the old one rather than trusted. On the same catalog All three configuration sources were exercised end to end against the real serializer
and the same config, the Python engine and this one produce **the same 13-title listing output, with the client's own classes and a sandbox store root:
with zero field differences** and the same resolved paths. Installing three titles — a
bare TIC-80 binary wrapped in a bundle, a Godot `.app` symlinked, and a hosted web entry —
gives **byte-identical payloads, identical file modes and an identical `Info.plist`**; the
only difference in the two trees is the sandbox path inside the generated launcher script.
`state.json` matches record for record.
The upgrade path was tested directly: pointed at a store home installed by the Python | Registry record | What configured the store | Prune boundary written |
engine, this one reports all three titles installed with no update available, and a |---|---|---|
re-sync writes nothing. Remove, prune, prune-suppression on a named sync, and the v1→v2 | `config` present | the registry, with no extra request | `teletypegames` |
state migration were each exercised. The zip reader was checked against Python's | `config: null`, repository given | the repository's `config.json` | `teletypegames` |
`extractall` on an archive holding stored, deflated, directory and symlink entries — | neither | the engine's defaults | `teletypegames` |
identical bytes and identical modes — and its zip-slip and not-a-zip guards both fire.
The renderer barrier was tested adversarially rather than assumed: an install started
from a record carrying `paths.subfolder: "ATTACKER"` and `paths.install_root:
"/tmp/pwned"` wrote `subfolder: teletypegames` and `install_root: null` to disk, because
the service re-read the registry and the mapper had already dropped the config.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "warp-engine-client", "name": "warp-engine-client",
"productName": "WarpEngine Client", "productName": "WarpEngine Client",
"version": "2.0.0", "version": "2.1.0",
"description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.", "description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.",
"license": "MIT", "license": "MIT",
"author": "Teletype Games <games@teletype.hu>", "author": "Teletype Games <games@teletype.hu>",
@@ -16,12 +16,20 @@ export class RegistryStoreDtoMapper {
return stores.map((store: RegistryStore): RegistryStoreDto => this.toDto(store)) return stores.map((store: RegistryStore): RegistryStoreDto => this.toDto(store))
} }
/** The window hands a record straight back when asking for an install. */ /**
* The window hands a record back when asking for an install — as an identity only.
*
* There is no `config` here on purpose. A store's config decides where files are
* written and which subtree the store may delete from, so it must not be something
* the window can supply; `StoreProvisioningService` reads the real record from the
* registry instead. That is the same rule as `GameDto` carrying no paths.
*/
public toModel (dto: RegistryStoreDto): RegistryStore { public toModel (dto: RegistryStoreDto): RegistryStore {
return { return {
name: dto.name, name: dto.name,
catalogUrl: dto.catalogUrl, catalogUrl: dto.catalogUrl,
storeRepositoryUrl: dto.storeRepositoryUrl storeRepositoryUrl: dto.storeRepositoryUrl,
config: null
} }
} }
} }
@@ -31,11 +31,44 @@ export class StoreProvisioningService {
} }
public async installStore ( public async installStore (
store: RegistryStore, chosen: RegistryStore,
progress?: EngineProgressListener progress?: EngineProgressListener
): Promise<InstalledStore> { ): Promise<InstalledStore> {
const store = await this.resolveFromRegistry(chosen, progress)
const home = this.stores.resolveDefaultHome(deriveStoreId(store)) const home = this.stores.resolveDefaultHome(deriveStoreId(store))
const installed = await this.installer.installEngine(home, store, progress) const installed = await this.installer.installEngine(home, store, progress)
return this.selection.adoptStore(installed) return this.selection.adoptStore(installed)
} }
/**
* The registry's own record for the store that was chosen.
*
* The window is handed stores to display and hands one back to install, but what it
* hands back is not what gets used. A store's config decides where files are written
* and which subtree the store may later delete from, so it cannot be something the
* window supplies — the choice is treated as an identity, a name and a catalog, and
* the record behind it is read again here.
*
* A store that has since left the registry, or a registry that cannot be reached, is
* not a reason to refuse the install: it proceeds on the engine's defaults, which is
* what a store with no config gets anyway.
*/
private async resolveFromRegistry (
chosen: RegistryStore,
progress?: EngineProgressListener
): Promise<RegistryStore> {
try {
const listed = await this.registry.listStores()
const found = listed.find((store: RegistryStore): boolean =>
store.catalogUrl === chosen.catalogUrl && store.name === chosen.name)
if (found !== undefined) return found
progress?.onLog?.(
`${chosen.name} is no longer in the registry — installing on the engine's defaults`)
} catch (error: unknown) {
const reason = error instanceof Error ? error.message : String(error)
progress?.onLog?.(
`the registry could not be read again (${reason}) — installing on the engine's defaults`)
}
return { ...chosen, config: null }
}
} }
+15 -5
View File
@@ -1,14 +1,24 @@
/** /**
* A store the site's registry offers. * A store the site's registry offers.
* *
* A name and a catalog are what make a store; the repository is optional. When * A name and a catalog are what make a store; the other two fields are optional.
* there is one it stays the authority on how that store behaves — which platforms *
* it offers, where things land — and when there is not, the engine's own defaults * `config` is how that store behaves — which platforms it offers, which release
* cover all of it and this record covers the identity. That is the whole reason a * statuses it shows, where its games land — in the same shape a store's `config.json`
* store needs no repository of its own. * had, because it is the same thing moved into the registry. With none, the engine's
* defaults cover all of it and this record covers the identity, which is why a store
* needs nothing of its own to be installable.
*
* `storeRepositoryUrl` is where the store's own repository is, when it has one. It is
* still read as a config source for a registry that has not moved its stores over yet.
*/ */
export interface RegistryStore { export interface RegistryStore {
readonly name: string readonly name: string
readonly catalogUrl: string readonly catalogUrl: string
readonly storeRepositoryUrl: string | null readonly storeRepositoryUrl: string | null
/**
* Deliberately not `JsonRecord`: `domain` imports nothing from `infrastructure`, and
* a JSON object is describable without it.
*/
readonly config: Readonly<Record<string, unknown>> | null
} }
@@ -1,7 +1,7 @@
import { RegistryUnavailableError } from '../../domain/errors/RegistryUnavailableError' import { RegistryUnavailableError } from '../../domain/errors/RegistryUnavailableError'
import type { RegistryStore } from '../../domain/models/RegistryStore' import type { RegistryStore } from '../../domain/models/RegistryStore'
import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository' import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository'
import { asRecord, readString, type JsonRecord } from '../json/JsonRecord' import { asRecord, readRecord, readString, type JsonRecord } from '../json/JsonRecord'
import { BuildConfiguration } from '../config/BuildConfiguration' import { BuildConfiguration } from '../config/BuildConfiguration'
import type { HttpTextClient } from '../http/HttpTextClient' import type { HttpTextClient } from '../http/HttpTextClient'
@@ -15,10 +15,10 @@ const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores'
* field a build was packaged with (for shipping a client for another site), and finally * field a build was packaged with (for shipping a client for another site), and finally
* the address of ours. * the address of ours.
* *
* A record needs a name and a catalog URL; those two make a store. The repository * A record needs a name and a catalog URL; those two make a store. The config and the
* is optional and arrives as null when absent — a store configured by nothing but * repository are both optional and arrive as null when absent — a store configured by
* this record installs on the engine's defaults. Records missing either of the two * nothing but this record installs on the engine's defaults. Records missing either of
* required fields are dropped rather than half-used. * the two required fields are dropped rather than half-used.
*/ */
export class HttpStoreRegistryRepository implements StoreRegistryRepository { export class HttpStoreRegistryRepository implements StoreRegistryRepository {
public readonly sourceUrl: string public readonly sourceUrl: string
@@ -53,7 +53,8 @@ export class HttpStoreRegistryRepository implements StoreRegistryRepository {
return { return {
name: readString(row, 'name').trim(), name: readString(row, 'name').trim(),
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim(), catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim(),
storeRepositoryUrl: repository.length > 0 ? repository : null storeRepositoryUrl: repository.length > 0 ? repository : null,
config: readRecord(row, 'config')
} }
}) })
.filter((store: RegistryStore): boolean => .filter((store: RegistryStore): boolean =>
@@ -77,13 +77,15 @@ export class NativeStoreEngineInstaller implements StoreEngineInstaller {
/** /**
* The store's configuration. * The store's configuration.
* *
* Three cases, and all of them install: * Four cases, and all of them install:
* *
* - **a repository with a config.json** — that file is the authority on how the * - **a config on the registry record** — the authority on how the store behaves:
* store behaves: which platforms it offers, which statuses it shows, where * which platforms it offers, which statuses it shows, where things land. No
* things land; * request at all, because it arrived with the store list;
* - **a repository with a config.json** — the same thing in its older home, read
* for a registry whose stores have not moved over yet;
* - **a repository without one** (404) — the engine's defaults, as below; * - **a repository without one** (404) — the engine's defaults, as below;
* - **no repository at all** — the same defaults, without the round trip. * - **neither** — the same defaults, without the round trip.
* *
* The engine's built-in defaults already cover the host-to-asset mapping, the * The engine's built-in defaults already cover the host-to-asset mapping, the
* modes, the platforms and the behaviour, so what a store actually has to supply is * modes, the platforms and the behaviour, so what a store actually has to supply is
@@ -113,6 +115,13 @@ export class NativeStoreEngineInstaller implements StoreEngineInstaller {
storeId: string, storeId: string,
progress: EngineProgressListener progress: EngineProgressListener
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
// The registry's own answer wins, and needs no request: a store's configuration is
// part of its record now rather than a file in a repository that has to exist and
// stay reachable.
if (store.config !== null) {
progress.onLog?.(`${store.name} is configured by the registry`)
return { ...store.config }
}
const repositoryUrl = store.storeRepositoryUrl const repositoryUrl = store.storeRepositoryUrl
if (repositoryUrl === null) { if (repositoryUrl === null) {
progress.onLog?.(`${store.name} has no store repository — using the engine defaults`) progress.onLog?.(`${store.name} has no store repository — using the engine defaults`)
+23 -4
View File
@@ -76,24 +76,43 @@ class SmokeTest {
} }
/** /**
* A store needs no repository, and a repository needs no config.json: either way * Where this store's configuration would come from, in the order the installer asks.
* the engine's defaults carry it. So both absences are reported, not failed. *
* A store needs no config and no repository: either way the engine's defaults carry
* it, so every absence here is reported rather than failed. What is worth seeing is
* *which* source answered — a store still being configured by a repository is a store
* that has not moved over to the registry yet.
*/ */
private async checkStoreConfig (store: RegistryStore): Promise<void> { private async checkStoreConfig (store: RegistryStore): Promise<void> {
if (store.config !== null) {
const subfolder = this.readSubfolder(store.config)
this.reportOk(' config', `${String(Object.keys(store.config).length)} sections ` +
`from the registry${subfolder === null ? '' : `, subfolder ${subfolder}`}`)
return
}
if (store.storeRepositoryUrl === null) { if (store.storeRepositoryUrl === null) {
this.reportOk(' config', 'no repository — the engine defaults would be used') this.reportOk(' config', 'none, and no repository — the engine defaults would be used')
return return
} }
const url = `${store.storeRepositoryUrl.replace(/\/+$/, '')}/raw/branch/master/config.json` const url = `${store.storeRepositoryUrl.replace(/\/+$/, '')}/raw/branch/master/config.json`
try { try {
const config: unknown = JSON.parse(await this.httpClient.readText(url)) const config: unknown = JSON.parse(await this.httpClient.readText(url))
const sections = typeof config === 'object' && config !== null ? Object.keys(config).length : 0 const sections = typeof config === 'object' && config !== null ? Object.keys(config).length : 0
this.reportOk(' config.json', `${String(sections)} sections`) this.reportOk(' config.json', `${String(sections)} sections from the repository ` +
'(not yet moved to the registry)')
} catch (error: unknown) { } catch (error: unknown) {
this.reportOk(' config.json', `absent (${this.describe(error)}) — defaults would be used`) this.reportOk(' config.json', `absent (${this.describe(error)}) — defaults would be used`)
} }
} }
/** The prune boundary, which is the field worth seeing at a glance. */
private readSubfolder (config: Readonly<Record<string, unknown>>): string | null {
const paths = config['paths']
if (typeof paths !== 'object' || paths === null) return null
const subfolder = (paths as Readonly<Record<string, unknown>>)['subfolder']
return typeof subfolder === 'string' ? subfolder : null
}
private findStore (): InstalledStore | null { private findStore (): InstalledStore | null {
const sandbox = process.env['SMOKE_HOME'] const sandbox = process.env['SMOKE_HOME']
if (sandbox !== undefined && sandbox.length > 0) { if (sandbox !== undefined && sandbox.length > 0) {