TypeScript, in layers, with a strict linter

The client was one main.js, one preload.js, three files in lib/ and one renderer
script. It is now a typed application whose imports point inward: domain (models,
ports, errors) knows nothing about Electron, Node or Python; application orchestrates
it through those ports; infrastructure holds the adapters — the Python CLI, HTTP, the
filesystem, Electron itself — and main, preload and renderer sit on top as hosts.

STRUCTURE.md is the map, and the deliverable as much as the code is: every layer, every
pattern in use (ports and adapters, repository vs gateway, service, DTO and mapper,
composition root, controller and router, single flight, observer streams, state store
with unidirectional flow, passive view, coded error hierarchy, frozen constant tables,
untrusted-data readers) and the naming rules — files, classes, and a verb vocabulary
for methods where find/require/read/list/apply/render/handle each state a contract.

Two properties fell out of the move, and they are why it was worth doing:

  - The catalog can be driven with no window and no Electron at all. The smoke test
    assembles the same services against the same ports in a plain Node process; it used
    to be a script that reimplemented the bridge.
  - The window never receives a filesystem path. A title crosses the bridge without
    one, and launching is asked for by name, resolved in the main process from the
    store's own state. Verified with a fake launcher: an unknown name answers false, a
    native title resolves to its menu entry, a hosted one to its catalog URL.

Types are mandatory, including where inference would manage: explicit return,
parameter and property types, strict plus noUncheckedIndexedAccess,
exactOptionalPropertyTypes, noImplicitOverride and noPropertyAccessFromIndexSignature,
typescript-eslint strictTypeChecked and stylisticTypeChecked, exhaustive switches, no
any, no non-null assertions, and no casts on foreign data — engine stdout and the
registry go through readers that turn unknown into typed values. naming-convention
enforces the patterns rather than trusting them.

Two rule conflicts had to be decided rather than papered over. typedef and
no-inferrable-types disagree about `fallback: string = ''`: the annotation wins, since a
signature states its types. erasableSyntaxOnly is off, because it forbids constructor
parameter properties, which are how dependencies are declared here.

The preload and the renderer are bundled by esbuild into one file each: a sandboxed
preload may not require its own modules, and a module script over file:// is blocked by
the page's own origin rules. tsc compiles the rest. The package ships build/** and
package.json — 111 entries, no sources, no toolchain.

New targets: build, typecheck, lint, lint-fix, and check — typecheck, lint, then both
test suites, cheapest failure first. Every script that runs the app builds first, so a
stale bundle cannot be tested.

Nothing about the window changed: same side menu, same categories, same switcher, same
two languages. make check is clean, both test suites pass with one store and with two,
the packaged 1.3.0 bundle drives the real store, and the window was photographed before
and after — the two are the same picture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 15:29:18 +02:00
co-authored by Claude Opus 5
parent a25acf6e35
commit 3d63c8a0b0
116 changed files with 6123 additions and 1704 deletions
@@ -0,0 +1,8 @@
import type { EngineVersion } from '../../domain/models/EngineVersion'
import type { EngineVersionDto } from '../../shared/contracts/dto/EngineVersionDto'
export class EngineVersionDtoMapper {
public toDto (version: EngineVersion): EngineVersionDto {
return { text: version.text, supported: version.supported }
}
}
+48
View File
@@ -0,0 +1,48 @@
import type { Game } from '../../domain/models/Game'
import type { GameDto } from '../../shared/contracts/dto/GameDto'
const ABSOLUTE_URL = /^https?:\/\//
/**
* A title as the window may see it.
*
* Two decisions live here rather than in the renderer: the box art is resolved
* against the catalog's base URL, and whether a title can be launched is answered
* here — so the window never receives a filesystem path it could be talked into
* opening.
*/
export class GameDtoMapper {
public toDto (game: Game, catalogBaseUrl: string): GameDto {
return {
name: game.name,
title: game.title,
platform: game.platform,
version: game.version,
mode: game.mode,
kind: game.kind,
description: game.description,
author: game.author,
imageUrl: this.resolveImageUrl(game, catalogBaseUrl),
installed: game.installed,
updateAvailable: game.updateAvailable,
installedVersion: game.installedVersion,
launchable: this.isLaunchable(game)
}
}
public toDtoList (games: readonly Game[], catalogBaseUrl: string): readonly GameDto[] {
return games.map((game: Game): GameDto => this.toDto(game, catalogBaseUrl))
}
private resolveImageUrl (game: Game, catalogBaseUrl: string): string | null {
if (game.imagePath === null) return null
if (ABSOLUTE_URL.test(game.imagePath)) return game.imagePath
return catalogBaseUrl.length > 0 ? `${catalogBaseUrl}${game.imagePath}` : null
}
private isLaunchable (game: Game): boolean {
if (!game.installed) return false
if (game.mode === 'web') return game.hostedUrl !== null
return game.menuEntryPath !== null || game.executablePath !== null
}
}
@@ -0,0 +1,12 @@
import type { InstalledStore } from '../../domain/models/InstalledStore'
import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto'
export class InstalledStoreDtoMapper {
public toDto (store: InstalledStore): InstalledStoreDto {
return { id: store.id, name: store.name, home: store.home, engine: store.engine }
}
public toDtoList (stores: readonly InstalledStore[]): readonly InstalledStoreDto[] {
return stores.map((store: InstalledStore): InstalledStoreDto => this.toDto(store))
}
}
@@ -0,0 +1,27 @@
import type { RegistryStore } from '../../domain/models/RegistryStore'
import { deriveStoreId } from '../../domain/models/StoreIdentity'
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
export class RegistryStoreDtoMapper {
public toDto (store: RegistryStore): RegistryStoreDto {
return {
name: store.name,
catalogUrl: store.catalogUrl,
storeRepositoryUrl: store.storeRepositoryUrl,
storeId: deriveStoreId(store)
}
}
public toDtoList (stores: readonly RegistryStore[]): readonly RegistryStoreDto[] {
return stores.map((store: RegistryStore): RegistryStoreDto => this.toDto(store))
}
/** The window hands a record straight back when asking for an install. */
public toModel (dto: RegistryStoreDto): RegistryStore {
return {
name: dto.name,
catalogUrl: dto.catalogUrl,
storeRepositoryUrl: dto.storeRepositoryUrl
}
}
}
@@ -0,0 +1,16 @@
import type { StorePaths } from '../../domain/models/StorePaths'
import type { StorePathsDto } from '../../shared/contracts/dto/StorePathsDto'
export class StorePathsDtoMapper {
public toDto (paths: StorePaths): StorePathsDto {
return {
operatingSystem: paths.operatingSystem,
architecture: paths.architecture,
storeFolder: paths.storeFolder,
menuGroup: paths.menuGroup,
catalogBaseUrl: paths.catalogBaseUrl,
storeName: paths.storeName,
storeId: paths.storeId
}
}
}
@@ -0,0 +1,54 @@
import { MINIMUM_ENGINE_VERSION, formatVersion } from '../../domain/models/EngineVersion'
import type { InstalledStore } from '../../domain/models/InstalledStore'
import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment'
import type { PythonRuntimeLocator } from '../../domain/ports/PythonRuntimeLocator'
import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto'
import { TranslationCatalog } from '../../shared/i18n/TranslationCatalog'
import { EngineVersionDtoMapper } from '../mappers/EngineVersionDtoMapper'
import { InstalledStoreDtoMapper } from '../mappers/InstalledStoreDtoMapper'
import type { PreferencesService } from './PreferencesService'
import type { StoreProvisioningService } from './StoreProvisioningService'
import type { StoreSelectionService } from './StoreSelectionService'
/**
* Everything the window needs before it can paint anything, in one answer.
*
* One call rather than six, because the first frame should not be a sequence of
* round trips — and because the decisions the window makes from it (no Python, no
* store, an engine too old) all depend on each other.
*/
export class ApplicationStateService {
public constructor (
private readonly preferences: PreferencesService,
private readonly selection: StoreSelectionService,
private readonly provisioning: StoreProvisioningService,
private readonly pythonLocator: PythonRuntimeLocator,
private readonly environment: ApplicationEnvironment,
private readonly translations: TranslationCatalog = new TranslationCatalog(),
private readonly storeMapper: InstalledStoreDtoMapper = new InstalledStoreDtoMapper(),
private readonly engineMapper: EngineVersionDtoMapper = new EngineVersionDtoMapper()
) {}
public readState (): AppStateDto {
const locale = this.preferences.readLocale()
const stores = this.selection.listStores()
const current: InstalledStore | null = this.selection.findCurrentStore()
const engine = current === null ? null : this.selection.findEngineVersion(current)
const runtime = this.pythonLocator.findRuntime()
return {
locale,
locales: this.translations.locales,
messages: this.translations.readBundle(locale),
navigationOpen: this.preferences.readNavigationOpen(),
pythonVersion: runtime === null ? null : runtime.version,
currentStore: current === null ? null : this.storeMapper.toDto(current),
stores: this.storeMapper.toDtoList(stores),
engine: engine === null ? null : this.engineMapper.toDto(engine),
minimumEngineVersion: formatVersion(MINIMUM_ENGINE_VERSION),
registryUrl: this.provisioning.registryUrl,
defaultStoreRoot: this.selection.readDefaultStoreRoot(),
appVersion: this.environment.readVersion()
}
}
}
@@ -0,0 +1,54 @@
import type { CatalogListing } from '../../domain/models/CatalogListing'
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
import type { Game } from '../../domain/models/Game'
import type { StorePaths } from '../../domain/models/StorePaths'
import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
import type { StoreSelectionService } from './StoreSelectionService'
/**
* The catalog of the store that is open.
*
* The last listing is kept so a launch can be resolved by name: the window asks
* for "pong", and the paths it would need to start it never leave this process.
*/
export class CatalogService {
private lastListing: CatalogListing | null = null
public constructor (
private readonly catalogGateway: StoreCatalogGateway,
private readonly selection: StoreSelectionService
) {}
public async listGames (progress?: EngineProgressListener): Promise<CatalogListing> {
const listing = await this.catalogGateway.listGames(this.selection.requireCurrentStore(), progress)
this.lastListing = listing
return listing
}
public async readPaths (progress?: EngineProgressListener): Promise<StorePaths> {
return this.catalogGateway.readPaths(this.selection.requireCurrentStore(), progress)
}
public async syncGames (names: readonly string[], progress?: EngineProgressListener): Promise<void> {
await this.catalogGateway.syncGames(this.selection.requireCurrentStore(), names, progress)
this.forgetListing()
}
public async removeGame (name: string, progress?: EngineProgressListener): Promise<void> {
await this.catalogGateway.removeGame(this.selection.requireCurrentStore(), name, progress)
this.forgetListing()
}
public findGame (name: string): Game | null {
return this.lastListing?.games.find((game: Game): boolean => game.name === name) ?? null
}
public findCatalogBaseUrl (): string {
return this.lastListing?.paths?.catalogBaseUrl ?? ''
}
/** After a write the listing is stale; the window refreshes anyway. */
public forgetListing (): void {
this.lastListing = null
}
}
@@ -0,0 +1,29 @@
import type { GameLauncher } from '../../domain/ports/GameLauncher'
import type { CatalogService } from './CatalogService'
/**
* Starting a title the window asked for by name.
*
* The name is all the window has; the launch target comes from the catalog this
* process last read.
*/
export class GameLaunchService {
public constructor (
private readonly launcher: GameLauncher,
private readonly catalog: CatalogService
) {}
public async launchGame (name: string): Promise<boolean> {
const game = this.catalog.findGame(name)
if (game === null) return false
return this.launcher.launchGame(game)
}
public async openFolder (directory: string): Promise<boolean> {
return this.launcher.openFolder(directory)
}
public async openUrl (url: string): Promise<boolean> {
return this.launcher.openUrl(url)
}
}
@@ -0,0 +1,51 @@
import type { Preferences } from '../../domain/models/Preferences'
import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment'
import type { PreferencesRepository } from '../../domain/ports/PreferencesRepository'
import { TranslationCatalog } from '../../shared/i18n/TranslationCatalog'
import type { Locale } from '../../shared/i18n/MessageBundle'
/**
* What the client remembers, and what it falls back to.
*
* The reads never fail: an unreadable file, a language that no longer exists and a
* fresh install all produce the same defaults.
*/
export class PreferencesService {
public constructor (
private readonly repository: PreferencesRepository,
private readonly environment: ApplicationEnvironment,
private readonly translations: TranslationCatalog = new TranslationCatalog()
) {}
public readLocale (): Locale {
const stored = this.repository.read().locale
return this.translations.resolveLocale(stored ?? this.environment.readSystemLocale())
}
public updateLocale (candidate: string): Locale {
const locale = this.translations.resolveLocale(candidate)
this.merge({ locale })
return locale
}
public readNavigationOpen (): boolean {
return this.repository.read().navigationOpen ?? true
}
public updateNavigationOpen (open: boolean): boolean {
this.merge({ navigationOpen: open })
return open
}
public readStoreHome (): string | null {
return this.repository.read().storeHome ?? null
}
public updateStoreHome (home: string): void {
this.merge({ storeHome: home })
}
private merge (changes: Preferences): void {
this.repository.write({ ...this.repository.read(), ...changes })
}
}
@@ -0,0 +1,41 @@
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
import type { InstalledStore } from '../../domain/models/InstalledStore'
import type { RegistryStore } from '../../domain/models/RegistryStore'
import { deriveStoreId } from '../../domain/models/StoreIdentity'
import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository'
import type { StoreEngineInstaller } from '../../domain/ports/StoreEngineInstaller'
import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository'
import type { StoreSelectionService } from './StoreSelectionService'
/**
* Getting a store onto this machine.
*
* Which stores exist is the site's answer — this asks the registry and installs
* what was chosen, into the folder the shell installer would have used. The newly
* installed store becomes the open one, so the window can carry straight on.
*/
export class StoreProvisioningService {
public constructor (
private readonly registry: StoreRegistryRepository,
private readonly installer: StoreEngineInstaller,
private readonly stores: InstalledStoreRepository,
private readonly selection: StoreSelectionService
) {}
public get registryUrl (): string {
return this.registry.sourceUrl
}
public async listAvailableStores (): Promise<readonly RegistryStore[]> {
return this.registry.listStores()
}
public async installStore (
store: RegistryStore,
progress?: EngineProgressListener
): Promise<InstalledStore> {
const home = this.stores.resolveDefaultHome(deriveStoreId(store))
const installed = await this.installer.installEngine(home, store, progress)
return this.selection.adoptStore(installed)
}
}
@@ -0,0 +1,73 @@
import { StoreMissingError } from '../../domain/errors/StoreMissingError'
import type { EngineVersion } from '../../domain/models/EngineVersion'
import type { InstalledStore } from '../../domain/models/InstalledStore'
import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository'
import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
import type { PreferencesService } from './PreferencesService'
/**
* Which store is open.
*
* A machine can carry several: two catalogs, or the same catalog installed twice.
* The remembered one wins, so the window reopens where it was left; the current
* store is cached because every catalog call needs it and re-scanning the disk per
* call would be silly.
*/
export class StoreSelectionService {
private current: InstalledStore | null = null
public constructor (
private readonly stores: InstalledStoreRepository,
private readonly catalogGateway: StoreCatalogGateway,
private readonly preferences: PreferencesService
) {}
public listStores (): readonly InstalledStore[] {
return this.stores.findAll()
}
/** The store to drive, remembering the choice across runs. Null when there is none. */
public findCurrentStore (): InstalledStore | null {
const known = this.stores.findAll()
const preferredHome = this.preferences.readStoreHome()
const remembered = preferredHome === null
? undefined
: known.find((store: InstalledStore): boolean => store.home === preferredHome)
this.current = remembered ?? known[0] ?? null
return this.current
}
public requireCurrentStore (): InstalledStore {
const store = this.current ?? this.findCurrentStore()
if (store === null) throw new StoreMissingError()
return store
}
public selectStore (home: string): InstalledStore {
const store = this.stores.findByHome(home)
if (store === null) throw new StoreMissingError(home)
this.current = store
this.preferences.updateStoreHome(store.home)
return store
}
/** Adopt a store that was just installed, without a disk scan. */
public adoptStore (store: InstalledStore): InstalledStore {
this.current = store
this.preferences.updateStoreHome(store.home)
return store
}
/**
* The engine version of a store, checked per store rather than once: two stores
* on one machine can be at different versions, and the one being switched to may
* be the older one.
*/
public findEngineVersion (store: InstalledStore): EngineVersion | null {
return this.catalogGateway.readEngineVersion(store)
}
public readDefaultStoreRoot (): string {
return this.stores.readRoots()[0] ?? ''
}
}