A registry record is a name and a catalog
`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>
This commit is contained in:
@@ -62,49 +62,34 @@ and there is nothing to decide; several and the setup screen shows a picker.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{
|
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org" },
|
||||||
"name": "Teletype Games",
|
{ "name": "Some Other Store", "catalogUrl": "https://games.example.org" }
|
||||||
"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 }
|
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
**A store needs nothing of its own.** A name and a catalog are enough: the store
|
**A name and a catalog are the whole record.** The store engine's built-in defaults
|
||||||
engine's built-in defaults already cover the host-to-asset mapping, the install modes,
|
already cover the host-to-asset mapping, the install modes, the platforms and the
|
||||||
the platforms and the behaviour, so what is actually missing from them is identity — a
|
behaviour, so what is actually missing from them is identity — and identity is all a
|
||||||
slug, a name and a catalog URL — and that is exactly what a registry record carries.
|
registry says. Nothing a record carries decides where files go: how a store behaves is
|
||||||
With `config` and `storeRepositoryUrl` both null the client writes a three-section
|
fixed per installed client, which knows its own machine, and a copy of that on a server
|
||||||
config and the store installs.
|
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
|
- **the store id** — which names the store home and the folder games land in — is a slug
|
||||||
shape a store's `config.json` had, because it is that file moved into the registry.
|
of the catalog host (`teletypegames.org` becomes `teletypegames`), or of the display
|
||||||
It costs no request: it arrived with the store list.
|
name if that fails. Derived from the *catalog* on purpose: the catalog is what a store
|
||||||
2. **`storeRepositoryUrl` → `…/raw/branch/master/config.json`** — the same thing in its
|
is, so two records naming the same one are the same store and land in the same place.
|
||||||
older home, read for a registry whose stores have not moved over yet. A repository
|
Reinstalling therefore never orphans what is already installed.
|
||||||
**without** a `config.json` is treated as no repository at all.
|
- **the games folder** is that same slug inside the OS's usual place for programs, and it
|
||||||
3. **the engine's defaults** — when there is neither.
|
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:
|
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
|
||||||
- **`catalogUrl` and `name`** override the config's own `store.base_url` and
|
config that lands on disk is written by the installer from the engine's own defaults.
|
||||||
`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.
|
|
||||||
|
|
||||||
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
|
||||||
@@ -390,6 +375,18 @@ across them. One class serving three versions is the honest way to say that.
|
|||||||
|
|
||||||
## Verified, and not
|
## 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
|
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
|
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
|
test (registry reached, store skipped as it should be on a machine that has none) and
|
||||||
|
|||||||
+46
-33
@@ -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`
|
**A registry record is a name and a catalog.** Nothing else. The `config` field added in
|
||||||
records carry a `config` field — the same thing a store's `config.json` held, moved into
|
2.1.0 is gone, and so is `storeRepositoryUrl` — along with the two store repositories they
|
||||||
the record that already says what the store is. The client applies it directly, so
|
pointed at, which have been deleted.
|
||||||
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.
|
|
||||||
|
|
||||||
The old path still works. Where a record has no `config` but names a repository, the
|
2.1.0 had the registry say how each store behaves. That was the wrong shape. How a store
|
||||||
client reads `…/raw/branch/master/config.json` from it exactly as before, so a registry
|
behaves is fixed per installed client: this application carries its own store engine and
|
||||||
whose stores have not moved over is unaffected. With neither, the engine's defaults
|
is the only thing that can see the machine it is running on. A copy of that on a server
|
||||||
carry the store, as they always have. The order is: registry config, then repository
|
was a second authority over decisions this side had already made correctly — including
|
||||||
file, then defaults.
|
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
|
Keeping two stores on one machine out of each other's files needs none of it. It is a
|
||||||
back to install, but only as an identity — the config is dropped on the way in, and the
|
subfolder, and the client derives it:
|
||||||
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.
|
| **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
|
### Also
|
||||||
|
|
||||||
The headless check now reports *which* source configured a store — registry, repository
|
The public `/stores` page loses its desktop card, which advertised a `curl … | sh` for a
|
||||||
or defaults — and names the resulting prune boundary, because "it installed" and "it
|
repository that no longer exists; an ordinary computer is served by the app. The `/desktop`
|
||||||
installed where I meant" are different claims.
|
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
|
### Opening it on macOS
|
||||||
|
|
||||||
@@ -40,19 +52,20 @@ 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
|
`make check` is clean: typecheck, lint, the headless smoke test and the window self-test.
|
||||||
self-test.
|
|
||||||
|
|
||||||
All three configuration sources were exercised end to end against the real serializer
|
The install path was measured end to end against a local registry serving exactly the new
|
||||||
output, with the client's own classes and a sandbox store root:
|
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 |
|
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
|
||||||
| `config` present | the registry, with no extra request | `teletypegames` |
|
is still found as `ttg` in `ttg-desktop`, listing its ten installed titles from
|
||||||
| `config: null`, repository given | the repository's `config.json` | `teletypegames` |
|
`Application Support/teletypegames`.
|
||||||
| neither | the engine's defaults | `teletypegames` |
|
|
||||||
|
|
||||||
The renderer barrier was tested adversarially rather than assumed: an install started
|
The site side was migrated and its specs re-run, and the frontend was built — which first
|
||||||
from a record carrying `paths.subfolder: "ATTACKER"` and `paths.install_root:
|
required removing a dead `engines` list that had been failing `vue-tsc` on master, so that
|
||||||
"/tmp/pwned"` wrote `subfolder: teletypegames` and `install_root: null` to disk, because
|
page could not be built to check at all.
|
||||||
the service re-read the registry and the mapper had already dropped the config.
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "warp-engine-client",
|
"name": "warp-engine-client",
|
||||||
"productName": "WarpEngine 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.",
|
"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>",
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export class RegistryStoreDtoMapper {
|
|||||||
return {
|
return {
|
||||||
name: store.name,
|
name: store.name,
|
||||||
catalogUrl: store.catalogUrl,
|
catalogUrl: store.catalogUrl,
|
||||||
storeRepositoryUrl: store.storeRepositoryUrl,
|
|
||||||
storeId: deriveStoreId(store)
|
storeId: deriveStoreId(store)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,20 +15,8 @@ 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, catalogUrl: dto.catalogUrl }
|
||||||
name: dto.name,
|
|
||||||
catalogUrl: dto.catalogUrl,
|
|
||||||
storeRepositoryUrl: dto.storeRepositoryUrl,
|
|
||||||
config: null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,45 +30,21 @@ export class StoreProvisioningService {
|
|||||||
return this.registry.listStores()
|
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 (
|
public async installStore (
|
||||||
chosen: RegistryStore,
|
store: 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 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* Keeping two stores on one machine out of each other's files is a subfolder, derived
|
||||||
* statuses it shows, where its games land — in the same shape a store's `config.json`
|
* here from the store's own slug rather than told to us by a server.
|
||||||
* 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
|
|
||||||
/**
|
|
||||||
* Deliberately not `JsonRecord`: `domain` imports nothing from `infrastructure`, and
|
|
||||||
* a JSON object is describable without it.
|
|
||||||
*/
|
|
||||||
readonly config: Readonly<Record<string, unknown>> | null
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,28 +3,22 @@ import type { RegistryStore } from './RegistryStore'
|
|||||||
/**
|
/**
|
||||||
* A store id, from whatever the registry gave us.
|
* 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
|
* The id names the store home, the folder games land in and the launcher files, so it
|
||||||
* it has to be short and filesystem-safe. Three sources, in order of how much they
|
* has to be short and filesystem-safe. Two sources, in order of how much they were
|
||||||
* were meant to be a name:
|
* meant to be a name:
|
||||||
*
|
*
|
||||||
* 1. the repository name — `ttg-desktop-store` becomes `ttg`;
|
* 1. the catalog host — `https://teletypegames.org` becomes `teletypegames`;
|
||||||
* 2. the catalog host — `https://teletypegames.org` becomes `teletypegames`;
|
* 2. the display name, slugged, as a last resort.
|
||||||
* 3. 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 {
|
export function deriveStoreId (store: RegistryStore): string {
|
||||||
const fromRepository = store.storeRepositoryUrl === null
|
return toSlug(readHostLabel(store.catalogUrl)) || toSlug(store.name) || 'store'
|
||||||
? ''
|
|
||||||
: (lastSegment(store.storeRepositoryUrl).replace(/-(desktop-)?store$/, ''))
|
|
||||||
return toSlug(fromRepository) || toSlug(readHostLabel(store.catalogUrl)) || toSlug(store.name) || 'store'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function lastSegment (url: string): string {
|
/** `https://www.teletypegames.org/x` -> `teletypegames`. */
|
||||||
return url.replace(/\/+$/, '').split('/').pop() ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `https://www.teletypegames.org/x` → `teletypegames`. */
|
|
||||||
function readHostLabel (catalogUrl: string): string {
|
function readHostLabel (catalogUrl: string): string {
|
||||||
try {
|
try {
|
||||||
const host = new URL(catalogUrl).hostname.replace(/^www\./, '')
|
const host = new URL(catalogUrl).hostname.replace(/^www\./, '')
|
||||||
|
|||||||
@@ -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, readRecord, readString, type JsonRecord } from '../json/JsonRecord'
|
import { asRecord, 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 config and the
|
* A name and a catalog URL make a store, and are all a record carries. Anything else it
|
||||||
* repository are both optional and arrive as null when absent — a store configured by
|
* happens to say is ignored: how a store behaves is this client's own business, decided
|
||||||
* nothing but this record installs on the engine's defaults. Records missing either of
|
* by the engine it ships with. Records missing either field are dropped rather than
|
||||||
* the two required fields are dropped rather than half-used.
|
* half-used.
|
||||||
*/
|
*/
|
||||||
export class HttpStoreRegistryRepository implements StoreRegistryRepository {
|
export class HttpStoreRegistryRepository implements StoreRegistryRepository {
|
||||||
public readonly sourceUrl: string
|
public readonly sourceUrl: string
|
||||||
@@ -44,19 +44,12 @@ export class HttpStoreRegistryRepository implements StoreRegistryRepository {
|
|||||||
return parsed
|
return parsed
|
||||||
.map((row: unknown): JsonRecord | null => asRecord(row))
|
.map((row: unknown): JsonRecord | null => asRecord(row))
|
||||||
.filter((row: JsonRecord | null): row is JsonRecord => row !== null)
|
.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
|
// Both spellings, because a registry is someone else's API: ours answers
|
||||||
// camelCase, and a hand-rolled one may not.
|
// camelCase, and a hand-rolled one may not.
|
||||||
const repository = (
|
.map((row: JsonRecord): RegistryStore => ({
|
||||||
readString(row, 'storeRepositoryUrl') || readString(row, 'store_repository_url')
|
|
||||||
).trim()
|
|
||||||
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,
|
}))
|
||||||
config: readRecord(row, 'config')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((store: RegistryStore): boolean =>
|
.filter((store: RegistryStore): boolean =>
|
||||||
store.name.length > 0 && store.catalogUrl.length > 0)
|
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 { DESKTOP_STORE_ENGINE } from '../../domain/models/StoreEngine'
|
||||||
import { deriveStoreId } from '../../domain/models/StoreIdentity'
|
import { deriveStoreId } from '../../domain/models/StoreIdentity'
|
||||||
import type { StoreEngineInstaller } from '../../domain/ports/StoreEngineInstaller'
|
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 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. */
|
/** 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']
|
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.
|
* Setting up a store where there is none.
|
||||||
*
|
*
|
||||||
* Since the engine moved into this application there is nothing to download but the
|
* Nothing is downloaded and nothing is asked of a server. The engine ships in this
|
||||||
* store's own configuration, so an install is one HTTP call and one file. The store
|
* application and its defaults already cover the host-to-asset mapping, the install
|
||||||
* home stays where it was and keeps its name, because the state and the catalog cache
|
* modes, the platforms and the behaviour; what a registry record adds is identity — a
|
||||||
* beside that config are what make an existing library recognisable.
|
* 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 {
|
export class NativeStoreEngineInstaller implements StoreEngineInstaller {
|
||||||
private readonly forgeBase: string
|
public installEngine (
|
||||||
|
|
||||||
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 (
|
|
||||||
home: string,
|
home: string,
|
||||||
store: RegistryStore,
|
store: RegistryStore,
|
||||||
progress: EngineProgressListener = {}
|
progress: EngineProgressListener = {}
|
||||||
): Promise<InstalledStore> {
|
): Promise<InstalledStore> {
|
||||||
fs.mkdirSync(home, { recursive: true })
|
fs.mkdirSync(home, { recursive: true })
|
||||||
|
|
||||||
const config = await this.readStoreConfig(store, progress)
|
const storeId = deriveStoreId(store)
|
||||||
const configPath = path.join(home, CONFIG_FILE_NAME)
|
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)
|
this.removeRetiredEngine(home, progress)
|
||||||
|
|
||||||
progress.onLog?.(`${store.name} is set up in ${home}`)
|
progress.onLog?.(`${store.name} is set up in ${home}`)
|
||||||
const configStore = asRecord(config['store'])
|
return Promise.resolve({
|
||||||
return {
|
id: storeId,
|
||||||
id: configStore === null ? deriveStoreId(store) : readString(configStore, 'id', deriveStoreId(store)),
|
|
||||||
name: store.name,
|
name: store.name,
|
||||||
home,
|
home,
|
||||||
configPath,
|
configPath,
|
||||||
engine: DESKTOP_STORE_ENGINE.id
|
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.
|
* 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
|
* A store home provisioned by 1.5.0 or by a shell installer holds two Python files
|
||||||
* files that nothing reads any more. They are harmless, but a directory that still
|
* that nothing reads any more. They are harmless, but a directory that still looks
|
||||||
* looks like it holds the engine invites someone to run it against a state file
|
* like it holds the engine invites someone to run it against a state file this
|
||||||
* this application is also writing.
|
* application is also writing.
|
||||||
*/
|
*/
|
||||||
private removeRetiredEngine (home: string, progress: EngineProgressListener): void {
|
private removeRetiredEngine (home: string, progress: EngineProgressListener): void {
|
||||||
for (const fileName of RETIRED_ENGINE_FILES) {
|
for (const fileName of RETIRED_ENGINE_FILES) {
|
||||||
@@ -73,88 +81,4 @@ export class NativeStoreEngineInstaller implements StoreEngineInstaller {
|
|||||||
progress.onLog?.(`removed the retired ${fileName}`)
|
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}`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export class ServiceContainer {
|
|||||||
const stores = new FileSystemInstalledStoreRepository()
|
const stores = new FileSystemInstalledStoreRepository()
|
||||||
const catalogGateway = new NativeStoreCatalogGateway()
|
const catalogGateway = new NativeStoreCatalogGateway()
|
||||||
const registry = new HttpStoreRegistryRepository(httpClient)
|
const registry = new HttpStoreRegistryRepository(httpClient)
|
||||||
const installer = new NativeStoreEngineInstaller(httpClient)
|
const installer = new NativeStoreEngineInstaller()
|
||||||
const preferencesRepository = new JsonFilePreferencesRepository(environment)
|
const preferencesRepository = new JsonFilePreferencesRepository(environment)
|
||||||
|
|
||||||
const preferences = new PreferencesService(preferencesRepository, environment)
|
const preferences = new PreferencesService(preferencesRepository, environment)
|
||||||
|
|||||||
@@ -27,12 +27,9 @@ export function requireStringArray (value: unknown, name: string): readonly stri
|
|||||||
export function requireRegistryStore (value: unknown): RegistryStoreDto {
|
export function requireRegistryStore (value: unknown): RegistryStoreDto {
|
||||||
const record = asRecord(value)
|
const record = asRecord(value)
|
||||||
if (record === null) throw new TypeError('a store record is required')
|
if (record === null) throw new TypeError('a store record is required')
|
||||||
const repository = readString(record, 'storeRepositoryUrl')
|
|
||||||
const store: RegistryStoreDto = {
|
const store: RegistryStoreDto = {
|
||||||
name: readString(record, 'name'),
|
name: readString(record, 'name'),
|
||||||
catalogUrl: readString(record, 'catalogUrl'),
|
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')
|
storeId: readString(record, 'storeId')
|
||||||
}
|
}
|
||||||
if (store.name.length === 0 || store.catalogUrl.length === 0) {
|
if (store.name.length === 0 || store.catalogUrl.length === 0) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import path from 'node:path'
|
|||||||
import { GameDtoMapper } from '../application/mappers/GameDtoMapper'
|
import { GameDtoMapper } from '../application/mappers/GameDtoMapper'
|
||||||
import type { CatalogListing } from '../domain/models/CatalogListing'
|
import type { CatalogListing } from '../domain/models/CatalogListing'
|
||||||
import type { InstalledStore } from '../domain/models/InstalledStore'
|
import type { InstalledStore } from '../domain/models/InstalledStore'
|
||||||
import type { RegistryStore } from '../domain/models/RegistryStore'
|
|
||||||
import { DESKTOP_STORE_ENGINE } from '../domain/models/StoreEngine'
|
import { DESKTOP_STORE_ENGINE } from '../domain/models/StoreEngine'
|
||||||
import { deriveStoreId } from '../domain/models/StoreIdentity'
|
import { deriveStoreId } from '../domain/models/StoreIdentity'
|
||||||
import { NativeStoreCatalogGateway } from '../infrastructure/engine/NativeStoreCatalogGateway'
|
import { NativeStoreCatalogGateway } from '../infrastructure/engine/NativeStoreCatalogGateway'
|
||||||
@@ -66,53 +65,17 @@ class SmokeTest {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.reportOk('registry', `${String(stores.length)} store(s) from ${this.registry.sourceUrl}`)
|
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) {
|
for (const store of stores) {
|
||||||
this.reportOk(` ${store.name}`, `${store.catalogUrl} · ${deriveStoreId(store)}`)
|
this.reportOk(` ${store.name}`, `${store.catalogUrl} · ${deriveStoreId(store)}`)
|
||||||
await this.checkStoreConfig(store)
|
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.reportBad('registry', `${this.registry.sourceUrl}: ${this.describe(error)}`)
|
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 {
|
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) {
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
export interface RegistryStoreDto {
|
export interface RegistryStoreDto {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly catalogUrl: string
|
readonly catalogUrl: string
|
||||||
/** Null when the store has no repository of its own; the engine's defaults are then used. */
|
/** Derived from the catalog host, or the name — what the store will be called on disk. */
|
||||||
readonly storeRepositoryUrl: string | null
|
|
||||||
/** Derived from the repository, the catalog host or the name — what the store will be called on disk. */
|
|
||||||
readonly storeId: string
|
readonly storeId: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user