ci/woodpecker/manual/woodpecker Pipeline was successful
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>
295 lines
14 KiB
Markdown
295 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
|
|
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_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.
|