From 82590d3ec400b2637a7bd9133b1a4c9e45318c1a Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Wed, 19 Aug 2026 07:27:52 +0200 Subject: [PATCH] A registry record is a name and a catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 `-desktop`, the games folder is ``, 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) --- README.md | 71 ++++----- RELEASE_NOTES.md | 79 ++++++---- package.json | 2 +- .../mappers/RegistryStoreDtoMapper.ts | 17 +- .../services/StoreProvisioningService.ts | 42 ++--- src/domain/models/RegistryStore.ts | 23 +-- src/domain/models/StoreIdentity.ts | 26 ++-- .../HttpStoreRegistryRepository.ts | 29 ++-- .../NativeStoreEngineInstaller.ts | 146 +++++------------- src/main/composition/ServiceContainer.ts | 2 +- src/main/ipc/IpcArguments.ts | 3 - src/scripts/SmokeTest.ts | 43 +----- src/shared/contracts/dto/RegistryStoreDto.ts | 4 +- 13 files changed, 160 insertions(+), 327 deletions(-) diff --git a/README.md b/README.md index 93b3812..66221b6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 25cee27..d9005af 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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** | `-desktop`, under the usual store root | +| **games folder** | ``, 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. diff --git a/package.json b/package.json index 82bcc20..d741356 100644 --- a/package.json +++ b/package.json @@ -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 ", diff --git a/src/application/mappers/RegistryStoreDtoMapper.ts b/src/application/mappers/RegistryStoreDtoMapper.ts index 267ed62..0319810 100644 --- a/src/application/mappers/RegistryStoreDtoMapper.ts +++ b/src/application/mappers/RegistryStoreDtoMapper.ts @@ -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 } } } diff --git a/src/application/services/StoreProvisioningService.ts b/src/application/services/StoreProvisioningService.ts index cea2bbe..26d8675 100644 --- a/src/application/services/StoreProvisioningService.ts +++ b/src/application/services/StoreProvisioningService.ts @@ -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 { - 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 { - 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 } - } } diff --git a/src/domain/models/RegistryStore.ts b/src/domain/models/RegistryStore.ts index 929dc03..2d83913 100644 --- a/src/domain/models/RegistryStore.ts +++ b/src/domain/models/RegistryStore.ts @@ -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> | null } diff --git a/src/domain/models/StoreIdentity.ts b/src/domain/models/StoreIdentity.ts index b548c3c..933efc5 100644 --- a/src/domain/models/StoreIdentity.ts +++ b/src/domain/models/StoreIdentity.ts @@ -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\./, '') diff --git a/src/infrastructure/repositories/HttpStoreRegistryRepository.ts b/src/infrastructure/repositories/HttpStoreRegistryRepository.ts index 107e2ae..c909564 100644 --- a/src/infrastructure/repositories/HttpStoreRegistryRepository.ts +++ b/src/infrastructure/repositories/HttpStoreRegistryRepository.ts @@ -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) } diff --git a/src/infrastructure/repositories/NativeStoreEngineInstaller.ts b/src/infrastructure/repositories/NativeStoreEngineInstaller.ts index 417fa6f..51ca158 100644 --- a/src/infrastructure/repositories/NativeStoreEngineInstaller.ts +++ b/src/infrastructure/repositories/NativeStoreEngineInstaller.ts @@ -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 { 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 { + 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> { - 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> { - // 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 { - 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}` - } } diff --git a/src/main/composition/ServiceContainer.ts b/src/main/composition/ServiceContainer.ts index b6c2013..3ae1cb3 100644 --- a/src/main/composition/ServiceContainer.ts +++ b/src/main/composition/ServiceContainer.ts @@ -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) diff --git a/src/main/ipc/IpcArguments.ts b/src/main/ipc/IpcArguments.ts index 466e861..9b328ed 100644 --- a/src/main/ipc/IpcArguments.ts +++ b/src/main/ipc/IpcArguments.ts @@ -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) { diff --git a/src/scripts/SmokeTest.ts b/src/scripts/SmokeTest.ts index 49abfa5..c93e977 100644 --- a/src/scripts/SmokeTest.ts +++ b/src/scripts/SmokeTest.ts @@ -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 { - 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>): string | null { - const paths = config['paths'] - if (typeof paths !== 'object' || paths === null) return null - const subfolder = (paths as Readonly>)['subfolder'] - return typeof subfolder === 'string' ? subfolder : null - } - private findStore (): InstalledStore | null { const sandbox = process.env['SMOKE_HOME'] if (sandbox !== undefined && sandbox.length > 0) { diff --git a/src/shared/contracts/dto/RegistryStoreDto.ts b/src/shared/contracts/dto/RegistryStoreDto.ts index febb811..88f48e7 100644 --- a/src/shared/contracts/dto/RegistryStoreDto.ts +++ b/src/shared/contracts/dto/RegistryStoreDto.ts @@ -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 }