Files
warp-engine-client/STRUCTURE.md
T
mr.zeroandClaude Opus 5 8511ccbef8
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
WarpEngine Client: the whole catalog, and a build that can point elsewhere
**The app is called WarpEngine Client.** "Store" named the thing it opens rather than the
thing you run, and the store is a catalog on a site, not a window on your machine. The
window title, the bundle, the packages and the menu entry follow; the repository already
did. The store being driven is named in the side menu, so the bar stopped repeating it as
a badge — the element stays in the page, hidden, because the window check reads it.

**Every title is listed, including the ones this machine cannot install.** They arrive
from the engine with `installable: false` and a reason, and they are drawn dimmed, with an
*unsupported platform* or *no build for this machine* badge, the engine's own sentence
underneath, and nothing to press: a disabled Install would invite a click that can never
work. They get a category of their own — *Not for this machine* — and they are kept out of
the native/hosted categories and counts, because a title with no build has no mode to be
counted under. An engine older than desktop 1.2.0 is unaffected: a missing `installable`
field reads as installable, which is what those engines mean.

**A build can be pointed at another site's registry:**

    make dist STORES_API=https://games.example.org/api/stores

BuildConfiguration reads the packaged package.json, where electron-builder's
extraMetadata writes that address, so a client for somebody else's catalog needs no source
change and nothing set on the user's machine. Precedence is runtime environment, then
build, then ours — three audiences, most specific first.

Also: the scrollbars are the window's own, because the platform's light track down the
side menu of a dark window looked like a mistake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 19:45:54 +02:00

304 lines
14 KiB
Markdown

# 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:
- **`domain` and `application` never import `electron`.** 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.** `GameDto` has 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
config/ BuildConfiguration: what was decided when this was packaged
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.
### Build-time configuration
`infrastructure/config/BuildConfiguration.ts` reads the packaged `package.json`, which is
where a build records the registry it was made for (`warpEngine.registryUrl`, set by
`make dist STORES_API=…`). Precedence is runtime environment, then build, then the
built-in default — most specific first, and each one is a different audience: someone
trying it out, someone shipping a client for another site, us.
### 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_CASE` form (`IpcChannels.ts` exports `IPC_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`, plus `noUncheckedIndexedAccess`, `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-types` and `typedef` are errors.
- Data is `readonly`: DTO and model fields, and arrays as `readonly T[]`.
- No `any`, no non-null `!`, no unchecked casts. Foreign data goes through
`JsonRecord`; DOM lookups go through `requireElement`, which checks the element type
it was asked for.
- Exhaustive `switch` over union types, checked by `switch-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.