1 Commits
Author SHA1 Message Date
mr.zeroandClaude Opus 5 82590d3ec4 A registry record is a name and a catalog
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
`config` — added this morning in 2.1.0 — is gone, and `storeRepositoryUrl` with it, along
with the two store repositories they pointed at.

2.1.0 had the registry say how each store behaves. Wrong shape: how a store behaves is
fixed per installed client, and this application is the only thing that can see the
machine it runs on. A copy of that on a server was a second authority over decisions this
side had already made correctly — including which directories the store may delete from —
and two authorities are a way to disagree.

Keeping two stores on one machine apart needs none of it. It is a subfolder, derived here:
the store id is a slug of the catalog host, the home is `<id>-desktop`, the games folder is
`<id>`, and that folder is the only subtree the store will ever delete from. Derived from
the *catalog* on purpose — the catalog is what a store is, so two records naming the same
one are the same store and land in the same place, which makes installing twice idempotent
instead of a way to orphan what is already there.

Existing installations keep their identity: a store home is recognised by its own
`config.json`, so one installed as `ttg` stays `ttg` in `ttg-desktop` with its games where
they are. Only a new install derives its id.

`StoreProvisioningService` no longer re-reads the registry before installing. That existed
to keep the renderer from supplying a config, and with no config in the record there is
nothing left to protect: a name and a catalog have no paths in them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 07:27:52 +02:00
13 changed files with 160 additions and 327 deletions
+34 -37
View File
@@ -62,49 +62,34 @@ 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",
"config": { "store": { "id": "ttg" }, "paths": { "subfolder": "teletypegames" } }
},
{ "name": "Some Other Store", "catalogUrl": "https://games.example.org",
"storeRepositoryUrl": null, "config": null }
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org" },
{ "name": "Some Other Store", "catalogUrl": "https://games.example.org" }
]
```
**A store needs nothing of its own.** A name and a catalog are enough: the store
engine's built-in defaults already cover the host-to-asset mapping, the install modes,
the platforms and the behaviour, so what is actually missing from them is identity — a
slug, a name and a catalog URL — and that is exactly what a registry record carries.
With `config` and `storeRepositoryUrl` both null the client writes a three-section
config and the store installs.
**A name and a catalog are the whole record.** The store engine's built-in defaults
already cover the host-to-asset mapping, the install modes, the platforms and the
behaviour, so what is actually missing from them is identity — and identity is all a
registry says. Nothing a record carries decides where files go: how a store behaves is
fixed per installed client, which knows its own machine, and a copy of that on a server
would be a second authority over decisions this side has already made.
Where a store's configuration comes from, in the order the client asks:
From a record the client works out the rest:
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.
- **the store id** — which names the store home and the folder games land in — is a slug
of the catalog host (`teletypegames.org` becomes `teletypegames`), or of the display
name if that fails. Derived from the *catalog* on purpose: the catalog is what a store
is, so two records naming the same one are the same store and land in the same place.
Reinstalling therefore never orphans what is already installed.
- **the games folder** is that same slug inside the OS's usual place for programs, and it
is the only subtree this store will ever delete from. That is the whole of how two
stores on one machine stay out of each other's files: a subfolder, derived here.
- **released, archived and demo** titles are listed, where the engine alone would show
released and archived only — a catalog that publishes a demo means it to be played.
Two fields the record always decides, whatever the config says:
- **`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 config's `store.id` when it sets one; otherwise from the repository name
(`ttg-desktop-store` becomes `ttg`), then the catalog host (`teletypegames.org`
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.
Because a record has no paths in it and no config, there is nothing for the window to
tamper with: `RegistryStoreDtoMapper.toModel` can take its choice at face value, and the
config that lands on disk is written by the installer from the engine's own defaults.
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
@@ -390,6 +375,18 @@ across them. One class serving three versions is the honest way to say that.
## Verified, and not
**2.2.0** — the registry record was cut back to a name and a catalog, so the whole
install path was measured again against a local registry serving exactly that. The slug
came out `teletypegames` from the catalog host, the home `teletypegames-desktop`, the
games subfolder `teletypegames`, and installing the same record twice landed in the same
home. A record carrying `config` and `storeRepositoryUrl` — the fields a stale client or a
tampering renderer might still send — changed nothing, because neither exists in the model
any more. The site side was migrated and its specs re-run; the frontend was built, which
first required removing a dead `engines` list that had been failing `vue-tsc` on master.
Older entries below describe what was verified for the version they name, and some of
them predate the store engine moving into this application.
The pipeline's commands were run in the same containers it uses, before the pipeline was
committed: `electronuserland/builder:22` installs, type-checks, lints, passes the smoke
test (registry reached, store skipped as it should be on a machine that has none) and
+46 -33
View File
@@ -1,29 +1,41 @@
# WarpEngine Client 2.1.0
# WarpEngine Client 2.2.0
**A store's configuration now comes from the registry itself.** `GET /api/stores`
records carry a `config` field — the same thing a store's `config.json` held, moved into
the record that already says what the store is. The client applies it directly, so
installing a store no longer depends on a second repository existing and staying
reachable, and a store can be configured entirely from the site's admin.
**A registry record is a name and a catalog.** Nothing else. The `config` field added in
2.1.0 is gone, and so is `storeRepositoryUrl` — along with the two store repositories they
pointed at, which have been deleted.
The old path still works. Where a record has no `config` but names a repository, the
client reads `…/raw/branch/master/config.json` from it exactly as before, so a registry
whose stores have not moved over is unaffected. With neither, the engine's defaults
carry the store, as they always have. The order is: registry config, then repository
file, then defaults.
2.1.0 had the registry say how each store behaves. That was the wrong shape. How a store
behaves is fixed per installed client: this application carries its own store engine and
is the only thing that can see the machine it is running on. A copy of that on a server
was a second authority over decisions this side had already made correctly — including
which directories the store is allowed to delete from — and a second authority is a way
for the two to disagree.
**The window cannot supply a store config.** It is handed stores to show and hands one
back to install, but only as an identity — the config is dropped on the way in, and the
main process reads the record again from the registry before installing anything. This
is not tidiness: a config decides where files are written and, through
`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.
Keeping two stores on one machine out of each other's files needs none of it. It is a
subfolder, and the client derives it:
| | |
|---|---|
| **store id** | a slug of the catalog host — `teletypegames.org` becomes `teletypegames` |
| **store home** | `<id>-desktop`, under the usual store root |
| **games folder** | `<id>`, inside the OS's usual place for programs |
| **delete boundary** | that same folder, and nothing outside it |
Derived from the *catalog* on purpose: the catalog is what a store is, so two records
naming the same one are the same store and land in the same place. Installing the same
store twice is idempotent rather than a way to orphan what is already there.
**Existing installations are untouched.** A store already on the machine is recognised by
the `config.json` in its own home and keeps the identity written there — a store installed
as `ttg` stays `ttg`, in `ttg-desktop`, with its games where they are. Only a *new* install
derives its id from the catalog.
### Also
The headless check now reports *which* source configured a store — registry, repository
or defaults — and names the resulting prune boundary, because "it installed" and "it
installed where I meant" are different claims.
The public `/stores` page loses its desktop card, which advertised a `curl … | sh` for a
repository that no longer exists; an ordinary computer is served by the app. The `/desktop`
URL now lands on the app rather than on a device tab, so what someone typing it wants is
what they get.
### Opening it on macOS
@@ -40,19 +52,20 @@ Windows (installer, portable) packages the pipeline builds when the tag is pushe
### 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.
All three configuration sources were exercised end to end against the real serializer
output, with the client's own classes and a sandbox store root:
The install path was measured end to end against a local registry serving exactly the new
record shape. The slug came out `teletypegames`, the home `teletypegames-desktop`, the
games subfolder `teletypegames`, and installing the same record twice landed in the same
home. A record carrying `config` and `storeRepositoryUrl` — what a stale registry or a
tampering renderer might still send — changed nothing on disk, because neither field exists
in the model any more.
| Registry record | What configured the store | Prune boundary written |
|---|---|---|
| `config` present | the registry, with no extra request | `teletypegames` |
| `config: null`, repository given | the repository's `config.json` | `teletypegames` |
| neither | the engine's defaults | `teletypegames` |
On this machine the check also shows both halves of the compatibility claim at once: the
registry's new slug reads `teletypegames`, while the store that was installed before today
is still found as `ttg` in `ttg-desktop`, listing its ten installed titles from
`Application Support/teletypegames`.
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.
The site side was migrated and its specs re-run, and the frontend was built — which first
required removing a dead `engines` list that had been failing `vue-tsc` on master, so that
page could not be built to check at all.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "warp-engine-client",
"productName": "WarpEngine Client",
"version": "2.1.0",
"version": "2.2.0",
"description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.",
"license": "MIT",
"author": "Teletype Games <games@teletype.hu>",
@@ -7,7 +7,6 @@ export class RegistryStoreDtoMapper {
return {
name: store.name,
catalogUrl: store.catalogUrl,
storeRepositoryUrl: store.storeRepositoryUrl,
storeId: deriveStoreId(store)
}
}
@@ -16,20 +15,8 @@ export class RegistryStoreDtoMapper {
return stores.map((store: RegistryStore): RegistryStoreDto => this.toDto(store))
}
/**
* 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.
*/
/** The window hands a record straight back when asking for an install. */
public toModel (dto: RegistryStoreDto): RegistryStore {
return {
name: dto.name,
catalogUrl: dto.catalogUrl,
storeRepositoryUrl: dto.storeRepositoryUrl,
config: null
}
return { name: dto.name, catalogUrl: dto.catalogUrl }
}
}
@@ -30,45 +30,21 @@ export class StoreProvisioningService {
return this.registry.listStores()
}
/**
* Install the chosen store.
*
* The window's choice is taken at face value, which is safe because a record is only a
* name and a catalog: there is no path in it and nothing that decides what may be
* deleted. The store's own configuration is written by the installer from the engine's
* defaults, so the renderer cannot influence where anything lands.
*/
public async installStore (
chosen: RegistryStore,
store: RegistryStore,
progress?: EngineProgressListener
): Promise<InstalledStore> {
const store = await this.resolveFromRegistry(chosen, progress)
const home = this.stores.resolveDefaultHome(deriveStoreId(store))
const installed = await this.installer.installEngine(home, store, progress)
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 }
}
}
+7 -16
View File
@@ -1,24 +1,15 @@
/**
* A store the site's registry offers.
* A store the site's registry offers: a name and a catalog.
*
* A name and a catalog are what make a store; the other two fields are optional.
* That is the whole record, and it is enough. How a store behaves is not the registry's
* business — this client carries its own store engine, whose defaults cover the
* host-to-asset mapping, the install modes, the platforms and the behaviour — so what
* was actually missing from those defaults is identity, and identity is all this is.
*
* `config` is how that store behaves — which platforms it offers, which release
* statuses it shows, where its games land — in the same shape a store's `config.json`
* 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.
* Keeping two stores on one machine out of each other's files is a subfolder, derived
* here from the store's own slug rather than told to us by a server.
*/
export interface RegistryStore {
readonly name: string
readonly catalogUrl: string
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
}
+10 -16
View File
@@ -3,28 +3,22 @@ import type { RegistryStore } from './RegistryStore'
/**
* A store id, from whatever the registry gave us.
*
* The id names the store home, the folder games land in and the launcher files, so
* it has to be short and filesystem-safe. Three sources, in order of how much they
* were meant to be a name:
* The id names the store home, the folder games land in and the launcher files, so it
* has to be short and filesystem-safe. Two sources, in order of how much they were
* meant to be a name:
*
* 1. the repository name — `ttg-desktop-store` becomes `ttg`;
* 2. the catalog host — `https://teletypegames.org` becomes `teletypegames`;
* 3. the display name, slugged, as a last resort.
* 1. the catalog host — `https://teletypegames.org` becomes `teletypegames`;
* 2. the display name, slugged, as a last resort.
*
* The store's own config.json overrides all of it whenever one exists.
* Derived rather than carried, and derived from the catalog: the catalog is what a store
* *is*, so two records naming the same catalog are the same store and land in the same
* place, which is what keeps a reinstall from orphaning what is already there.
*/
export function deriveStoreId (store: RegistryStore): string {
const fromRepository = store.storeRepositoryUrl === null
? ''
: (lastSegment(store.storeRepositoryUrl).replace(/-(desktop-)?store$/, ''))
return toSlug(fromRepository) || toSlug(readHostLabel(store.catalogUrl)) || toSlug(store.name) || 'store'
return toSlug(readHostLabel(store.catalogUrl)) || toSlug(store.name) || 'store'
}
function lastSegment (url: string): string {
return url.replace(/\/+$/, '').split('/').pop() ?? ''
}
/** `https://www.teletypegames.org/x` → `teletypegames`. */
/** `https://www.teletypegames.org/x` -> `teletypegames`. */
function readHostLabel (catalogUrl: string): string {
try {
const host = new URL(catalogUrl).hostname.replace(/^www\./, '')
@@ -1,7 +1,7 @@
import { RegistryUnavailableError } from '../../domain/errors/RegistryUnavailableError'
import type { RegistryStore } from '../../domain/models/RegistryStore'
import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository'
import { asRecord, readRecord, readString, type JsonRecord } from '../json/JsonRecord'
import { asRecord, readString, type JsonRecord } from '../json/JsonRecord'
import { BuildConfiguration } from '../config/BuildConfiguration'
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
* the address of ours.
*
* A record needs a name and a catalog URL; those two make a store. The config and the
* repository are both optional and arrive as null when absent — a store configured by
* nothing but this record installs on the engine's defaults. Records missing either of
* the two required fields are dropped rather than half-used.
* A name and a catalog URL make a store, and are all a record carries. Anything else it
* happens to say is ignored: how a store behaves is this client's own business, decided
* by the engine it ships with. Records missing either field are dropped rather than
* half-used.
*/
export class HttpStoreRegistryRepository implements StoreRegistryRepository {
public readonly sourceUrl: string
@@ -44,19 +44,12 @@ export class HttpStoreRegistryRepository implements StoreRegistryRepository {
return parsed
.map((row: unknown): JsonRecord | null => asRecord(row))
.filter((row: JsonRecord | null): row is JsonRecord => row !== null)
.map((row: JsonRecord): RegistryStore => {
// Both spellings, because a registry is someone else's API: ours answers
// camelCase, and a hand-rolled one may not.
const repository = (
readString(row, 'storeRepositoryUrl') || readString(row, 'store_repository_url')
).trim()
return {
name: readString(row, 'name').trim(),
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim(),
storeRepositoryUrl: repository.length > 0 ? repository : null,
config: readRecord(row, 'config')
}
})
// Both spellings, because a registry is someone else's API: ours answers
// camelCase, and a hand-rolled one may not.
.map((row: JsonRecord): RegistryStore => ({
name: readString(row, 'name').trim(),
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim()
}))
.filter((store: RegistryStore): boolean =>
store.name.length > 0 && store.catalogUrl.length > 0)
}
@@ -6,12 +6,8 @@ import type { RegistryStore } from '../../domain/models/RegistryStore'
import { DESKTOP_STORE_ENGINE } from '../../domain/models/StoreEngine'
import { deriveStoreId } from '../../domain/models/StoreIdentity'
import type { StoreEngineInstaller } from '../../domain/ports/StoreEngineInstaller'
import { asRecord, readString } from '../json/JsonRecord'
import { HttpStatusError, type HttpTextClient } from '../http/HttpTextClient'
const CONFIG_FILE_NAME = 'config.json'
const DEFAULT_FORGE_BASE = 'https://git.teletypegames.org'
const DEFAULT_BRANCH = 'master'
/** What an install used to leave in a store home, back when the engine was a script. */
const RETIRED_ENGINE_FILES: readonly string[] = ['desktop_store.py', 'warpstore.py']
@@ -19,51 +15,63 @@ const RETIRED_ENGINE_FILES: readonly string[] = ['desktop_store.py', 'warpstore.
/**
* Setting up a store where there is none.
*
* Since the engine moved into this application there is nothing to download but the
* store's own configuration, so an install is one HTTP call and one file. The store
* home stays where it was and keeps its name, because the state and the catalog cache
* beside that config are what make an existing library recognisable.
* Nothing is downloaded and nothing is asked of a server. The engine ships in this
* application and its defaults already cover the host-to-asset mapping, the install
* modes, the platforms and the behaviour; what a registry record adds is identity — a
* name, a catalog and a slug — and that is what gets written.
*
* The config is written to disk rather than kept in memory because it is the store's
* own record of itself: `StoreConfigurationReader` reads it on every operation, an
* existing store home is recognised by it, and a person can look at it.
*/
export class NativeStoreEngineInstaller implements StoreEngineInstaller {
private readonly forgeBase: string
public constructor (private readonly httpClient: HttpTextClient, forgeBase?: string) {
const configured = process.env['FORGE_BASE']
this.forgeBase = forgeBase ?? (configured !== undefined && configured.length > 0
? configured
: DEFAULT_FORGE_BASE)
}
public async installEngine (
public installEngine (
home: string,
store: RegistryStore,
progress: EngineProgressListener = {}
): Promise<InstalledStore> {
fs.mkdirSync(home, { recursive: true })
const config = await this.readStoreConfig(store, progress)
const storeId = deriveStoreId(store)
const configPath = path.join(home, CONFIG_FILE_NAME)
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
fs.writeFileSync(configPath, `${JSON.stringify(this.buildConfig(store, storeId), null, 2)}\n`)
this.removeRetiredEngine(home, progress)
progress.onLog?.(`${store.name} is set up in ${home}`)
const configStore = asRecord(config['store'])
return {
id: configStore === null ? deriveStoreId(store) : readString(configStore, 'id', deriveStoreId(store)),
return Promise.resolve({
id: storeId,
name: store.name,
home,
configPath,
engine: DESKTOP_STORE_ENGINE.id
})
}
/**
* The store's configuration: its identity, and the two things worth stating.
*
* Everything absent from this falls to the engine's defaults, which is most of it. The
* subfolder is named after the store so two stores on one machine cannot reach into
* each other's files — it is the prune boundary, so it has to be the store's own.
* Demo titles are listed because a catalog that publishes them means them to be
* played; the engine defaults to released and archived only, which is the safer
* default for a store nobody configured.
*/
private buildConfig (store: RegistryStore, storeId: string): Record<string, unknown> {
return {
store: { id: storeId, name: store.name, base_url: store.catalogUrl },
paths: { subfolder: storeId },
catalog: { statuses: ['released', 'archived', 'demo'] }
}
}
/**
* Clear out the scripts an older client downloaded here.
*
* A store home provisioned by 1.5.0 or by the shell installer holds two Python
* files that nothing reads any more. They are harmless, but a directory that still
* looks like it holds the engine invites someone to run it against a state file
* this application is also writing.
* A store home provisioned by 1.5.0 or by a shell installer holds two Python files
* that nothing reads any more. They are harmless, but a directory that still looks
* like it holds the engine invites someone to run it against a state file this
* application is also writing.
*/
private removeRetiredEngine (home: string, progress: EngineProgressListener): void {
for (const fileName of RETIRED_ENGINE_FILES) {
@@ -73,88 +81,4 @@ export class NativeStoreEngineInstaller implements StoreEngineInstaller {
progress.onLog?.(`removed the retired ${fileName}`)
}
}
/**
* The store's configuration.
*
* Four cases, and all of them install:
*
* - **a config on the registry record** — the authority on how the store behaves:
* which platforms it offers, which statuses it shows, where things land. No
* 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;
* - **neither** — the same defaults, without the round trip.
*
* 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
* identity: a slug, a name and a catalog. That is exactly what a registry record
* carries, which is why a store needs no repository of its own. The registry always
* wins on those three, whatever a config file says.
*/
private async readStoreConfig (
store: RegistryStore,
progress: EngineProgressListener
): Promise<Record<string, unknown>> {
const storeId = deriveStoreId(store)
const config = await this.readPublishedConfig(store, storeId, progress)
const existing = asRecord(config['store']) ?? {}
config['store'] = {
...existing,
id: readString(existing, 'id', storeId),
name: store.name,
base_url: store.catalogUrl
}
return config
}
private async readPublishedConfig (
store: RegistryStore,
storeId: string,
progress: EngineProgressListener
): 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
if (repositoryUrl === null) {
progress.onLog?.(`${store.name} has no store repository — using the engine defaults`)
return this.defaultConfig(storeId)
}
try {
progress.onLog?.(`reading the store config from ${repositoryUrl}`)
const body = await this.httpClient.readText(this.configUrl(repositoryUrl))
return { ...(asRecord(JSON.parse(body)) ?? {}) }
} catch (error: unknown) {
if (!(error instanceof HttpStatusError) || error.statusCode !== 404) throw error
progress.onLog?.('no config.json in the store repository — using the engine defaults')
return this.defaultConfig(storeId)
}
}
/**
* What a store gets when nothing else says otherwise.
*
* Two fields, on top of the identity added by the caller. The subfolder keeps two
* stores on one machine out of each other's files, and it is the prune boundary, so
* it must be the store's own. Demo titles are listed because a catalog that
* publishes them means them to be played — the engine defaults to released and
* archived only, which is the safer default for a store nobody configured.
*/
private defaultConfig (storeId: string): Record<string, unknown> {
return {
paths: { subfolder: storeId },
catalog: { statuses: ['released', 'archived', 'demo'] }
}
}
private configUrl (repositoryUrl: string, branch: string = DEFAULT_BRANCH): string {
return `${repositoryUrl.replace(/\/+$/, '')}/raw/branch/${branch}/${CONFIG_FILE_NAME}`
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ export class ServiceContainer {
const stores = new FileSystemInstalledStoreRepository()
const catalogGateway = new NativeStoreCatalogGateway()
const registry = new HttpStoreRegistryRepository(httpClient)
const installer = new NativeStoreEngineInstaller(httpClient)
const installer = new NativeStoreEngineInstaller()
const preferencesRepository = new JsonFilePreferencesRepository(environment)
const preferences = new PreferencesService(preferencesRepository, environment)
-3
View File
@@ -27,12 +27,9 @@ export function requireStringArray (value: unknown, name: string): readonly stri
export function requireRegistryStore (value: unknown): RegistryStoreDto {
const record = asRecord(value)
if (record === null) throw new TypeError('a store record is required')
const repository = readString(record, 'storeRepositoryUrl')
const store: RegistryStoreDto = {
name: readString(record, 'name'),
catalogUrl: readString(record, 'catalogUrl'),
// Optional: a store with no repository installs on the engine's defaults.
storeRepositoryUrl: repository.length > 0 ? repository : null,
storeId: readString(record, 'storeId')
}
if (store.name.length === 0 || store.catalogUrl.length === 0) {
+3 -40
View File
@@ -3,7 +3,6 @@ import path from 'node:path'
import { GameDtoMapper } from '../application/mappers/GameDtoMapper'
import type { CatalogListing } from '../domain/models/CatalogListing'
import type { InstalledStore } from '../domain/models/InstalledStore'
import type { RegistryStore } from '../domain/models/RegistryStore'
import { DESKTOP_STORE_ENGINE } from '../domain/models/StoreEngine'
import { deriveStoreId } from '../domain/models/StoreIdentity'
import { NativeStoreCatalogGateway } from '../infrastructure/engine/NativeStoreCatalogGateway'
@@ -66,53 +65,17 @@ class SmokeTest {
return
}
this.reportOk('registry', `${String(stores.length)} store(s) from ${this.registry.sourceUrl}`)
// The slug is worth printing: it names the store home and the games subfolder, and
// it is derived here rather than told to us, so a wrong catalog URL shows up as a
// wrong folder name before anything is installed.
for (const store of stores) {
this.reportOk(` ${store.name}`, `${store.catalogUrl} · ${deriveStoreId(store)}`)
await this.checkStoreConfig(store)
}
} catch (error: unknown) {
this.reportBad('registry', `${this.registry.sourceUrl}: ${this.describe(error)}`)
}
}
/**
* Where this store's configuration would come from, in the order the installer asks.
*
* 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> {
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) {
this.reportOk(' config', 'none, and no repository — the engine defaults would be used')
return
}
const url = `${store.storeRepositoryUrl.replace(/\/+$/, '')}/raw/branch/master/config.json`
try {
const config: unknown = JSON.parse(await this.httpClient.readText(url))
const sections = typeof config === 'object' && config !== null ? Object.keys(config).length : 0
this.reportOk(' config.json', `${String(sections)} sections from the repository ` +
'(not yet moved to the registry)')
} catch (error: unknown) {
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 {
const sandbox = process.env['SMOKE_HOME']
if (sandbox !== undefined && sandbox.length > 0) {
+1 -3
View File
@@ -2,8 +2,6 @@
export interface RegistryStoreDto {
readonly name: string
readonly catalogUrl: string
/** Null when the store has no repository of its own; the engine's defaults are then used. */
readonly storeRepositoryUrl: string | null
/** Derived from the repository, the catalog host or the name — what the store will be called on disk. */
/** Derived from the catalog host, or the name — what the store will be called on disk. */
readonly storeId: string
}