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
+96
View File
@@ -0,0 +1,96 @@
import path from 'node:path'
import { BrowserWindow, dialog, type App, type IpcMain, type Shell } from 'electron'
import { ServiceContainer } from './composition/ServiceContainer'
import { MainWindowFactory } from './MainWindowFactory'
import { SelfTestRunner } from './diagnostics/SelfTestRunner'
const SELFTEST_FLAG = '--selftest'
const SELFTEST_USER_DATA_DIRECTORY = 'warpstore-gui-selftest'
const PRODUCT_NAME = 'WarpEngine Store'
/**
* The application's lifecycle.
*
* Thin on purpose: it owns the window and the process events, and hands everything
* else to the container. The self-test mode is part of the lifecycle because it has
* to bypass two of its rules — see below.
*/
export class ElectronApplication {
private readonly selfTest: boolean
private readonly container: ServiceContainer
private readonly windowFactory: MainWindowFactory
private window: BrowserWindow | null = null
public constructor (
private readonly app: App,
private readonly ipc: IpcMain,
shell: Shell,
argv: readonly string[] = process.argv
) {
this.selfTest = argv.includes(SELFTEST_FLAG)
this.container = new ServiceContainer(app, shell)
this.windowFactory = new MainWindowFactory((message: string, level: number): void => {
if (level >= 2 || this.selfTest) console.log(`[renderer] ${message}`)
})
}
public start (): void {
// A test run must never be swallowed by a copy the user already has open: it
// gets its own user-data directory and skips the single-instance lock. Without
// this the second process exits silently with status 0, which reads as a pass.
if (this.selfTest) {
this.app.setPath('userData', path.join(this.app.getPath('temp'), SELFTEST_USER_DATA_DIRECTORY))
} else if (!this.app.requestSingleInstanceLock()) {
this.app.quit()
return
}
this.container.registerIpc(this.ipc)
this.app.on('second-instance', (): void => { this.focusWindow() })
this.app.on('activate', (): void => {
if (BrowserWindow.getAllWindows().length === 0) this.openWindow()
})
this.app.on('window-all-closed', (): void => {
if (process.platform !== 'darwin') this.app.quit()
})
process.on('unhandledRejection', (reason: unknown): void => {
dialog.showErrorBox(PRODUCT_NAME, reason instanceof Error ? reason.message : String(reason))
})
void this.app.whenReady().then((): void => { this.openWindow() })
}
private openWindow (): void {
const window = this.windowFactory.createWindow()
this.window = window
this.container.streams.attachWindow(window)
window.on('closed', (): void => {
this.container.streams.detachWindow()
this.window = null
})
if (this.selfTest) this.scheduleSelfTest(window)
}
private scheduleSelfTest (window: BrowserWindow): void {
const runner = new SelfTestRunner(window)
window.webContents.once('did-finish-load', (): void => {
// The first listing has to finish before there is anything to look at.
setTimeout((): void => {
runner.run().then(
(passed: boolean): void => { this.app.exit(passed ? 0 : 1) },
(error: unknown): void => {
console.log(`SELFTEST ERROR ${error instanceof Error ? error.message : String(error)}`)
this.app.exit(1)
}
)
}, runner.settleDelayMs)
})
}
private focusWindow (): void {
const window = this.window
if (window === null) return
if (window.isMinimized()) window.restore()
window.focus()
}
}
+63
View File
@@ -0,0 +1,63 @@
import path from 'node:path'
import { BrowserWindow, shell, type BrowserWindowConstructorOptions } from 'electron'
const WINDOW_OPTIONS: BrowserWindowConstructorOptions = {
width: 1040,
height: 720,
minWidth: 760,
minHeight: 520,
backgroundColor: '#11151c',
title: 'WarpEngine Store'
}
/**
* The one window.
*
* Locked down deliberately: context isolation on, node integration off, sandbox on,
* and the page carries a CSP of its own. Nothing here should ever navigate away or
* open a second window — a link the user clicks goes to their browser instead.
*/
export class MainWindowFactory {
public constructor (private readonly onRendererMessage: (message: string, level: number) => void) {}
public createWindow (): BrowserWindow {
const window = new BrowserWindow({
...WINDOW_OPTIONS,
webPreferences: {
preload: path.join(__dirname, '..', 'preload', 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true
}
})
void window.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))
this.forwardRendererDiagnostics(window)
this.denyNavigation(window)
return window
}
/** A renderer error is invisible from the main process otherwise. */
private forwardRendererDiagnostics (window: BrowserWindow): void {
window.webContents.on('console-message', (details): void => {
const level = details.level === 'error' ? 3 : details.level === 'warning' ? 2 : 1
this.onRendererMessage(details.message, level)
})
window.webContents.on('render-process-gone', (_event, details): void => {
this.onRendererMessage(`gone: ${details.reason}`, 3)
})
}
private denyNavigation (window: BrowserWindow): void {
window.webContents.setWindowOpenHandler(({ url }: { url: string }): { action: 'deny' } => {
if (url.startsWith('https://')) void shell.openExternal(url)
return { action: 'deny' }
})
window.webContents.on('will-navigate', (event, url: string): void => {
if (url === window.webContents.getURL()) return
event.preventDefault()
if (url.startsWith('https://')) void shell.openExternal(url)
})
}
}
+78
View File
@@ -0,0 +1,78 @@
import type { App, IpcMain, Shell } from 'electron'
import { ApplicationStateService } from '../../application/services/ApplicationStateService'
import { CatalogService } from '../../application/services/CatalogService'
import { GameLaunchService } from '../../application/services/GameLaunchService'
import { PreferencesService } from '../../application/services/PreferencesService'
import { StoreProvisioningService } from '../../application/services/StoreProvisioningService'
import { StoreSelectionService } from '../../application/services/StoreSelectionService'
import { ElectronApplicationEnvironment } from '../../infrastructure/electron/ElectronApplicationEnvironment'
import { ElectronGameLauncher } from '../../infrastructure/electron/ElectronGameLauncher'
import { HttpTextClient } from '../../infrastructure/http/HttpTextClient'
import { PythonEngineProcessRunner } from '../../infrastructure/process/PythonEngineProcessRunner'
import { SystemPythonRuntimeLocator } from '../../infrastructure/process/SystemPythonRuntimeLocator'
import { FileSystemInstalledStoreRepository } from '../../infrastructure/repositories/FileSystemInstalledStoreRepository'
import { HttpStoreEngineInstaller } from '../../infrastructure/repositories/HttpStoreEngineInstaller'
import { HttpStoreRegistryRepository } from '../../infrastructure/repositories/HttpStoreRegistryRepository'
import { JsonFilePreferencesRepository } from '../../infrastructure/repositories/JsonFilePreferencesRepository'
import { PythonStoreCatalogGateway } from '../../infrastructure/repositories/PythonStoreCatalogGateway'
import { AppIpcController } from '../ipc/AppIpcController'
import { CatalogIpcController } from '../ipc/CatalogIpcController'
import { IpcRouter } from '../ipc/IpcRouter'
import { SingleFlightGuard } from '../ipc/SingleFlightGuard'
import { StoreIpcController } from '../ipc/StoreIpcController'
import { WindowStreamBroadcaster } from '../streams/WindowStreamBroadcaster'
/**
* The composition root: the only file that knows which implementation backs which
* port.
*
* Every layer above depends on interfaces, so swapping the engine for a stub or the
* registry for a local endpoint is a change here and nowhere else.
*/
export class ServiceContainer {
public readonly streams: WindowStreamBroadcaster
public readonly guard: SingleFlightGuard
public readonly catalog: CatalogService
public readonly selection: StoreSelectionService
public readonly provisioning: StoreProvisioningService
public readonly state: ApplicationStateService
public readonly launching: GameLaunchService
private readonly controllers: readonly { register: (router: IpcRouter) => void }[]
public constructor (app: App, shell: Shell) {
this.streams = new WindowStreamBroadcaster()
this.guard = new SingleFlightGuard((busy: boolean): void => { this.streams.publishBusyChanged(busy) })
const environment = new ElectronApplicationEnvironment(app)
const httpClient = new HttpTextClient()
const pythonLocator = new SystemPythonRuntimeLocator()
const engineRunner = new PythonEngineProcessRunner(pythonLocator)
const stores = new FileSystemInstalledStoreRepository()
const catalogGateway = new PythonStoreCatalogGateway(engineRunner)
const registry = new HttpStoreRegistryRepository(httpClient)
const installer = new HttpStoreEngineInstaller(httpClient)
const preferencesRepository = new JsonFilePreferencesRepository(environment)
const preferences = new PreferencesService(preferencesRepository, environment)
this.selection = new StoreSelectionService(stores, catalogGateway, preferences)
this.catalog = new CatalogService(catalogGateway, this.selection)
this.provisioning = new StoreProvisioningService(registry, installer, stores, this.selection)
this.launching = new GameLaunchService(new ElectronGameLauncher(shell), this.catalog)
this.state = new ApplicationStateService(
preferences, this.selection, this.provisioning, pythonLocator, environment
)
this.controllers = [
new AppIpcController(this.state, preferences, this.launching),
new CatalogIpcController(this.catalog, this.launching, this.guard, this.streams),
new StoreIpcController(this.provisioning, this.selection, this.guard, this.streams)
]
}
public registerIpc (ipc: IpcMain): void {
const router = new IpcRouter(ipc)
for (const controller of this.controllers) controller.register(router)
}
}
+173
View File
@@ -0,0 +1,173 @@
import fs from 'node:fs'
import type { BrowserWindow } from 'electron'
import {
asRecord, readBoolean, readNumber, readOptionalString, readString, readStringArray
} from '../../infrastructure/json/JsonRecord'
const SETTLE_DELAY_MS = 6_000
const SWITCH_SETTLE_DELAY_MS = 8_000
const SHOT_FRAME_DELAY_MS = 400
/** What the window says about itself once it has painted. */
interface SelfTestReport {
readonly cards: number
readonly installed: number
readonly buttons: number
readonly gateVisible: boolean
readonly gateTitle: string
readonly gateChoices: readonly string[]
readonly gateAction: string
readonly appName: string
readonly storeId: string
readonly navOpen: boolean
readonly stores: readonly string[]
readonly categories: readonly string[]
readonly activeCategory: string | null
readonly paths: string
readonly logLines: number
readonly locales: readonly string[]
}
/** What changed after clicking a store that was not open. */
interface StoreSwitchReport {
readonly storeId: string
readonly active: string | null
readonly cards: number
readonly categories: number
}
/**
* Drives the window once and reports what rendered.
*
* This is the only check that would notice a renderer error at all: the main
* process log stays empty when the page throws. Counting nodes is not enough on its
* own — the collapsed-grid bug passed every count while showing neither box art nor
* buttons — so `SELFTEST_SHOT` has the window photograph itself for a human to look
* at.
*/
export class SelfTestRunner {
public constructor (
private readonly window: BrowserWindow,
private readonly shotPath: string | null = process.env['SELFTEST_SHOT'] ?? null
) {}
public get settleDelayMs (): number {
return SETTLE_DELAY_MS
}
/** True when the window is in a state a user could work with. */
public async run (): Promise<boolean> {
const report = await this.readReport()
console.log(JSON.stringify(report, null, 2))
const switched = report.stores.length > 1 ? await this.switchStore() : null
if (switched !== null) console.log(`switched: ${JSON.stringify(switched)}`)
if (this.shotPath !== null) await this.captureShot(this.shotPath)
const rendered = report.locales.length > 1 && (
(report.cards > 0 && !report.gateVisible && report.stores.length > 0 &&
report.categories.length > 0 && report.activeCategory !== null) ||
(report.gateVisible && report.gateChoices.length > 0 && report.gateAction.length > 0))
const switchedWell = switched === null || (
switched.storeId.length > 0 && switched.storeId !== report.storeId &&
switched.cards > 0 && switched.categories > 0)
const passed = rendered && switchedWell
console.log(passed ? 'SELFTEST OK' : 'SELFTEST FAILED')
return passed
}
private async readReport (): Promise<SelfTestReport> {
const record = asRecord(JSON.parse(await this.evaluate(`JSON.stringify({
cards: document.querySelectorAll('.card').length,
installed: document.querySelectorAll('.card.is-installed').length,
buttons: document.querySelectorAll('.card .actions button').length,
gateVisible: !document.getElementById('gate').hidden,
gateTitle: document.getElementById('gate-title').textContent,
gateChoices: [...document.getElementById('gate-select').options].map((option) => option.text),
gateAction: document.getElementById('gate-action').textContent,
appName: document.getElementById('app-name').textContent,
storeId: document.getElementById('store-id').textContent,
navOpen: !document.body.classList.contains('nav-closed'),
stores: [...document.querySelectorAll('#store-list .store-row')].map((row) => row.textContent),
categories: [...document.querySelectorAll('#cats .cat')].map((cat) => cat.textContent),
activeCategory: (document.querySelector('#cats .cat.is-active') || {}).textContent || null,
paths: document.getElementById('log-paths').textContent.slice(0, 120),
logLines: document.querySelectorAll('.log-line').length,
locales: [...document.getElementById('locale').options].map((option) => option.value)
})`))) ?? {}
return {
cards: readNumber(record, 'cards'),
installed: readNumber(record, 'installed'),
buttons: readNumber(record, 'buttons'),
gateVisible: readBoolean(record, 'gateVisible'),
gateTitle: readString(record, 'gateTitle'),
gateChoices: readStringArray(record, 'gateChoices'),
gateAction: readString(record, 'gateAction'),
appName: readString(record, 'appName'),
storeId: readString(record, 'storeId'),
navOpen: readBoolean(record, 'navOpen'),
stores: readStringArray(record, 'stores'),
categories: readStringArray(record, 'categories'),
activeCategory: readOptionalString(record, 'activeCategory'),
paths: readString(record, 'paths'),
logLines: readNumber(record, 'logLines'),
locales: readStringArray(record, 'locales')
}
}
/**
* With two stores on the machine the switcher is the thing most likely to break
* without anyone noticing, so the test uses it. Skipped with one store, which
* cannot be switched away from.
*/
private async switchStore (): Promise<StoreSwitchReport> {
const record = asRecord(JSON.parse(await this.evaluate(`(async () => {
const other = [...document.querySelectorAll('#store-list .store-row')]
.find((row) => !row.classList.contains('is-active'))
other.click()
await new Promise((done) => setTimeout(done, ${String(SWITCH_SETTLE_DELAY_MS)}))
return JSON.stringify({
storeId: document.getElementById('store-id').textContent,
active: (document.querySelector('#store-list .store-row.is-active') || {}).textContent || null,
cards: document.querySelectorAll('.card').length,
categories: document.querySelectorAll('#cats .cat').length
})
})()`))) ?? {}
return {
storeId: readString(record, 'storeId'),
active: readOptionalString(record, 'active'),
cards: readNumber(record, 'cards'),
categories: readNumber(record, 'categories')
}
}
/**
* capturePage hands back the last painted frame, so a window that is behind
* others — or still loading box art — photographs as a half-drawn page. Focus it,
* wait for the images, then let one frame go by.
*/
private async captureShot (target: string): Promise<void> {
this.window.show()
this.window.focus()
await this.evaluate(`(async () => {
await Promise.all([...document.images].map((image) => image.complete
? null
: new Promise((done) => { image.onload = done; image.onerror = done })))
await new Promise((done) => requestAnimationFrame(() => setTimeout(done, ${String(SHOT_FRAME_DELAY_MS)})))
return String(document.images.length)
})()`)
const image = await this.window.webContents.capturePage()
fs.writeFileSync(target, image.toPNG())
console.log(`shot: ${target}`)
}
/** Every probe returns a JSON string, so nothing untyped crosses back. */
private async evaluate (script: string): Promise<string> {
const result: unknown = await this.window.webContents.executeJavaScript(script)
return typeof result === 'string' ? result : JSON.stringify(result ?? null)
}
}
+52
View File
@@ -0,0 +1,52 @@
import type { ApplicationStateService } from '../../application/services/ApplicationStateService'
import type { GameLaunchService } from '../../application/services/GameLaunchService'
import type { PreferencesService } from '../../application/services/PreferencesService'
import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels'
import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto'
import type { LocaleSelectionDto } from '../../shared/contracts/dto/LocaleSelectionDto'
import { TranslationCatalog } from '../../shared/i18n/TranslationCatalog'
import { requireBoolean, requireString } from './IpcArguments'
import type { IpcRouter } from './IpcRouter'
/** The window's own concerns: what it needs to paint, its language, its menu state. */
export class AppIpcController {
public constructor (
private readonly state: ApplicationStateService,
private readonly preferences: PreferencesService,
private readonly launching: GameLaunchService,
private readonly translations: TranslationCatalog = new TranslationCatalog()
) {}
public register (router: IpcRouter): void {
router.handle(IPC_CHANNELS.appReadState, (): AppStateDto => this.handleReadState())
router.handle(IPC_CHANNELS.appUpdateLocale, (locale: unknown): LocaleSelectionDto =>
this.handleUpdateLocale(requireString(locale, 'locale')))
router.handle(IPC_CHANNELS.appUpdateNavOpen, (open: unknown): boolean =>
this.handleUpdateNavOpen(requireBoolean(open, 'open')))
router.handle(IPC_CHANNELS.appOpenFolder, async (directory: unknown): Promise<boolean> =>
this.handleOpenFolder(requireString(directory, 'directory')))
router.handle(IPC_CHANNELS.appOpenUrl, async (url: unknown): Promise<boolean> =>
this.handleOpenUrl(requireString(url, 'url')))
}
private handleReadState (): AppStateDto {
return this.state.readState()
}
private handleUpdateLocale (candidate: string): LocaleSelectionDto {
const locale = this.preferences.updateLocale(candidate)
return { locale, messages: this.translations.readBundle(locale) }
}
private handleUpdateNavOpen (open: boolean): boolean {
return this.preferences.updateNavigationOpen(open)
}
private async handleOpenFolder (directory: string): Promise<boolean> {
return this.launching.openFolder(directory)
}
private async handleOpenUrl (url: string): Promise<boolean> {
return this.launching.openUrl(url)
}
}
+74
View File
@@ -0,0 +1,74 @@
import type { CatalogService } from '../../application/services/CatalogService'
import type { GameLaunchService } from '../../application/services/GameLaunchService'
import { GameDtoMapper } from '../../application/mappers/GameDtoMapper'
import { StorePathsDtoMapper } from '../../application/mappers/StorePathsDtoMapper'
import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels'
import type { CatalogListingDto } from '../../shared/contracts/dto/CatalogListingDto'
import type { StorePathsDto } from '../../shared/contracts/dto/StorePathsDto'
import type { WindowStreamBroadcaster } from '../streams/WindowStreamBroadcaster'
import { requireString, requireStringArray } from './IpcArguments'
import type { IpcRouter } from './IpcRouter'
import type { SingleFlightGuard } from './SingleFlightGuard'
/**
* Everything that touches the catalog.
*
* The four engine calls go through the guard; launching does not, because it starts
* someone else's program and writes nothing.
*/
export class CatalogIpcController {
public constructor (
private readonly catalog: CatalogService,
private readonly launching: GameLaunchService,
private readonly guard: SingleFlightGuard,
private readonly streams: WindowStreamBroadcaster,
private readonly gameMapper: GameDtoMapper = new GameDtoMapper(),
private readonly pathsMapper: StorePathsDtoMapper = new StorePathsDtoMapper()
) {}
public register (router: IpcRouter): void {
router.handle(IPC_CHANNELS.catalogListGames, async (): Promise<CatalogListingDto> =>
this.handleListGames())
router.handle(IPC_CHANNELS.catalogReadPaths, async (): Promise<StorePathsDto> =>
this.handleReadPaths())
router.handle(IPC_CHANNELS.catalogSyncGames, async (names: unknown): Promise<void> =>
this.handleSyncGames(names === undefined ? [] : requireStringArray(names, 'names')))
router.handle(IPC_CHANNELS.catalogRemoveGame, async (name: unknown): Promise<void> =>
this.handleRemoveGame(requireString(name, 'name')))
router.handle(IPC_CHANNELS.catalogLaunchGame, async (name: unknown): Promise<boolean> =>
this.handleLaunchGame(requireString(name, 'name')))
}
private async handleListGames (): Promise<CatalogListingDto> {
return this.guard.run(async (): Promise<CatalogListingDto> => {
const listing = await this.catalog.listGames(this.streams.asProgressListener())
const baseUrl = listing.paths?.catalogBaseUrl ?? ''
return {
games: this.gameMapper.toDtoList(listing.games, baseUrl),
skipped: listing.skipped,
paths: listing.paths === null ? null : this.pathsMapper.toDto(listing.paths)
}
})
}
private async handleReadPaths (): Promise<StorePathsDto> {
return this.guard.run(async (): Promise<StorePathsDto> =>
this.pathsMapper.toDto(await this.catalog.readPaths(this.streams.asProgressListener())))
}
private async handleSyncGames (names: readonly string[]): Promise<void> {
await this.guard.run(async (): Promise<void> => {
await this.catalog.syncGames(names, this.streams.asProgressListener())
})
}
private async handleRemoveGame (name: string): Promise<void> {
await this.guard.run(async (): Promise<void> => {
await this.catalog.removeGame(name, this.streams.asProgressListener())
})
}
private async handleLaunchGame (name: string): Promise<boolean> {
return this.launching.launchGame(name)
}
}
+40
View File
@@ -0,0 +1,40 @@
import { asRecord, readString } from '../../infrastructure/json/JsonRecord'
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
/**
* Reading what came over the bridge.
*
* The window is ours, but the channel is an interface: a payload is checked here
* once, so no service below has to wonder whether a string is really a string.
*/
export function requireString (value: unknown, name: string): string {
if (typeof value !== 'string' || value.length === 0) {
throw new TypeError(`${name} must be a non-empty string`)
}
return value
}
export function requireBoolean (value: unknown, name: string): boolean {
if (typeof value !== 'boolean') throw new TypeError(`${name} must be a boolean`)
return value
}
export function requireStringArray (value: unknown, name: string): readonly string[] {
if (!Array.isArray(value)) throw new TypeError(`${name} must be an array of strings`)
return value.map((item: unknown, index: number): string => requireString(item, `${name}[${String(index)}]`))
}
export function requireRegistryStore (value: unknown): RegistryStoreDto {
const record = asRecord(value)
if (record === null) throw new TypeError('a store record is required')
const store: RegistryStoreDto = {
name: readString(record, 'name'),
catalogUrl: readString(record, 'catalogUrl'),
storeRepositoryUrl: readString(record, 'storeRepositoryUrl'),
storeId: readString(record, 'storeId')
}
if (store.name.length === 0 || store.catalogUrl.length === 0 || store.storeRepositoryUrl.length === 0) {
throw new TypeError('a store record needs a name, a catalog URL and a repository URL')
}
return store
}
+37
View File
@@ -0,0 +1,37 @@
import type { IpcMain, IpcMainInvokeEvent } from 'electron'
import { DomainError } from '../../domain/errors/DomainError'
import type { IpcChannel } from '../../shared/contracts/IpcChannels'
/** What a channel does with the arguments it was invoked with. */
export type IpcHandler<TResult> = (...args: readonly unknown[]) => Promise<TResult> | TResult
/**
* The one place a channel is registered.
*
* Errors are normalised on the way out: a domain error crosses as `CODE: message`
* so the log drawer shows something a person can act on, and an unexpected one is
* logged here rather than vanishing into a rejected promise the window cannot read.
*/
export class IpcRouter {
public constructor (private readonly ipc: IpcMain) {}
public handle<TResult>(channel: IpcChannel, handler: IpcHandler<TResult>): void {
this.ipc.handle(channel, async (_event: IpcMainInvokeEvent, ...args: readonly unknown[]): Promise<TResult> => {
try {
return await handler(...args)
} catch (error: unknown) {
throw this.describe(channel, error)
}
})
}
private describe (channel: IpcChannel, error: unknown): Error {
if (error instanceof DomainError) return new Error(`${error.code}: ${error.message}`)
if (error instanceof Error) {
console.error(`[ipc] ${channel} failed: ${error.message}`)
return error
}
console.error(`[ipc] ${channel} failed: ${String(error)}`)
return new Error(String(error))
}
}
+30
View File
@@ -0,0 +1,30 @@
import { BusyError } from '../../domain/errors/BusyError'
/**
* One engine call at a time.
*
* The store writes files, and two writers would race. Callers are told which state
* the guard is in, so the window can disable exactly what would start a second
* call and leave the rest alive.
*/
export class SingleFlightGuard {
private running = false
public constructor (private readonly onBusyChanged: (busy: boolean) => void) {}
public get busy (): boolean {
return this.running
}
public async run<TResult>(task: () => Promise<TResult>): Promise<TResult> {
if (this.running) throw new BusyError()
this.running = true
this.onBusyChanged(true)
try {
return await task()
} finally {
this.running = false
this.onBusyChanged(false)
}
}
}
+73
View File
@@ -0,0 +1,73 @@
import { InstalledStoreDtoMapper } from '../../application/mappers/InstalledStoreDtoMapper'
import { EngineVersionDtoMapper } from '../../application/mappers/EngineVersionDtoMapper'
import { RegistryStoreDtoMapper } from '../../application/mappers/RegistryStoreDtoMapper'
import type { StoreProvisioningService } from '../../application/services/StoreProvisioningService'
import type { StoreSelectionService } from '../../application/services/StoreSelectionService'
import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels'
import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto'
import type { RegistryResultDto } from '../../shared/contracts/dto/RegistryResultDto'
import type { StoreSelectionDto } from '../../shared/contracts/dto/StoreSelectionDto'
import type { WindowStreamBroadcaster } from '../streams/WindowStreamBroadcaster'
import { requireRegistryStore, requireString } from './IpcArguments'
import type { IpcRouter } from './IpcRouter'
import type { SingleFlightGuard } from './SingleFlightGuard'
/** Which stores exist, which one is open, and installing a new one. */
export class StoreIpcController {
public constructor (
private readonly provisioning: StoreProvisioningService,
private readonly selection: StoreSelectionService,
private readonly guard: SingleFlightGuard,
private readonly streams: WindowStreamBroadcaster,
private readonly registryMapper: RegistryStoreDtoMapper = new RegistryStoreDtoMapper(),
private readonly storeMapper: InstalledStoreDtoMapper = new InstalledStoreDtoMapper(),
private readonly engineMapper: EngineVersionDtoMapper = new EngineVersionDtoMapper()
) {}
public register (router: IpcRouter): void {
router.handle(IPC_CHANNELS.storeListRegistry, async (): Promise<RegistryResultDto> =>
this.handleListRegistry())
router.handle(IPC_CHANNELS.storeInstallStore, async (store: unknown): Promise<InstalledStoreDto> =>
this.handleInstallStore(store))
router.handle(IPC_CHANNELS.storeSelectStore, (home: unknown): StoreSelectionDto =>
this.handleSelectStore(requireString(home, 'home')))
}
/**
* The registry lookup never rejects: the window has to say *why* there is nothing
* to install, and an unreachable site and an empty list need different words.
*/
private async handleListRegistry (): Promise<RegistryResultDto> {
try {
const stores = await this.provisioning.listAvailableStores()
return {
stores: this.registryMapper.toDtoList(stores),
sourceUrl: this.provisioning.registryUrl,
error: null
}
} catch (error: unknown) {
return {
stores: [],
sourceUrl: this.provisioning.registryUrl,
error: error instanceof Error ? error.message : String(error)
}
}
}
private async handleInstallStore (payload: unknown): Promise<InstalledStoreDto> {
const chosen = this.registryMapper.toModel(requireRegistryStore(payload))
return this.guard.run(async (): Promise<InstalledStoreDto> => {
const installed = await this.provisioning.installStore(chosen, this.streams.asProgressListener())
return this.storeMapper.toDto(installed)
})
}
private handleSelectStore (home: string): StoreSelectionDto {
const store = this.selection.selectStore(home)
const engine = this.selection.findEngineVersion(store)
return {
store: this.storeMapper.toDto(store),
engine: engine === null ? null : this.engineMapper.toDto(engine)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
import { app, ipcMain, shell } from 'electron'
import { ElectronApplication } from './ElectronApplication'
// The entry point does one thing: everything else is a class with a name.
new ElectronApplication(app, ipcMain, shell).start()
@@ -0,0 +1,49 @@
import type { BrowserWindow } from 'electron'
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels'
import type { SyncEventDto } from '../../shared/contracts/dto/SyncEventDto'
/**
* The three one-way streams to the window: log lines, progress events, busy state.
*
* Holds no window of its own — the reference is handed in when one exists and
* cleared when it does not, so a stream that outlives the window is a no-op rather
* than a crash.
*/
export class WindowStreamBroadcaster {
private window: BrowserWindow | null = null
public attachWindow (window: BrowserWindow): void {
this.window = window
}
public detachWindow (): void {
this.window = null
}
public publishLog (line: string): void {
this.send(IPC_CHANNELS.streamLog, line)
}
public publishSyncEvent (event: SyncEventDto): void {
this.send(IPC_CHANNELS.streamSyncEvent, event)
}
public publishBusyChanged (busy: boolean): void {
this.send(IPC_CHANNELS.streamBusyChanged, busy)
}
/** A progress listener wired to these streams, for handing to the engine. */
public asProgressListener (): EngineProgressListener {
return {
onLog: (line: string): void => { this.publishLog(line) },
onEvent: (event: SyncEventDto): void => { this.publishSyncEvent(event) }
}
}
private send (channel: string, payload: unknown): void {
const window = this.window
if (window === null || window.isDestroyed()) return
window.webContents.send(channel, payload)
}
}