The store engine moves into the client, and Python goes with it
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful

Reading the catalog, choosing the release that fits this machine, unpacking it,
writing the menu entry and remembering what went where all happen in process now.
There is no interpreter to find, no child process, and no JSON-lines protocol
between the two halves — `PythonEngineProcessRunner`, the runtime locator, the two
engine mappers and the version negotiation are all gone, and with them the one
unchecked cast this codebase had (engine stdout to a typed event).

What that buys a person: on Windows and on a fresh Mac the app simply works. It
used to look for `python3`, `python` and `py -3` and draw a link to python.org
where none answered.

What lands on disk is unchanged, deliberately. `config.json` and `state.json` keep
the shell engine's snake_case shape, its `<scope>:<name>` keys and its file modes,
so a machine whose library was installed by the CLI keeps it — verified against the
Python engine on the same catalog: the same 13-title listing with zero field
differences, byte-identical payloads, identical modes and an identical Info.plist,
and a re-sync over a Python-installed home that writes nothing. Remove, prune,
prune-suppression on a named sync and the v1 state migration were each exercised.

Three things worth knowing about the new code:

  - the zip reader is ~150 lines over `node:zlib`, because Node has none and this
    application has no runtime dependencies. It restores the executable bit from
    each entry's external attributes, without which nothing installed can start,
    and it refuses zip64, unknown compression and paths that escape the
    destination rather than guessing;

  - `SUPPORTED_WARP_ENGINE_VERSIONS` names the engine versions this client is
    written against, checked against the `WarpEngine-Version` header every
    response carries. `selectCatalogDialect` switches over that list exhaustively,
    so adding a version fails the build — type checker and linter both — until
    somebody says what its catalog reads like. An absent header is read as the
    oldest version, which is what an engine before 0.4.0 is;

  - refresh and the language picker are icons at the foot of the side menu now,
    both named for a tooltip and a screen reader, the picker still a real
    `<select>` under its glyph.

The repository is free of Python as well: the Makefile, the CI check and the
release script read package.json and the forge's JSON with Node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 23:36:25 +02:00
co-authored by Claude Opus 5
parent 8511ccbef8
commit 06f3f2a3b1
66 changed files with 3327 additions and 762 deletions
+41 -22
View File
@@ -12,9 +12,9 @@ that reaches the window is a safety question, not a style one.
```
shared ← contracts and strings both sides need (no logic, no I/O)
domain ← models, ports, errors. Knows nothing about Electron, Node or Python
domain ← models, ports, errors. Knows nothing about Electron or Node
application ← services and DTO mappers. Orchestrates the domain through its ports
infrastructure ← adapters: the Python CLI, HTTP, the filesystem, Electron itself
infrastructure ← adapters: the store engine, HTTP, the filesystem, Electron itself
main ← the Electron host: window, IPC controllers, composition root
preload ← the bridge, and only the bridge
renderer ← the window: state store, views, controllers
@@ -57,10 +57,13 @@ src/
services/ CatalogService, StoreSelectionService, …
mappers/ domain → DTO
infrastructure/
process/ Python: locating it, running it, reading its streams
engine/ the store engine: catalog, releases, install, state
dialects/ one per WarpEngine version's catalog shape
launchers/ .desktop, .app bundle, .lnk — the three hosts
archive/ ZipArchive: a zip reader over node:zlib
files/ StoreFileSystem: atomic writes and the delete guard
repositories/ the port implementations
mappers/ engine JSON → domain
http/ HttpTextClient, HttpStatusError
http/ HttpTextClient, StoreHttpClient, HttpStatusError
json/ JsonRecord: reading data that came from elsewhere
config/ BuildConfiguration: what was decided when this was packaged
electron/ ApplicationEnvironment and GameLauncher adapters
@@ -95,9 +98,13 @@ on this list, it belongs on this list.
### Ports and adapters
`domain/ports/*` are interfaces; `infrastructure/*` implements them; the composition
root is the only file that knows which implementation is in use. This is what makes
the Python CLI, the registry HTTP call and Electron's `shell` replaceable — by a stub
in a test, by a local endpoint in development, by a second engine later.
root is the only file that knows which implementation is in use. This is what makes the
store engine, the registry HTTP call and Electron's `shell` replaceable — by a stub in a
test, by a local endpoint in development, by a second host's engine later.
It has already paid for itself once: the engine used to be a Python CLI driven as a child
process, and replacing it with one that runs in process was a new adapter behind the same
port. Nothing in `application`, `main` or `renderer` changed shape for it.
### Repository and Gateway
@@ -105,8 +112,10 @@ Both are ports; the distinction is what is behind them.
- **Repository** — a store of records this application owns the shape of:
`InstalledStoreRepository`, `PreferencesRepository`, `StoreRegistryRepository`.
- **Gateway** — another program or service with its own protocol:
`StoreCatalogGateway` (the engine).
- **Gateway** — something with a protocol of its own, whether or not it is another
process: `StoreCatalogGateway` (the store engine), which today is
`NativeStoreCatalogGateway` in this application and was a Python CLI before it. The port
stays async because the work is: it downloads and unpacks.
### Service
@@ -119,8 +128,11 @@ controller or a view.
Data crossing a boundary is a DTO, and a mapper converts. Two boundaries, two
directions:
- `infrastructure/mappers/Engine*Mapper` — engine JSON (snake_case) → domain model.
These are the only files that know the engine's field names.
- `infrastructure/engine/dialects/*` — catalog JSON → typed catalog records. These are
the only files that know a WarpEngine version's field names.
- `infrastructure/engine/StoreConfigurationReader`, `StoreStateRepository` — the two
snake_case files on disk → domain models. These are the only files that know the on-disk
field names, which are the shell engine's and stay that way.
- `application/mappers/*DtoMapper` — domain model → DTO for the bridge. Decisions the
window must not make live here: the absolute box-art URL, whether a title can be
launched at all.
@@ -176,8 +188,8 @@ never calls the bridge.
### Error hierarchy with codes
`DomainError` is abstract with a `code`; subclasses name a single failure
(`PythonMissingError`, `StoreMissingError`, `EngineInvocationError`,
`RegistryUnavailableError`, `BusyError`). The code is what crosses the bridge.
(`StoreMissingError`, `EngineInvocationError`, `RegistryUnavailableError`, `BusyError`).
The code is what crosses the bridge.
### Frozen constant tables
@@ -195,7 +207,8 @@ trying it out, someone shipping a client for another site, us.
### Untrusted-data readers
Anything parsed from outside — engine stdout, the registry — goes through
Anything parsed from outside — the catalog, a store's config, the state file, the
registry — goes through
`infrastructure/json/JsonRecord.ts`: `unknown` in, a typed value with a stated
fallback out. No `as` casts on foreign data.
@@ -219,7 +232,7 @@ The names are a pattern, not a preference, and are checked by
|---|---|---|
| Domain model | plain noun, no suffix | `Game`, `InstalledStore` |
| Port | `<Subject>Repository` / `Gateway` / `Locator` / `Installer` / `Launcher` | `StoreCatalogGateway` |
| Adapter | `<Technology><Port>` | `PythonStoreCatalogGateway`, `HttpStoreRegistryRepository`, `FileSystemInstalledStoreRepository` |
| Adapter | `<Technology><Port>` | `NativeStoreCatalogGateway`, `HttpStoreRegistryRepository`, `FileSystemInstalledStoreRepository` |
| Service | `<Area>Service` | `CatalogService` |
| Mapper | `<Subject>Mapper` / `<Subject>DtoMapper` | `EngineGameMapper`, `GameDtoMapper` |
| Wire type | `<Subject>Dto` | `CatalogListingDto` |
@@ -227,7 +240,7 @@ The names are a pattern, not a preference, and are checked by
| Renderer controller | `<Area>Controller` | `StoreController` |
| View | `<Region>View` | `SideMenuView`, `GameCardView` |
| Factory | `<Product>Factory` | `MainWindowFactory` |
| Error | `<Cause>Error` | `PythonMissingError` |
| Error | `<Cause>Error` | `StoreMissingError` |
| Callback bag | `<Owner>Callbacks` | `SideMenuViewCallbacks` |
| Type parameter | `T`-prefixed | `TResult`, `TElement` |
@@ -281,12 +294,18 @@ dependencies are declared here, and that is worth more than being strippable by
the implementation to `preload.ts`, a `handle…` method to the right controller, and the
behaviour to a service. The compiler names every file you missed.
**A new engine (e.g. RetroArch).** Add an entry to `STORE_ENGINES`. The store
discovery, the home suffix and the launcher name all read from that table; the CLI has
the same command shape, so `PythonStoreCatalogGateway` is unchanged.
**A new engine (e.g. RetroArch).** Add an entry to `STORE_ENGINES` — the store discovery,
the home suffix and the launcher name all read from that table — and a `StoreCatalogGateway`
implementation for that host. Everything above the port is unchanged.
**A new field from the engine.** `EngineGameMapper` reads it into the model, `GameDto`
and `GameDtoMapper` carry it across if the window needs it, and a view renders it.
**A new WarpEngine version.** Add it to `SUPPORTED_WARP_ENGINE_VERSIONS`. The build then
fails in `selectCatalogDialect` until the switch says which `CatalogDialect` reads it:
either an existing one, when the catalog's shape did not change, or a new one beside
`SoftwareListCatalogDialect`.
**A new field from the catalog.** The dialect reads it into `CatalogSoftware`,
`CatalogRelease` or `CatalogAsset`; `SelectedGame` and `Game` carry it if the survey or the
window needs it; `GameDto` and `GameDtoMapper` take it across the bridge.
**A new language.** Add `<Language>Messages.ts` typed as `MessageBundle`, add the code
to `LOCALES` and the bundle to `TranslationCatalog`. A missing key will not compile.