A store no longer needs a repository of its own. The engine's built-in defaults already cover the host-to-asset mapping, the install modes, the platforms and the behaviour; what they cannot know is identity — a slug, a name and a catalog URL — and that is exactly what a registry record carries. So `storeRepositoryUrl` is optional: a record with a name and a catalog is a complete store, the id falls back from the repository name to the catalog host (`teletypegames.org` becomes `teletypegames`) to the display name, and the client writes a three-section config. Given a repository it still reads it, and that file stays the authority on how the store behaves; a repository without a config.json is treated as no repository at all. Measured end to end against a local registry serving one record with a null repository: the engine and the core downloaded, engine 1.1.0 accepted the written config, it listed the same ten titles the configured store does, and a hosted title synced into a sandbox with its menu entry written. The "Install all" button is gone, and with it the string it used. Titles are installed one at a time from their own cards. No footer. The window carried a bar at the bottom at all times — a toggle and a line of absolute paths — for something most sessions never need. The log is still there, folder buttons included, behind a quiet switch at the bottom of the side menu; it takes no room until it is opened, and an arriving line does not open it, because the store logs on every refresh and a window that unfolds panels by itself is worse than one that keeps quiet. Three faults that every automated count had passed, found by photographing the setup screen: the store badge rendered as an empty pill with no store open; the gate's picker showed as an empty dropdown stub, because an explicit `display` beats the browser's own `[hidden]` rule; and the gate went up while the empty-catalog line stayed on screen underneath it. The last was a design fault — whether the gate is up was a call on a view rather than state, so the two could disagree. The setup screen is now a field in the state store, and that one field decides which of the gate and the grid is drawn. The window test's gate assertion was wrong too: it demanded a store picker, which only appears when the registry offers more than one store, so one store — the ordinary case — failed it. CI builds the packages this machine cannot. `.woodpecker.yaml` runs the checks on every push and, on a tag or by hand, builds the Linux packages in `electronuserland/builder:22` and the Windows ones in `:22-wine`, then attaches them to the release with scripts/ci-upload.sh. The pipeline lives here rather than in the update server's `/build/config` extension, which serves game-platform pipelines publishing into the site's catalog — a different product with a different target. macOS stays a local build: Apple's toolchain and its signing exist only on a Mac. Both build steps verify what they produced, because a half-finished Wine build leaves a 162 KB stub named like the real installer and `ls` is happy with it. The Linux step was rehearsed locally in the same image (AppImage 128 MB, deb 100 MB); the Wine step cannot be rehearsed on Apple Silicon, where 16 KB host pages break Wine's 4 KB assumption, so the runner is where it is proven. The size check was tested against both outcomes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
14 KiB
Structure
This is the map of the client: which layer may know about which, what every kind of class is called, and which pattern is used where. It is written to be read before adding anything — the point of the layout is that a new feature has an obvious place.
The application drives a program that installs and deletes files. That is why the
rules below are strict rather than tasteful: an implicit any or a filesystem path
that reaches the window is a safety question, not a style one.
The layers
shared ← contracts and strings both sides need (no logic, no I/O)
domain ← models, ports, errors. Knows nothing about Electron, Node or Python
application ← services and DTO mappers. Orchestrates the domain through its ports
infrastructure ← adapters: the Python CLI, 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
The dependency rule: imports point inward. domain imports nothing but shared.
application imports domain and shared. infrastructure implements domain
ports. main, preload and renderer are hosts: they may import inward, and nothing
imports them. There is no barrel file and no index.ts re-export — every import names
the module it needs, so a cycle is visible in the diff that creates it.
Two consequences worth stating, because they are the reason the layout pays for itself:
domainandapplicationnever importelectron. The smoke test assembles the same services with no Electron at all (src/scripts/SmokeTest.ts), which is how the catalog is exercised in a terminal.- The renderer never receives a filesystem path it could act on.
GameDtohas no paths; a launch is asked for by name and resolved in the main process.
The tree
src/
shared/
contracts/
IpcChannels.ts every channel name, frozen, in one table
BridgeApi.ts the whole surface the window gets
dto/ what crosses the bridge: plain, JSON-safe data
i18n/
EnglishMessages.ts the key set, and the English bundle
HungarianMessages.ts typed against those keys
MessageBundle.ts MessageBundle, Locale, LOCALES
TranslationCatalog.ts locale resolution and bundle lookup
domain/
models/ Game, InstalledStore, RegistryStore, StorePaths, …
ports/ the interfaces the application depends on
errors/ DomainError and its subclasses, each with a code
application/
services/ CatalogService, StoreSelectionService, …
mappers/ domain → DTO
infrastructure/
process/ Python: locating it, running it, reading its streams
repositories/ the port implementations
mappers/ engine JSON → domain
http/ HttpTextClient, HttpStatusError
json/ JsonRecord: reading data that came from elsewhere
electron/ ApplicationEnvironment and GameLauncher adapters
main/
main.ts the entry point: one line of work
ElectronApplication.ts lifecycle, single instance, self-test mode
MainWindowFactory.ts the window and its security settings
composition/ ServiceContainer: the composition root
ipc/ IpcRouter, the controllers, the guard, argument readers
streams/ WindowStreamBroadcaster: the three one-way streams
diagnostics/ SelfTestRunner
preload/
preload.ts implements BridgeApi over ipcRenderer
renderer/
main.ts the entry point
RendererApplication.ts wires views and controllers, owns the boot decision
BridgeAccess.ts the typed window.storeApi
state/ AppStore, CategoryFilter
views/ one class per region of the window
controllers/ one class per group of actions
dom/ Dom.ts: the DOM chores
index.html, style.css copied into the build as-is
scripts/
SmokeTest.ts the second composition root, with no window
Patterns
Every pattern in the codebase is listed here. If a change needs a pattern that is not 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.
Repository and Gateway
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).
Service
application/services/* — one service per area of behaviour, no HTTP, no fs, no
child_process. A service may depend on ports and on other services, never on a
controller or a view.
DTO and Mapper
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.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.
Composition root
main/composition/ServiceContainer.ts for the application, scripts/SmokeTest.ts for
the headless check. Wiring happens in exactly these two places. No service constructs
its own adapter, and there is no service locator or global registry — dependencies
arrive through constructors.
Controller and Router
main/ipc/*IpcController register their channels on IpcRouter and translate a
channel invocation into one service call. They validate their arguments
(IpcArguments.ts) and map results through DTO mappers. The router normalises errors
so a DomainError crosses as CODE: message.
Renderer controllers (renderer/controllers/*) are the mirror image: a user action
becomes one bridge call and one write to the state store.
Single flight
SingleFlightGuard — one engine call at a time, because the store writes files and
two writers would race. It reports its state, which is what lets the window disable
exactly the controls that would start a second call and leave the filters and the log
alive.
Observer streams
Main pushes three one-way streams — log lines, progress events, busy state — through
WindowStreamBroadcaster, which the engine sees as an EngineProgressListener. The
renderer subscribes once, in EngineStreamController.
State store and unidirectional flow
renderer/state/AppStore.ts holds the whole window state. Every mutator is named
after what it changes and notifies afterwards; RendererApplication re-renders every
view from the new state. Views never read each other and never hold state, so a
listing can be thrown away and rebuilt.
Screens are state, not calls. The setup screen lives in the state as
gate: GatePresentation | null, and that one field decides whether the gate or the
grid is drawn. While it was two imperative calls the two disagreed: the gate went up
and the empty-catalog line stayed on screen underneath it.
Passive view
renderer/views/* — a view takes its DOM nodes and callbacks in the constructor and
has one render(state) method. It contains no decisions beyond presentation, and it
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.
Frozen constant tables
IPC_CHANNELS, STORE_ENGINES, the message bundles: as const tables with a derived
type, so a typo is a compile error and adding an entry is the whole change. This is
the extension point for a second engine.
Untrusted-data readers
Anything parsed from outside — engine stdout, the registry — goes through
infrastructure/json/JsonRecord.ts: unknown in, a typed value with a stated
fallback out. No as casts on foreign data.
Naming
The names are a pattern, not a preference, and are checked by
@typescript-eslint/naming-convention where a linter can check them.
Files
- One primary export per file; the filename is the subject in
PascalCase(CatalogService.ts,GameDto.ts). - A file whose primary export is a constant table is named for the table, and the
export is its
UPPER_SNAKE_CASEform (IpcChannels.tsexportsIPC_CHANNELS). - Directories are lowercase and plural where they hold several of a kind (
models,ports,views,services).
Types and classes
| Kind | Pattern | Example |
|---|---|---|
| Domain model | plain noun, no suffix | Game, InstalledStore |
| Port | <Subject>Repository / Gateway / Locator / Installer / Launcher |
StoreCatalogGateway |
| Adapter | <Technology><Port> |
PythonStoreCatalogGateway, HttpStoreRegistryRepository, FileSystemInstalledStoreRepository |
| Service | <Area>Service |
CatalogService |
| Mapper | <Subject>Mapper / <Subject>DtoMapper |
EngineGameMapper, GameDtoMapper |
| Wire type | <Subject>Dto |
CatalogListingDto |
| IPC controller | <Domain>IpcController |
CatalogIpcController |
| Renderer controller | <Area>Controller |
StoreController |
| View | <Region>View |
SideMenuView, GameCardView |
| Factory | <Product>Factory |
MainWindowFactory |
| Error | <Cause>Error |
PythonMissingError |
| Callback bag | <Owner>Callbacks |
SideMenuViewCallbacks |
| Type parameter | T-prefixed |
TResult, TElement |
Interfaces carry no I prefix: a port is named for what it does, and its
implementations say what they are made of.
Methods
The verb states the contract, so a caller knows what a name will do before reading it.
| Prefix | Contract |
|---|---|
find… |
returns the thing or null / an array; absence is normal |
require… |
returns the thing or throws; absence is a fault |
read… |
fetches from a store, a file or a process |
list… |
returns a collection from somewhere outside |
install…, sync…, remove…, select…, update… |
changes something |
apply… |
writes to the renderer state store |
render… |
draws (views only) |
handle… |
an IPC or DOM event handler |
on… |
a callback property or subscription |
to… / from… |
a mapper conversion |
is…, has…, can… |
a boolean |
describe… |
turns something into a message for a person |
Booleans read as assertions (supported, installed, launchable, busy), never
flag or status.
Type rules
strict, plusnoUncheckedIndexedAccess,exactOptionalPropertyTypes,noImplicitOverride,noImplicitReturns,noPropertyAccessFromIndexSignature,noFallthroughCasesInSwitch,isolatedModules.- Every signature is annotated — parameters, return types, class properties —
including where inference would manage:
explicit-function-return-type,explicit-module-boundary-typesandtypedefare errors. - Data is
readonly: DTO and model fields, and arrays asreadonly T[]. - No
any, no non-null!, no unchecked casts. Foreign data goes throughJsonRecord; DOM lookups go throughrequireElement, which checks the element type it was asked for. - Exhaustive
switchover union types, checked byswitch-exhaustiveness-check— the sync-event union is handled that way on purpose.
erasableSyntaxOnly is deliberately off: constructor parameter properties are how
dependencies are declared here, and that is worth more than being strippable by
node --experimental-strip-types.
How to add things
A new bridge call. Add the channel to IPC_CHANNELS, the method to BridgeApi,
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 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 language. Add <Language>Messages.ts typed as MessageBundle, add the code
to LOCALES and the bundle to TranslationCatalog. A missing key will not compile.
Build layout
tsc compiles the main process to CommonJS in build/. The preload and the renderer
are bundled by esbuild into one file each (build/preload/preload.js,
build/renderer/app.js), because a sandboxed preload may not require its own modules
and a module script over file:// is blocked by the page's origin rules. index.html
and style.css are copied. electron-builder ships build/** and nothing else.
make check is the gate: typecheck, lint, then the two test suites — the cheapest
check that can fail runs first.