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:
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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] ?? ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { DomainError } from './DomainError'
|
||||
|
||||
/** A second engine call while one is running. The store writes files; two writers race. */
|
||||
export class BusyError extends DomainError {
|
||||
public override readonly code: string = 'BUSY'
|
||||
|
||||
public constructor () {
|
||||
super('the store is busy with another operation')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* The base for every error this application raises on purpose.
|
||||
*
|
||||
* `code` is what crosses the bridge: the window shows its own sentence for a code
|
||||
* it knows, and the message only ever ends up in the log drawer.
|
||||
*/
|
||||
export abstract class DomainError extends Error {
|
||||
public abstract readonly code: string
|
||||
|
||||
protected constructor (message: string) {
|
||||
super(message)
|
||||
this.name = new.target.name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { DomainError } from './DomainError'
|
||||
|
||||
/** The engine ran and failed: a non-zero exit, or a process that never started. */
|
||||
export class EngineInvocationError extends DomainError {
|
||||
public override readonly code: string = 'ENGINE_FAILED'
|
||||
|
||||
public constructor (message: string, public readonly exitCode: number | null = null) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { DomainError } from './DomainError'
|
||||
|
||||
export class PythonMissingError extends DomainError {
|
||||
public override readonly code: string = 'PYTHON_MISSING'
|
||||
|
||||
public constructor () {
|
||||
super('python3 was not found on this machine')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { DomainError } from './DomainError'
|
||||
|
||||
export class RegistryUnavailableError extends DomainError {
|
||||
public override readonly code: string = 'REGISTRY_UNAVAILABLE'
|
||||
|
||||
public constructor (public readonly sourceUrl: string, reason: string) {
|
||||
super(`${sourceUrl}: ${reason}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { DomainError } from './DomainError'
|
||||
|
||||
export class StoreMissingError extends DomainError {
|
||||
public override readonly code: string = 'STORE_MISSING'
|
||||
|
||||
public constructor (home?: string) {
|
||||
super(home === undefined ? 'no store is installed yet' : `no store at ${home}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Game } from './Game'
|
||||
import type { StorePaths } from './StorePaths'
|
||||
|
||||
/** One reading of a store's catalog. */
|
||||
export interface CatalogListing {
|
||||
readonly games: readonly Game[]
|
||||
readonly skipped: readonly string[]
|
||||
readonly paths: StorePaths | null
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { SyncEventDto } from '../../shared/contracts/dto/SyncEventDto'
|
||||
|
||||
/**
|
||||
* How a long-running engine call reports itself.
|
||||
*
|
||||
* `onLog` is a line a person can read (the engine's stderr), `onEvent` one of its
|
||||
* JSON progress events. Both are optional: a caller that only wants the result
|
||||
* passes neither.
|
||||
*/
|
||||
export interface EngineProgressListener {
|
||||
readonly onLog?: (line: string) => void
|
||||
readonly onEvent?: (event: SyncEventDto) => void
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* The engine version this client found, and whether it can drive it.
|
||||
*
|
||||
* `--json` arrived with engine 1.1.0. An older engine is not broken, it simply
|
||||
* cannot be driven from a window — and it will be met in the wild, because the CLI
|
||||
* shipped before this client did.
|
||||
*/
|
||||
export interface EngineVersion {
|
||||
readonly text: string
|
||||
readonly numbers: readonly number[] | null
|
||||
readonly supported: boolean
|
||||
}
|
||||
|
||||
export const MINIMUM_ENGINE_VERSION: readonly number[] = [1, 1, 0]
|
||||
|
||||
export function parseVersionNumbers (text: string): readonly number[] | null {
|
||||
const match = /(\d+)\.(\d+)\.(\d+)/.exec(text)
|
||||
return match ? match.slice(1, 4).map((part: string): number => Number(part)) : null
|
||||
}
|
||||
|
||||
export function isAtLeast (version: readonly number[] | null, minimum: readonly number[]): boolean {
|
||||
if (version === null) return false
|
||||
for (let index = 0; index < minimum.length; index += 1) {
|
||||
const found = version[index] ?? 0
|
||||
const needed = minimum[index] ?? 0
|
||||
if (found > needed) return true
|
||||
if (found < needed) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function formatVersion (numbers: readonly number[]): string {
|
||||
return numbers.join('.')
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/** How a title runs: unpacked on this machine, or served as a web build. */
|
||||
export type GameMode = 'app' | 'web'
|
||||
|
||||
/**
|
||||
* A catalog entry, with what the store did about it on this machine.
|
||||
*
|
||||
* The launch targets live here and nowhere nearer the window: resolving what to
|
||||
* open is the main process's job.
|
||||
*/
|
||||
export interface Game {
|
||||
readonly name: string
|
||||
readonly title: string
|
||||
readonly platform: string
|
||||
readonly version: string
|
||||
readonly mode: GameMode
|
||||
readonly kind: string
|
||||
readonly description: string
|
||||
readonly author: string
|
||||
/** Relative to the catalog's base URL, as published. */
|
||||
readonly imagePath: string | null
|
||||
readonly installed: boolean
|
||||
readonly updateAvailable: boolean
|
||||
readonly installedVersion: string | null
|
||||
readonly menuEntryPath: string | null
|
||||
readonly executablePath: string | null
|
||||
readonly hostedUrl: string | null
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** A store engine installed on this machine, with everything needed to run it. */
|
||||
export interface InstalledStore {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly home: string
|
||||
readonly scriptPath: string
|
||||
readonly configPath: string
|
||||
readonly engine: string
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Locale } from '../../shared/i18n/MessageBundle'
|
||||
|
||||
/** What the client remembers between runs. Every field optional: a fresh install has none. */
|
||||
export interface Preferences {
|
||||
readonly locale?: Locale
|
||||
readonly navigationOpen?: boolean
|
||||
/** The home of the store last opened, so the window reopens where it was left. */
|
||||
readonly storeHome?: string
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** The Python 3 this machine has, and how to invoke it. */
|
||||
export interface PythonRuntime {
|
||||
readonly command: string
|
||||
readonly arguments: readonly string[]
|
||||
readonly version: string
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* A store the site's registry offers.
|
||||
*
|
||||
* Three fields, because that is what a record is: what it is called, which
|
||||
* catalog it serves, and where its configuration lives.
|
||||
*/
|
||||
export interface RegistryStore {
|
||||
readonly name: string
|
||||
readonly catalogUrl: string
|
||||
readonly storeRepositoryUrl: string
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* A store engine this client knows how to drive.
|
||||
*
|
||||
* There is one today. The table exists because the RetroArch store has the same
|
||||
* command shape, so a second entry — not a second code path — is what adding it
|
||||
* would take.
|
||||
*/
|
||||
export interface StoreEngine {
|
||||
readonly id: string
|
||||
readonly scriptFileName: string
|
||||
/** The installer names a store home `<store id><homeSuffix>`. */
|
||||
readonly homeSuffix: string
|
||||
readonly launcherSuffix: string
|
||||
}
|
||||
|
||||
export const DESKTOP_STORE_ENGINE: StoreEngine = {
|
||||
id: 'desktop',
|
||||
scriptFileName: 'desktop_store.py',
|
||||
homeSuffix: '-desktop',
|
||||
launcherSuffix: '-desktop-store'
|
||||
}
|
||||
|
||||
export const STORE_ENGINES: readonly StoreEngine[] = [DESKTOP_STORE_ENGINE]
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { RegistryStore } from './RegistryStore'
|
||||
|
||||
/**
|
||||
* A store id from its repository name: `ttg-desktop-store` becomes `ttg`.
|
||||
*
|
||||
* The id names the store home and the folder games land in, so it has to be short
|
||||
* and filesystem-safe. The repository name is the best source available before
|
||||
* anything is downloaded; the store's own config.json overrides it once it is.
|
||||
*/
|
||||
export function deriveStoreId (store: RegistryStore): string {
|
||||
const lastSegment = store.storeRepositoryUrl.replace(/\/+$/, '').split('/').pop() ?? ''
|
||||
const base = lastSegment.replace(/-(desktop-)?store$/, '') || store.name
|
||||
const slug = base.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
return slug || 'store'
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** The store's resolved locations on this machine, as the engine reports them. */
|
||||
export interface StorePaths {
|
||||
readonly operatingSystem: string
|
||||
readonly architecture: string
|
||||
readonly installRoot: string
|
||||
readonly menuDirectory: string
|
||||
readonly storeFolder: string
|
||||
readonly menuGroup: string
|
||||
readonly catalogBaseUrl: string
|
||||
readonly storeName: string
|
||||
readonly storeId: string
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** What the host application knows about itself: its version, locale and storage. */
|
||||
export interface ApplicationEnvironment {
|
||||
readVersion: () => string
|
||||
readSystemLocale: () => string
|
||||
resolveUserDataPath: (fileName: string) => string
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Game } from '../models/Game'
|
||||
|
||||
/** Opening things outside this application: a game, a folder, a page. */
|
||||
export interface GameLauncher {
|
||||
launchGame: (game: Game) => Promise<boolean>
|
||||
openFolder: (directory: string) => Promise<boolean>
|
||||
openUrl: (url: string) => Promise<boolean>
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { InstalledStore } from '../models/InstalledStore'
|
||||
|
||||
/** The stores present on this machine, wherever the shell installer would put them. */
|
||||
export interface InstalledStoreRepository {
|
||||
findAll: () => readonly InstalledStore[]
|
||||
findByHome: (home: string) => InstalledStore | null
|
||||
/** The roots that are searched, in the order the shell installer would use them. */
|
||||
readRoots: () => readonly string[]
|
||||
resolveDefaultHome: (storeId: string) => string
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Preferences } from '../models/Preferences'
|
||||
|
||||
export interface PreferencesRepository {
|
||||
read: () => Preferences
|
||||
write: (preferences: Preferences) => void
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { PythonRuntime } from '../models/PythonRuntime'
|
||||
|
||||
export interface PythonRuntimeLocator {
|
||||
findRuntime: () => PythonRuntime | null
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { CatalogListing } from '../models/CatalogListing'
|
||||
import type { EngineProgressListener } from '../models/EngineProgress'
|
||||
import type { EngineVersion } from '../models/EngineVersion'
|
||||
import type { InstalledStore } from '../models/InstalledStore'
|
||||
import type { StorePaths } from '../models/StorePaths'
|
||||
|
||||
/**
|
||||
* The store engine, as an interface.
|
||||
*
|
||||
* Every catalog operation this client performs is one call on this port; the CLI
|
||||
* behind it stays the product, and nothing above this line knows it is Python.
|
||||
*/
|
||||
export interface StoreCatalogGateway {
|
||||
listGames: (store: InstalledStore, progress?: EngineProgressListener) => Promise<CatalogListing>
|
||||
readPaths: (store: InstalledStore, progress?: EngineProgressListener) => Promise<StorePaths>
|
||||
syncGames: (store: InstalledStore, names: readonly string[], progress?: EngineProgressListener) => Promise<void>
|
||||
removeGame: (store: InstalledStore, name: string, progress?: EngineProgressListener) => Promise<void>
|
||||
readEngineVersion: (store: InstalledStore) => EngineVersion | null
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { EngineProgressListener } from '../models/EngineProgress'
|
||||
import type { InstalledStore } from '../models/InstalledStore'
|
||||
import type { RegistryStore } from '../models/RegistryStore'
|
||||
|
||||
/**
|
||||
* Setting up a store where there is none.
|
||||
*
|
||||
* This is why the client exists on Windows at all: the store's own installer is
|
||||
* `curl … | sh`, which Windows does not have.
|
||||
*/
|
||||
export interface StoreEngineInstaller {
|
||||
installEngine: (
|
||||
home: string,
|
||||
store: RegistryStore,
|
||||
progress?: EngineProgressListener
|
||||
) => Promise<InstalledStore>
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { RegistryStore } from '../models/RegistryStore'
|
||||
|
||||
/** Which stores exist at all — the site's answer, not this client's. */
|
||||
export interface StoreRegistryRepository {
|
||||
readonly sourceUrl: string
|
||||
listStores: () => Promise<readonly RegistryStore[]>
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import path from 'node:path'
|
||||
import type { App } from 'electron'
|
||||
import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment'
|
||||
|
||||
/** The host application, as the services see it. Keeps `electron` out of them. */
|
||||
export class ElectronApplicationEnvironment implements ApplicationEnvironment {
|
||||
public constructor (private readonly app: App) {}
|
||||
|
||||
public readVersion (): string {
|
||||
return this.app.getVersion()
|
||||
}
|
||||
|
||||
public readSystemLocale (): string {
|
||||
return this.app.getLocale()
|
||||
}
|
||||
|
||||
public resolveUserDataPath (fileName: string): string {
|
||||
return path.join(this.app.getPath('userData'), fileName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { Shell } from 'electron'
|
||||
import type { Game } from '../../domain/models/Game'
|
||||
import type { GameLauncher } from '../../domain/ports/GameLauncher'
|
||||
|
||||
/**
|
||||
* Launching what was installed.
|
||||
*
|
||||
* A hosted title is a URL, so it goes to the browser. A native one is whatever the
|
||||
* store recorded: on macOS the app bundle through `open`, elsewhere the executable
|
||||
* from its own directory — the same working directory the menu entry uses, because
|
||||
* games load their assets relative to it.
|
||||
*/
|
||||
export class ElectronGameLauncher implements GameLauncher {
|
||||
public constructor (private readonly shell: Shell) {}
|
||||
|
||||
public async launchGame (game: Game): Promise<boolean> {
|
||||
if (game.mode === 'web' && game.hostedUrl !== null) {
|
||||
await this.shell.openExternal(game.hostedUrl)
|
||||
return true
|
||||
}
|
||||
|
||||
const target = game.menuEntryPath ?? game.executablePath
|
||||
if (target === null || !fs.existsSync(target)) return false
|
||||
|
||||
if (process.platform === 'darwin' && target.endsWith('.app')) {
|
||||
this.spawnDetached('open', [target], path.dirname(target))
|
||||
return true
|
||||
}
|
||||
|
||||
if (process.platform === 'win32' || target.endsWith('.desktop')) {
|
||||
const failure = await this.shell.openPath(target)
|
||||
if (failure === '') return true
|
||||
}
|
||||
|
||||
const executable = game.executablePath ?? target
|
||||
this.spawnDetached(executable, [], path.dirname(executable))
|
||||
return true
|
||||
}
|
||||
|
||||
public async openFolder (directory: string): Promise<boolean> {
|
||||
if (directory.length === 0) return false
|
||||
const failure = await this.shell.openPath(directory)
|
||||
return failure === ''
|
||||
}
|
||||
|
||||
public async openUrl (url: string): Promise<boolean> {
|
||||
if (!url.startsWith('https://')) return false
|
||||
await this.shell.openExternal(url)
|
||||
return true
|
||||
}
|
||||
|
||||
private spawnDetached (command: string, commandArguments: readonly string[], cwd: string): void {
|
||||
spawn(command, [...commandArguments], { cwd, detached: true, stdio: 'ignore' }).unref()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 60_000
|
||||
const MAX_REDIRECTS = 5
|
||||
const USER_AGENT = 'warp-engine-desktop-gui'
|
||||
|
||||
/** A response that arrived but said no. The status matters: 404 is not a failure everywhere. */
|
||||
export class HttpStatusError extends Error {
|
||||
public constructor (public readonly url: string, public readonly statusCode: number) {
|
||||
super(`${url} answered ${String(statusCode)}`)
|
||||
this.name = 'HttpStatusError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET a URL as text, following redirects.
|
||||
*
|
||||
* Node's own client rather than `fetch`, because this runs in the main process
|
||||
* where the proxy and certificate settings are the system's, and because a moved
|
||||
* repository answers 301.
|
||||
*/
|
||||
export class HttpTextClient {
|
||||
public async readText (url: string, redirectsLeft: number = MAX_REDIRECTS): Promise<string> {
|
||||
return new Promise<string>((resolve: (body: string) => void, reject: (error: Error) => void): void => {
|
||||
const client = url.startsWith('http://') ? http : https
|
||||
const request = client.get(url, { headers: { 'User-Agent': USER_AGENT } }, (response): void => {
|
||||
const status = response.statusCode ?? 0
|
||||
const location = response.headers.location
|
||||
|
||||
if (status >= 300 && status < 400 && location !== undefined) {
|
||||
response.resume()
|
||||
if (redirectsLeft <= 0) {
|
||||
reject(new Error(`too many redirects for ${url}`))
|
||||
return
|
||||
}
|
||||
const next = new URL(location, url).toString()
|
||||
this.readText(next, redirectsLeft - 1).then(resolve, reject)
|
||||
return
|
||||
}
|
||||
|
||||
if (status !== 200) {
|
||||
response.resume()
|
||||
reject(new HttpStatusError(url, status))
|
||||
return
|
||||
}
|
||||
|
||||
let body = ''
|
||||
response.setEncoding('utf8')
|
||||
response.on('data', (chunk: string): void => { body += chunk })
|
||||
response.on('end', (): void => { resolve(body) })
|
||||
})
|
||||
|
||||
request.setTimeout(REQUEST_TIMEOUT_MS, (): void => {
|
||||
request.destroy(new Error(`${url} timed out`))
|
||||
})
|
||||
request.on('error', reject)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Reading JSON that came from somewhere else.
|
||||
*
|
||||
* The engine's stdout and the site's registry are both outside this program, so
|
||||
* their shape is a claim, not a fact. These readers turn `unknown` into typed
|
||||
* values with a stated fallback, which keeps every parser honest and every mapper
|
||||
* free of casts.
|
||||
*/
|
||||
export type JsonRecord = Readonly<Record<string, unknown>>
|
||||
|
||||
export function asRecord (value: unknown): JsonRecord | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as JsonRecord)
|
||||
: null
|
||||
}
|
||||
|
||||
export function readString (record: JsonRecord, key: string, fallback: string = ''): string {
|
||||
const value = record[key]
|
||||
return typeof value === 'string' ? value : fallback
|
||||
}
|
||||
|
||||
export function readOptionalString (record: JsonRecord, key: string): string | null {
|
||||
const value = record[key]
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
export function readBoolean (record: JsonRecord, key: string, fallback: boolean = false): boolean {
|
||||
const value = record[key]
|
||||
return typeof value === 'boolean' ? value : fallback
|
||||
}
|
||||
|
||||
export function readNumber (record: JsonRecord, key: string, fallback: number = 0): number {
|
||||
const value = record[key]
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
|
||||
export function readStringArray (record: JsonRecord, key: string): readonly string[] {
|
||||
const value = record[key]
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((item: unknown): item is string => typeof item === 'string')
|
||||
}
|
||||
|
||||
export function readRecordArray (record: JsonRecord, key: string): readonly JsonRecord[] {
|
||||
const value = record[key]
|
||||
if (!Array.isArray(value)) return []
|
||||
return value
|
||||
.map((item: unknown): JsonRecord | null => asRecord(item))
|
||||
.filter((item: JsonRecord | null): item is JsonRecord => item !== null)
|
||||
}
|
||||
|
||||
export function readRecord (record: JsonRecord, key: string): JsonRecord | null {
|
||||
return asRecord(record[key])
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Game, GameMode } from '../../domain/models/Game'
|
||||
import {
|
||||
readBoolean, readOptionalString, readString, type JsonRecord
|
||||
} from '../json/JsonRecord'
|
||||
|
||||
/**
|
||||
* One engine JSON entry to one domain model.
|
||||
*
|
||||
* The engine speaks snake_case and this is the only place that knows it: rename a
|
||||
* field there and this mapper is the single file that follows.
|
||||
*/
|
||||
export class EngineGameMapper {
|
||||
public toModel (record: JsonRecord): Game {
|
||||
return {
|
||||
name: readString(record, 'name'),
|
||||
title: readString(record, 'title'),
|
||||
platform: readString(record, 'platform'),
|
||||
version: readString(record, 'version'),
|
||||
mode: this.toMode(readString(record, 'mode')),
|
||||
kind: readString(record, 'kind'),
|
||||
description: readString(record, 'desc'),
|
||||
author: readString(record, 'author'),
|
||||
imagePath: readOptionalString(record, 'image_url'),
|
||||
installed: readBoolean(record, 'installed'),
|
||||
updateAvailable: readBoolean(record, 'update_available'),
|
||||
installedVersion: readOptionalString(record, 'installed_version'),
|
||||
menuEntryPath: readOptionalString(record, 'menu_entry'),
|
||||
executablePath: readOptionalString(record, 'exe'),
|
||||
hostedUrl: readOptionalString(record, 'url')
|
||||
}
|
||||
}
|
||||
|
||||
private toMode (value: string): GameMode {
|
||||
return value === 'web' ? 'web' : 'app'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { StorePaths } from '../../domain/models/StorePaths'
|
||||
import { readRecord, readString, type JsonRecord } from '../json/JsonRecord'
|
||||
|
||||
/** The engine's `paths` answer to one domain model. */
|
||||
export class EngineStorePathsMapper {
|
||||
public toModel (record: JsonRecord): StorePaths {
|
||||
const store = readRecord(record, 'store')
|
||||
return {
|
||||
operatingSystem: readString(record, 'os'),
|
||||
architecture: readString(record, 'arch'),
|
||||
installRoot: readString(record, 'install_root'),
|
||||
menuDirectory: readString(record, 'menu_dir'),
|
||||
storeFolder: readString(record, 'store_folder'),
|
||||
menuGroup: readString(record, 'menu_group'),
|
||||
catalogBaseUrl: store === null ? '' : readString(store, 'base_url'),
|
||||
storeName: store === null ? '' : readString(store, 'name'),
|
||||
storeId: store === null ? '' : readString(store, 'id')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { EngineInvocationError } from '../../domain/errors/EngineInvocationError'
|
||||
import { PythonMissingError } from '../../domain/errors/PythonMissingError'
|
||||
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
|
||||
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
||||
import type { PythonRuntimeLocator } from '../../domain/ports/PythonRuntimeLocator'
|
||||
import type { SyncEventDto } from '../../shared/contracts/dto/SyncEventDto'
|
||||
import { asRecord, type JsonRecord } from '../json/JsonRecord'
|
||||
|
||||
const VERSION_PROBE_TIMEOUT_MS = 15_000
|
||||
|
||||
/**
|
||||
* Runs one engine command and reads its two streams.
|
||||
*
|
||||
* The engine's contract with any client is `--json`: data on stdout, one JSON
|
||||
* object per line, and the human-readable log on stderr. So nothing here parses a
|
||||
* sentence meant for a person, and a line that is not JSON is handed to the log
|
||||
* rather than crashing the call.
|
||||
*/
|
||||
export class PythonEngineProcessRunner {
|
||||
public constructor (private readonly runtimeLocator: PythonRuntimeLocator) {}
|
||||
|
||||
public async runCommand (
|
||||
store: InstalledStore,
|
||||
commandArguments: readonly string[],
|
||||
progress: EngineProgressListener = {}
|
||||
): Promise<readonly JsonRecord[]> {
|
||||
const runtime = this.runtimeLocator.findRuntime()
|
||||
if (runtime === null) throw new PythonMissingError()
|
||||
|
||||
const argv = [
|
||||
...runtime.arguments,
|
||||
store.scriptPath,
|
||||
'--config', store.configPath,
|
||||
'--json',
|
||||
...commandArguments
|
||||
]
|
||||
|
||||
return new Promise<readonly JsonRecord[]>((
|
||||
resolve: (records: readonly JsonRecord[]) => void,
|
||||
reject: (error: Error) => void
|
||||
): void => {
|
||||
const child = spawn(runtime.command, argv, {
|
||||
env: { ...process.env, DESKTOP_STORE_HOME: store.home }
|
||||
})
|
||||
const records: JsonRecord[] = []
|
||||
let stdoutRest = ''
|
||||
let stderrRest = ''
|
||||
|
||||
const takeStdout = (chunk: string): void => {
|
||||
stdoutRest += chunk
|
||||
const parts = stdoutRest.split('\n')
|
||||
stdoutRest = parts.pop() ?? ''
|
||||
for (const part of parts) this.consumeStdoutLine(part, records, progress)
|
||||
}
|
||||
|
||||
const takeStderr = (chunk: string): void => {
|
||||
stderrRest += chunk
|
||||
const parts = stderrRest.split('\n')
|
||||
stderrRest = parts.pop() ?? ''
|
||||
for (const part of parts) {
|
||||
if (part.trim().length > 0) progress.onLog?.(part)
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', takeStdout)
|
||||
child.stderr.on('data', takeStderr)
|
||||
child.on('error', (error: Error): void => {
|
||||
reject(new EngineInvocationError(error.message))
|
||||
})
|
||||
child.on('close', (code: number | null): void => {
|
||||
takeStdout('\n')
|
||||
takeStderr('\n')
|
||||
if (code === 0) resolve(records)
|
||||
else reject(new EngineInvocationError(`the store exited with code ${String(code)}`, code))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** The engine's `--version`, read synchronously because it gates the first paint. */
|
||||
public readVersionText (store: InstalledStore): string | null {
|
||||
const runtime = this.runtimeLocator.findRuntime()
|
||||
if (runtime === null) return null
|
||||
try {
|
||||
const probe = spawnSync(runtime.command, [...runtime.arguments, store.scriptPath, '--version'], {
|
||||
encoding: 'utf8',
|
||||
timeout: VERSION_PROBE_TIMEOUT_MS
|
||||
})
|
||||
const text = `${probe.stdout}${probe.stderr}`.trim()
|
||||
return probe.status === 0 && text.length > 0 ? text : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private consumeStdoutLine (
|
||||
line: string,
|
||||
records: JsonRecord[],
|
||||
progress: EngineProgressListener
|
||||
): void {
|
||||
if (line.trim().length === 0) return
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(line)
|
||||
} catch {
|
||||
// Not ours to interpret — hand it on as a log line rather than fail the call.
|
||||
progress.onLog?.(line)
|
||||
return
|
||||
}
|
||||
const record = asRecord(parsed)
|
||||
if (record === null) return
|
||||
records.push(record)
|
||||
const event = this.asSyncEvent(record)
|
||||
if (event !== null) progress.onEvent?.(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* A progress line, or null for the final result object.
|
||||
*
|
||||
* The engine tags its stream events with `event`; the listing and paths answers
|
||||
* carry no such field, which is exactly the difference.
|
||||
*/
|
||||
private asSyncEvent (record: JsonRecord): SyncEventDto | null {
|
||||
return typeof record['event'] === 'string' ? (record as unknown as SyncEventDto) : null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import type { PythonRuntime } from '../../domain/models/PythonRuntime'
|
||||
import type { PythonRuntimeLocator } from '../../domain/ports/PythonRuntimeLocator'
|
||||
|
||||
interface RuntimeCandidate {
|
||||
readonly command: string
|
||||
readonly arguments: readonly string[]
|
||||
}
|
||||
|
||||
/** `py -3` is the Windows launcher, and often the only Python on PATH there. */
|
||||
const WINDOWS_CANDIDATES: readonly RuntimeCandidate[] = [
|
||||
{ command: 'py', arguments: ['-3'] },
|
||||
{ command: 'python', arguments: [] },
|
||||
{ command: 'python3', arguments: [] }
|
||||
]
|
||||
|
||||
const POSIX_CANDIDATES: readonly RuntimeCandidate[] = [
|
||||
{ command: 'python3', arguments: [] },
|
||||
{ command: 'python', arguments: [] }
|
||||
]
|
||||
|
||||
const PROBE_TIMEOUT_MS = 10_000
|
||||
|
||||
/**
|
||||
* Finds the Python 3 the store needs.
|
||||
*
|
||||
* The answer is cached: the probe spawns processes, and the window asks for it on
|
||||
* every state read.
|
||||
*/
|
||||
export class SystemPythonRuntimeLocator implements PythonRuntimeLocator {
|
||||
private cached: PythonRuntime | null = null
|
||||
private probed = false
|
||||
|
||||
public findRuntime (): PythonRuntime | null {
|
||||
if (this.probed) return this.cached
|
||||
this.probed = true
|
||||
const candidates = process.platform === 'win32' ? WINDOWS_CANDIDATES : POSIX_CANDIDATES
|
||||
for (const candidate of candidates) {
|
||||
const runtime = this.probeCandidate(candidate)
|
||||
if (runtime !== null) {
|
||||
this.cached = runtime
|
||||
return runtime
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private probeCandidate (candidate: RuntimeCandidate): PythonRuntime | null {
|
||||
try {
|
||||
const probe = spawnSync(candidate.command, [...candidate.arguments, '--version'], {
|
||||
encoding: 'utf8',
|
||||
timeout: PROBE_TIMEOUT_MS
|
||||
})
|
||||
const output = `${probe.stdout}${probe.stderr}`
|
||||
if (probe.status === 0 && output.includes('Python 3.')) {
|
||||
return { command: candidate.command, arguments: candidate.arguments, version: output.trim() }
|
||||
}
|
||||
} catch {
|
||||
// An absent interpreter is the normal case, not an error worth reporting.
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
||||
import { DESKTOP_STORE_ENGINE, STORE_ENGINES } from '../../domain/models/StoreEngine'
|
||||
import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository'
|
||||
import { asRecord, readString } from '../json/JsonRecord'
|
||||
|
||||
const STORE_DIRECTORY_NAME = 'warp-engine-store'
|
||||
const CONFIG_FILE_NAME = 'config.json'
|
||||
|
||||
/**
|
||||
* Finds stores where the shell installers put them.
|
||||
*
|
||||
* The roots are searched in the installers' own order, and `STORE_ROOT` comes
|
||||
* first so a sandbox can be driven without touching a working installation — which
|
||||
* is how this repository is tested.
|
||||
*/
|
||||
export class FileSystemInstalledStoreRepository implements InstalledStoreRepository {
|
||||
public findAll (): readonly InstalledStore[] {
|
||||
const found: InstalledStore[] = []
|
||||
for (const root of this.readRoots()) {
|
||||
for (const entry of this.readDirectories(root)) {
|
||||
const home = path.join(root, entry)
|
||||
const store = this.readStoreAt(home, entry)
|
||||
if (store !== null) found.push(store)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
public findByHome (home: string): InstalledStore | null {
|
||||
return this.findAll().find((store: InstalledStore): boolean => store.home === home) ?? null
|
||||
}
|
||||
|
||||
public readRoots (): readonly string[] {
|
||||
const home = os.homedir()
|
||||
const roots: string[] = []
|
||||
const override = process.env['STORE_ROOT']
|
||||
if (override !== undefined && override.length > 0) roots.push(override)
|
||||
const xdgDataHome = process.env['XDG_DATA_HOME']
|
||||
if (xdgDataHome !== undefined && xdgDataHome.length > 0) {
|
||||
roots.push(path.join(xdgDataHome, STORE_DIRECTORY_NAME))
|
||||
}
|
||||
roots.push(path.join(home, '.local', 'share', STORE_DIRECTORY_NAME))
|
||||
if (process.platform === 'darwin') {
|
||||
roots.push(path.join(home, 'Library', 'Application Support', STORE_DIRECTORY_NAME))
|
||||
}
|
||||
const localAppData = process.env['LOCALAPPDATA']
|
||||
if (process.platform === 'win32' && localAppData !== undefined && localAppData.length > 0) {
|
||||
roots.push(path.join(localAppData, STORE_DIRECTORY_NAME))
|
||||
}
|
||||
return [...new Set(roots)]
|
||||
}
|
||||
|
||||
public resolveDefaultHome (storeId: string): string {
|
||||
const root = this.readRoots()[0] ?? path.join(os.homedir(), '.local', 'share', STORE_DIRECTORY_NAME)
|
||||
return path.join(root, `${storeId}${DESKTOP_STORE_ENGINE.homeSuffix}`)
|
||||
}
|
||||
|
||||
private readDirectories (root: string): readonly string[] {
|
||||
try {
|
||||
return fs.readdirSync(root, { withFileTypes: true })
|
||||
.filter((entry: fs.Dirent): boolean => entry.isDirectory())
|
||||
.map((entry: fs.Dirent): string => entry.name)
|
||||
} catch {
|
||||
// A root that does not exist is the normal case on a fresh machine.
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private readStoreAt (home: string, directoryName: string): InstalledStore | null {
|
||||
for (const engine of STORE_ENGINES) {
|
||||
const scriptPath = path.join(home, engine.scriptFileName)
|
||||
const configPath = path.join(home, CONFIG_FILE_NAME)
|
||||
if (!fs.existsSync(scriptPath) || !fs.existsSync(configPath)) continue
|
||||
const id = directoryName.replace(engine.homeSuffix, '')
|
||||
return {
|
||||
id,
|
||||
name: this.readStoreName(configPath, id),
|
||||
home,
|
||||
scriptPath,
|
||||
configPath,
|
||||
engine: engine.id
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The store's own name, from the config the installer wrote.
|
||||
*
|
||||
* Read here rather than asked of the engine: the switcher lists every store on
|
||||
* the machine, and starting a Python process per entry to learn its name would
|
||||
* be absurd.
|
||||
*/
|
||||
private readStoreName (configPath: string, fallback: string): string {
|
||||
try {
|
||||
const config = asRecord(JSON.parse(fs.readFileSync(configPath, 'utf8')))
|
||||
const store = config === null ? null : asRecord(config['store'])
|
||||
const name = store === null ? '' : readString(store, 'name')
|
||||
return name.length > 0 ? name : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
|
||||
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
||||
import type { RegistryStore } from '../../domain/models/RegistryStore'
|
||||
import { DESKTOP_STORE_ENGINE } from '../../domain/models/StoreEngine'
|
||||
import { deriveStoreId } from '../../domain/models/StoreIdentity'
|
||||
import type { StoreEngineInstaller } from '../../domain/ports/StoreEngineInstaller'
|
||||
import { asRecord, readString } from '../json/JsonRecord'
|
||||
import { HttpStatusError, type HttpTextClient } from '../http/HttpTextClient'
|
||||
|
||||
const CONFIG_FILE_NAME = 'config.json'
|
||||
const SCRIPT_MODE = 0o755
|
||||
const PYTHON_SHEBANG = '#!/usr/bin/env python3'
|
||||
const DEFAULT_FORGE_BASE = 'https://git.teletypegames.org'
|
||||
const DEFAULT_BRANCH = 'master'
|
||||
|
||||
/**
|
||||
* Downloads the engine, the shared core and a store config into a store home.
|
||||
*
|
||||
* The same three files, in the same folder, the shell installer would place — so
|
||||
* the CLI and this client stay one installation, and running install.sh afterwards
|
||||
* only adds the launcher script. No launcher is written here: the window is it.
|
||||
*/
|
||||
export class HttpStoreEngineInstaller implements StoreEngineInstaller {
|
||||
private readonly forgeBase: string
|
||||
|
||||
public constructor (private readonly httpClient: HttpTextClient, forgeBase?: string) {
|
||||
const configured = process.env['FORGE_BASE']
|
||||
this.forgeBase = forgeBase ?? (configured !== undefined && configured.length > 0
|
||||
? configured
|
||||
: DEFAULT_FORGE_BASE)
|
||||
}
|
||||
|
||||
public async installEngine (
|
||||
home: string,
|
||||
store: RegistryStore,
|
||||
progress: EngineProgressListener = {}
|
||||
): Promise<InstalledStore> {
|
||||
fs.mkdirSync(home, { recursive: true })
|
||||
|
||||
for (const [fileName, url] of Object.entries(this.engineSources())) {
|
||||
progress.onLog?.(`downloading ${fileName}`)
|
||||
const body = await this.httpClient.readText(url)
|
||||
if (!body.startsWith(PYTHON_SHEBANG)) {
|
||||
throw new Error(`${fileName} does not look like the store engine — refusing to install it`)
|
||||
}
|
||||
fs.writeFileSync(path.join(home, fileName), body, { mode: SCRIPT_MODE })
|
||||
}
|
||||
|
||||
const config = await this.readStoreConfig(store, progress)
|
||||
const configPath = path.join(home, CONFIG_FILE_NAME)
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||
|
||||
progress.onLog?.(`${store.name} is set up in ${home}`)
|
||||
const configStore = asRecord(config['store'])
|
||||
return {
|
||||
id: configStore === null ? deriveStoreId(store) : readString(configStore, 'id', deriveStoreId(store)),
|
||||
name: store.name,
|
||||
home,
|
||||
scriptPath: path.join(home, DESKTOP_STORE_ENGINE.scriptFileName),
|
||||
configPath,
|
||||
engine: DESKTOP_STORE_ENGINE.id
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the engine itself comes from: this client's own machinery, not the registry's. */
|
||||
private engineSources (): Readonly<Record<string, string>> {
|
||||
return {
|
||||
[DESKTOP_STORE_ENGINE.scriptFileName]:
|
||||
`${this.forgeBase}/stores/warp-engine-desktop-store/raw/branch/${DEFAULT_BRANCH}/${DESKTOP_STORE_ENGINE.scriptFileName}`,
|
||||
'warpstore.py': `${this.forgeBase}/engines/warpstore/raw/branch/${DEFAULT_BRANCH}/warpstore.py`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The store's configuration.
|
||||
*
|
||||
* Its repository is the authority on how the store behaves — which platforms,
|
||||
* which statuses, where things land. A repository without a config.json still
|
||||
* works: the engine merges whatever it is given onto its own defaults, so a
|
||||
* three-field config is a complete one. The registry wins on identity and on
|
||||
* which catalog to read.
|
||||
*/
|
||||
private async readStoreConfig (
|
||||
store: RegistryStore,
|
||||
progress: EngineProgressListener
|
||||
): Promise<Record<string, unknown>> {
|
||||
const storeId = deriveStoreId(store)
|
||||
let config: Record<string, unknown>
|
||||
try {
|
||||
progress.onLog?.(`reading the store config from ${store.storeRepositoryUrl}`)
|
||||
const body = await this.httpClient.readText(this.configUrl(store.storeRepositoryUrl))
|
||||
config = { ...(asRecord(JSON.parse(body)) ?? {}) }
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof HttpStatusError) || error.statusCode !== 404) throw error
|
||||
progress.onLog?.('no config.json in the store repository — using the engine defaults')
|
||||
config = {
|
||||
paths: { subfolder: storeId },
|
||||
catalog: { statuses: ['released', 'archived', 'demo'] }
|
||||
}
|
||||
}
|
||||
|
||||
const existing = asRecord(config['store']) ?? {}
|
||||
config['store'] = {
|
||||
...existing,
|
||||
id: readString(existing, 'id', storeId),
|
||||
name: store.name,
|
||||
base_url: store.catalogUrl
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
private configUrl (repositoryUrl: string, branch: string = DEFAULT_BRANCH): string {
|
||||
return `${repositoryUrl.replace(/\/+$/, '')}/raw/branch/${branch}/${CONFIG_FILE_NAME}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { RegistryUnavailableError } from '../../domain/errors/RegistryUnavailableError'
|
||||
import type { RegistryStore } from '../../domain/models/RegistryStore'
|
||||
import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository'
|
||||
import { asRecord, readString, type JsonRecord } from '../json/JsonRecord'
|
||||
import type { HttpTextClient } from '../http/HttpTextClient'
|
||||
|
||||
const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores'
|
||||
|
||||
/**
|
||||
* The registry: `GET /api/stores` on the site.
|
||||
*
|
||||
* The one address this client knows, and even that is overridable — `STORES_API`
|
||||
* points it at another site or at a local endpoint. Records missing any of the
|
||||
* three fields are dropped rather than half-used.
|
||||
*/
|
||||
export class HttpStoreRegistryRepository implements StoreRegistryRepository {
|
||||
public readonly sourceUrl: string
|
||||
|
||||
public constructor (private readonly httpClient: HttpTextClient, sourceUrl?: string) {
|
||||
const configured = process.env['STORES_API']
|
||||
this.sourceUrl = sourceUrl ?? (configured !== undefined && configured.length > 0
|
||||
? configured
|
||||
: DEFAULT_REGISTRY_URL)
|
||||
}
|
||||
|
||||
public async listStores (): Promise<readonly RegistryStore[]> {
|
||||
const body = await this.httpClient.readText(this.sourceUrl)
|
||||
const parsed: unknown = JSON.parse(body)
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new RegistryUnavailableError(this.sourceUrl, 'the answer was not a list of stores')
|
||||
}
|
||||
return parsed
|
||||
.map((row: unknown): JsonRecord | null => asRecord(row))
|
||||
.filter((row: JsonRecord | null): row is JsonRecord => row !== null)
|
||||
.map((row: JsonRecord): RegistryStore => ({
|
||||
name: readString(row, 'name').trim(),
|
||||
// Both spellings, because a registry is someone else's API: ours answers
|
||||
// camelCase, and a hand-rolled one may not.
|
||||
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim(),
|
||||
storeRepositoryUrl: (
|
||||
readString(row, 'storeRepositoryUrl') || readString(row, 'store_repository_url')
|
||||
).trim()
|
||||
}))
|
||||
.filter((store: RegistryStore): boolean =>
|
||||
store.name.length > 0 && store.catalogUrl.length > 0 && store.storeRepositoryUrl.length > 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { Preferences } from '../../domain/models/Preferences'
|
||||
import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment'
|
||||
import type { PreferencesRepository } from '../../domain/ports/PreferencesRepository'
|
||||
import { LOCALES, type Locale } from '../../shared/i18n/MessageBundle'
|
||||
import { asRecord, readBoolean, readOptionalString } from '../json/JsonRecord'
|
||||
|
||||
const PREFERENCES_FILE_NAME = 'prefs.json'
|
||||
|
||||
/**
|
||||
* Preferences in one small JSON file next to the application's own data.
|
||||
*
|
||||
* A lost preference is not worth an error dialog, so both directions swallow their
|
||||
* failures — the defaults are always usable.
|
||||
*/
|
||||
export class JsonFilePreferencesRepository implements PreferencesRepository {
|
||||
public constructor (private readonly environment: ApplicationEnvironment) {}
|
||||
|
||||
public read (): Preferences {
|
||||
try {
|
||||
const parsed = asRecord(JSON.parse(fs.readFileSync(this.filePath(), 'utf8')))
|
||||
if (parsed === null) return {}
|
||||
const locale = readOptionalString(parsed, 'locale')
|
||||
const storeHome = readOptionalString(parsed, 'storeHome')
|
||||
const preferences: {
|
||||
locale?: Locale
|
||||
navigationOpen?: boolean
|
||||
storeHome?: string
|
||||
} = {}
|
||||
const known = LOCALES.find((candidate: Locale): boolean => candidate === locale)
|
||||
if (known !== undefined) preferences.locale = known
|
||||
if (storeHome !== null) preferences.storeHome = storeHome
|
||||
if (typeof parsed['navigationOpen'] === 'boolean') {
|
||||
preferences.navigationOpen = readBoolean(parsed, 'navigationOpen', true)
|
||||
}
|
||||
return preferences
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
public write (preferences: Preferences): void {
|
||||
try {
|
||||
const target = this.filePath()
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||
fs.writeFileSync(target, `${JSON.stringify(preferences, null, 2)}\n`)
|
||||
} catch {
|
||||
// Not worth interrupting the session over.
|
||||
}
|
||||
}
|
||||
|
||||
private filePath (): string {
|
||||
return this.environment.resolveUserDataPath(PREFERENCES_FILE_NAME)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { CatalogListing } from '../../domain/models/CatalogListing'
|
||||
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
|
||||
import {
|
||||
MINIMUM_ENGINE_VERSION, isAtLeast, parseVersionNumbers, type EngineVersion
|
||||
} from '../../domain/models/EngineVersion'
|
||||
import type { Game } from '../../domain/models/Game'
|
||||
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
||||
import type { StorePaths } from '../../domain/models/StorePaths'
|
||||
import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
|
||||
import { readRecord, readRecordArray, readStringArray, type JsonRecord } from '../json/JsonRecord'
|
||||
import { EngineGameMapper } from '../mappers/EngineGameMapper'
|
||||
import { EngineStorePathsMapper } from '../mappers/EngineStorePathsMapper'
|
||||
import type { PythonEngineProcessRunner } from '../process/PythonEngineProcessRunner'
|
||||
|
||||
/**
|
||||
* The store engine, driven as a child process.
|
||||
*
|
||||
* The only adapter that knows the CLI exists. Everything above it sees the port.
|
||||
*/
|
||||
export class PythonStoreCatalogGateway implements StoreCatalogGateway {
|
||||
public constructor (
|
||||
private readonly runner: PythonEngineProcessRunner,
|
||||
private readonly gameMapper: EngineGameMapper = new EngineGameMapper(),
|
||||
private readonly pathsMapper: EngineStorePathsMapper = new EngineStorePathsMapper()
|
||||
) {}
|
||||
|
||||
public async listGames (
|
||||
store: InstalledStore,
|
||||
progress?: EngineProgressListener
|
||||
): Promise<CatalogListing> {
|
||||
const records = await this.runner.runCommand(store, ['list'], progress)
|
||||
const answer = this.lastRecord(records)
|
||||
if (answer === null) return { games: [], skipped: [], paths: null }
|
||||
const games: readonly Game[] = readRecordArray(answer, 'games')
|
||||
.map((record: JsonRecord): Game => this.gameMapper.toModel(record))
|
||||
const paths = readRecord(answer, 'paths')
|
||||
return {
|
||||
games,
|
||||
skipped: readStringArray(answer, 'skipped'),
|
||||
paths: paths === null ? null : this.pathsMapper.toModel(paths)
|
||||
}
|
||||
}
|
||||
|
||||
public async readPaths (
|
||||
store: InstalledStore,
|
||||
progress?: EngineProgressListener
|
||||
): Promise<StorePaths> {
|
||||
const records = await this.runner.runCommand(store, ['paths'], progress)
|
||||
const answer = this.lastRecord(records)
|
||||
return this.pathsMapper.toModel(answer ?? {})
|
||||
}
|
||||
|
||||
public async syncGames (
|
||||
store: InstalledStore,
|
||||
names: readonly string[],
|
||||
progress?: EngineProgressListener
|
||||
): Promise<void> {
|
||||
await this.runner.runCommand(store, ['sync', ...names], progress)
|
||||
}
|
||||
|
||||
public async removeGame (
|
||||
store: InstalledStore,
|
||||
name: string,
|
||||
progress?: EngineProgressListener
|
||||
): Promise<void> {
|
||||
await this.runner.runCommand(store, ['remove', name], progress)
|
||||
}
|
||||
|
||||
public readEngineVersion (store: InstalledStore): EngineVersion | null {
|
||||
const text = this.runner.readVersionText(store)
|
||||
if (text === null) return null
|
||||
const numbers = parseVersionNumbers(text)
|
||||
return { text, numbers, supported: isAtLeast(numbers, MINIMUM_ENGINE_VERSION) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The result object is the last line: the stream events come first, and both
|
||||
* arrive on the same pipe.
|
||||
*/
|
||||
private lastRecord (records: readonly JsonRecord[]): JsonRecord | null {
|
||||
for (let index = records.length - 1; index >= 0; index -= 1) {
|
||||
const record = records[index]
|
||||
if (record !== undefined && typeof record['event'] !== 'string') return record
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||
import {
|
||||
BRIDGE_GLOBAL_NAME, type BridgeApi, type StreamListener
|
||||
} from '../shared/contracts/BridgeApi'
|
||||
import { IPC_CHANNELS } from '../shared/contracts/IpcChannels'
|
||||
import type { AppStateDto } from '../shared/contracts/dto/AppStateDto'
|
||||
import type { CatalogListingDto } from '../shared/contracts/dto/CatalogListingDto'
|
||||
import type { InstalledStoreDto } from '../shared/contracts/dto/InstalledStoreDto'
|
||||
import type { LocaleSelectionDto } from '../shared/contracts/dto/LocaleSelectionDto'
|
||||
import type { RegistryResultDto } from '../shared/contracts/dto/RegistryResultDto'
|
||||
import type { RegistryStoreDto } from '../shared/contracts/dto/RegistryStoreDto'
|
||||
import type { StorePathsDto } from '../shared/contracts/dto/StorePathsDto'
|
||||
import type { StoreSelectionDto } from '../shared/contracts/dto/StoreSelectionDto'
|
||||
import type { SyncEventDto } from '../shared/contracts/dto/SyncEventDto'
|
||||
|
||||
/**
|
||||
* The bridge, and nothing else.
|
||||
*
|
||||
* This file is the whole surface the window gets: no Node, no filesystem, no child
|
||||
* processes. It is bundled into a single script on purpose — a sandboxed preload
|
||||
* cannot require its own modules — and it implements `BridgeApi`, so the renderer
|
||||
* and the main process are compiled against the same contract.
|
||||
*/
|
||||
const bridge: BridgeApi = {
|
||||
readState: async (): Promise<AppStateDto> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.appReadState) as Promise<AppStateDto>,
|
||||
updateLocale: async (locale: string): Promise<LocaleSelectionDto> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.appUpdateLocale, locale) as Promise<LocaleSelectionDto>,
|
||||
updateNavOpen: async (open: boolean): Promise<boolean> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.appUpdateNavOpen, open) as Promise<boolean>,
|
||||
|
||||
listGames: async (): Promise<CatalogListingDto> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.catalogListGames) as Promise<CatalogListingDto>,
|
||||
readPaths: async (): Promise<StorePathsDto> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.catalogReadPaths) as Promise<StorePathsDto>,
|
||||
syncGames: async (names: readonly string[]): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.catalogSyncGames, names) as Promise<void>,
|
||||
removeGame: async (name: string): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.catalogRemoveGame, name) as Promise<void>,
|
||||
launchGame: async (name: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.catalogLaunchGame, name) as Promise<boolean>,
|
||||
|
||||
listRegistryStores: async (): Promise<RegistryResultDto> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.storeListRegistry) as Promise<RegistryResultDto>,
|
||||
installStore: async (store: RegistryStoreDto): Promise<InstalledStoreDto> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.storeInstallStore, store) as Promise<InstalledStoreDto>,
|
||||
selectStore: async (home: string): Promise<StoreSelectionDto> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.storeSelectStore, home) as Promise<StoreSelectionDto>,
|
||||
|
||||
openFolder: async (directory: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.appOpenFolder, directory) as Promise<boolean>,
|
||||
openUrl: async (url: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.appOpenUrl, url) as Promise<boolean>,
|
||||
|
||||
onLog: (listener: StreamListener<string>): void => {
|
||||
ipcRenderer.on(IPC_CHANNELS.streamLog, (_event: IpcRendererEvent, line: string): void => {
|
||||
listener(line)
|
||||
})
|
||||
},
|
||||
onSyncEvent: (listener: StreamListener<SyncEventDto>): void => {
|
||||
ipcRenderer.on(IPC_CHANNELS.streamSyncEvent, (_event: IpcRendererEvent, payload: SyncEventDto): void => {
|
||||
listener(payload)
|
||||
})
|
||||
},
|
||||
onBusyChanged: (listener: StreamListener<boolean>): void => {
|
||||
ipcRenderer.on(IPC_CHANNELS.streamBusyChanged, (_event: IpcRendererEvent, busy: boolean): void => {
|
||||
listener(busy)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld(BRIDGE_GLOBAL_NAME, bridge)
|
||||
@@ -0,0 +1,21 @@
|
||||
import { BRIDGE_GLOBAL_NAME, type BridgeApi } from '../shared/contracts/BridgeApi'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
readonly storeApi?: BridgeApi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The bridge the preload published.
|
||||
*
|
||||
* Absent means the preload did not run, which is a packaging fault rather than a
|
||||
* runtime condition — so it fails here, once, with a sentence that says what happened.
|
||||
*/
|
||||
export function requireBridge (): BridgeApi {
|
||||
const bridge = window.storeApi
|
||||
if (bridge === undefined) {
|
||||
throw new Error(`window.${BRIDGE_GLOBAL_NAME} is missing — the preload script did not run`)
|
||||
}
|
||||
return bridge
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { BridgeApi } from '../shared/contracts/BridgeApi'
|
||||
import { requireBridge } from './BridgeAccess'
|
||||
import { CatalogController } from './controllers/CatalogController'
|
||||
import { EngineStreamController } from './controllers/EngineStreamController'
|
||||
import { PreferencesController } from './controllers/PreferencesController'
|
||||
import { StoreController } from './controllers/StoreController'
|
||||
import { AppStore, type AppState } from './state/AppStore'
|
||||
import { buildCategorySections, resolveFilter, type CategoryFilter } from './state/CategoryFilter'
|
||||
import { CatalogGridView } from './views/CatalogGridView'
|
||||
import { GameCardView } from './views/GameCardView'
|
||||
import { GateView } from './views/GateView'
|
||||
import { LogDrawerView } from './views/LogDrawerView'
|
||||
import { SideMenuView } from './views/SideMenuView'
|
||||
import { TopBarView } from './views/TopBarView'
|
||||
|
||||
/**
|
||||
* The window, assembled.
|
||||
*
|
||||
* The flow is one direction only: a control calls a controller, the controller calls
|
||||
* the bridge and writes to the store, and the store re-renders every view. No view
|
||||
* reads another view, and nothing but the store decides what is on screen.
|
||||
*/
|
||||
export class RendererApplication {
|
||||
private readonly store = new AppStore()
|
||||
private readonly bridge: BridgeApi
|
||||
private readonly log: LogDrawerView
|
||||
private readonly gate: GateView
|
||||
private readonly grid: CatalogGridView
|
||||
private readonly topBar: TopBarView
|
||||
private readonly sideMenu: SideMenuView
|
||||
private readonly catalog: CatalogController
|
||||
private readonly stores: StoreController
|
||||
private readonly preferences: PreferencesController
|
||||
private readonly streams: EngineStreamController
|
||||
|
||||
public constructor (bridge: BridgeApi = requireBridge()) {
|
||||
this.bridge = bridge
|
||||
|
||||
this.log = new LogDrawerView({
|
||||
onOpenFolder: (directory: string): void => { void this.bridge.openFolder(directory) }
|
||||
})
|
||||
this.gate = new GateView((url: string): void => { void this.bridge.openUrl(url) })
|
||||
this.catalog = new CatalogController(this.bridge, this.store, this.log)
|
||||
this.stores = new StoreController(this.bridge, this.store, this.gate, this.log, this.catalog)
|
||||
this.preferences = new PreferencesController(this.bridge, this.store)
|
||||
this.streams = new EngineStreamController(this.bridge, this.store, this.log)
|
||||
|
||||
this.grid = new CatalogGridView(new GameCardView({
|
||||
onInstall: (name: string): void => { void this.catalog.syncGames([name]) },
|
||||
onLaunch: (name: string): void => { void this.catalog.launchGame(name) },
|
||||
onRemove: (name: string): void => { void this.catalog.removeGame(name) }
|
||||
}))
|
||||
this.topBar = new TopBarView({
|
||||
onToggleNavigation: (): void => { void this.preferences.toggleNavigation() }
|
||||
})
|
||||
this.sideMenu = new SideMenuView({
|
||||
onSelectStore: (home: string): void => { void this.stores.selectStore(home) },
|
||||
onAddStore: (): void => { void this.stores.offerStores() },
|
||||
onSyncAll: (): void => { void this.catalog.syncGames([]) },
|
||||
onRefresh: (): void => { void this.catalog.refresh() },
|
||||
onSelectCategory: (filter: CategoryFilter): void => { this.store.applyFilter(filter) },
|
||||
onSelectLocale: (locale: string): void => { void this.preferences.selectLocale(locale) }
|
||||
})
|
||||
|
||||
this.store.subscribe((state: AppState): void => { this.render(state) })
|
||||
this.streams.subscribe()
|
||||
}
|
||||
|
||||
/** Decides what the window is showing, then hands over to the views. */
|
||||
public async start (): Promise<void> {
|
||||
this.store.applyAppState(await this.bridge.readState())
|
||||
const state = this.store.readState()
|
||||
|
||||
if (state.pythonVersion === null) {
|
||||
this.stores.showMissingPythonGate()
|
||||
return
|
||||
}
|
||||
if (state.currentStore === null) {
|
||||
await this.stores.offerStores()
|
||||
return
|
||||
}
|
||||
if (state.engine !== null && !state.engine.supported) {
|
||||
this.stores.showOutdatedEngineGate()
|
||||
return
|
||||
}
|
||||
this.gate.hide()
|
||||
await this.catalog.refresh()
|
||||
}
|
||||
|
||||
private render (state: AppState): void {
|
||||
// The filter is corrected before anything is drawn from it, so the menu and the
|
||||
// grid can never disagree about which category is active.
|
||||
const corrected = resolveFilter(state.filter, buildCategorySections(state.games, state.messages))
|
||||
if (corrected !== state.filter) {
|
||||
this.store.applyFilter(corrected)
|
||||
return
|
||||
}
|
||||
|
||||
document.body.classList.toggle('nav-closed', !state.navigationOpen)
|
||||
this.topBar.render(state)
|
||||
this.sideMenu.render(state)
|
||||
this.log.render(state)
|
||||
if (this.gate.visible) this.grid.hide()
|
||||
else this.grid.render(state)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { BridgeApi } from '../../shared/contracts/BridgeApi'
|
||||
import type { CatalogListingDto } from '../../shared/contracts/dto/CatalogListingDto'
|
||||
import type { AppStore } from '../state/AppStore'
|
||||
import type { LogDrawerView } from '../views/LogDrawerView'
|
||||
|
||||
/**
|
||||
* The catalog operations, as the window performs them.
|
||||
*
|
||||
* Every one of them ends in a refresh: the engine is the authority on what is
|
||||
* installed, so the window asks again rather than guessing what changed.
|
||||
*/
|
||||
export class CatalogController {
|
||||
public constructor (
|
||||
private readonly bridge: BridgeApi,
|
||||
private readonly store: AppStore,
|
||||
private readonly log: LogDrawerView
|
||||
) {}
|
||||
|
||||
public async refresh (): Promise<void> {
|
||||
try {
|
||||
const listing: CatalogListingDto = await this.bridge.listGames()
|
||||
this.store.applyCatalog(listing.games, listing.paths)
|
||||
for (const reason of listing.skipped) this.log.appendLine(`skipped ${reason}`)
|
||||
} catch (error: unknown) {
|
||||
this.reportFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
public async syncGames (names: readonly string[]): Promise<void> {
|
||||
try {
|
||||
await this.bridge.syncGames(names)
|
||||
} catch (error: unknown) {
|
||||
this.reportFailure(error)
|
||||
}
|
||||
await this.refresh()
|
||||
}
|
||||
|
||||
public async removeGame (name: string): Promise<void> {
|
||||
try {
|
||||
await this.bridge.removeGame(name)
|
||||
} catch (error: unknown) {
|
||||
this.reportFailure(error)
|
||||
}
|
||||
await this.refresh()
|
||||
}
|
||||
|
||||
public async launchGame (name: string): Promise<void> {
|
||||
try {
|
||||
const launched = await this.bridge.launchGame(name)
|
||||
if (!launched) this.log.appendLine(`${name}: ${this.store.readState().messages.failed}`)
|
||||
} catch (error: unknown) {
|
||||
this.reportFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private reportFailure (error: unknown): void {
|
||||
this.log.appendLine(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { BridgeApi } from '../../shared/contracts/BridgeApi'
|
||||
import type { SyncEventDto } from '../../shared/contracts/dto/SyncEventDto'
|
||||
import type { AppStore, SyncProgress } from '../state/AppStore'
|
||||
import type { LogDrawerView } from '../views/LogDrawerView'
|
||||
|
||||
/**
|
||||
* The engine's own voice: its log, its progress events, and whether it is busy.
|
||||
*
|
||||
* Subscribed once at boot. The counter is derived from the events rather than
|
||||
* guessed: `plan` says how many titles there are, `begin` and `installed` move it.
|
||||
*/
|
||||
export class EngineStreamController {
|
||||
private progress: SyncProgress | null = null
|
||||
|
||||
public constructor (
|
||||
private readonly bridge: BridgeApi,
|
||||
private readonly store: AppStore,
|
||||
private readonly log: LogDrawerView
|
||||
) {}
|
||||
|
||||
public subscribe (): void {
|
||||
this.bridge.onLog((line: string): void => { this.log.appendLine(line) })
|
||||
this.bridge.onBusyChanged((busy: boolean): void => {
|
||||
if (!busy) this.progress = null
|
||||
this.store.applyBusy(busy)
|
||||
})
|
||||
this.bridge.onSyncEvent((event: SyncEventDto): void => { this.handleEvent(event) })
|
||||
}
|
||||
|
||||
private handleEvent (event: SyncEventDto): void {
|
||||
const messages = this.store.readState().messages
|
||||
switch (event.event) {
|
||||
case 'plan':
|
||||
this.progress = { total: event.count, done: 0, label: `0 ${messages.of} ${String(event.count)}` }
|
||||
this.store.applyProgress(this.progress)
|
||||
return
|
||||
case 'begin': {
|
||||
const current = this.progress
|
||||
if (current === null) return
|
||||
this.progress = {
|
||||
...current,
|
||||
label: `${String(current.done + 1)} ${messages.of} ${String(current.total)} · ${event.title}`
|
||||
}
|
||||
this.store.applyProgress(this.progress)
|
||||
return
|
||||
}
|
||||
case 'installed': {
|
||||
const current = this.progress
|
||||
if (current !== null) {
|
||||
this.progress = { ...current, done: current.done + 1 }
|
||||
this.store.applyProgress(this.progress)
|
||||
}
|
||||
this.log.appendLine(`${event.title} — ${event.changed ? messages.installed : messages.upToDate}`)
|
||||
return
|
||||
}
|
||||
case 'failed':
|
||||
this.log.appendLine(`${event.name}: ${messages.failed} — ${event.error}`)
|
||||
return
|
||||
case 'removed':
|
||||
this.log.appendLine(`${event.name} — ${messages.removed}`)
|
||||
return
|
||||
case 'pruned':
|
||||
case 'finished':
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { BridgeApi } from '../../shared/contracts/BridgeApi'
|
||||
import type { AppStore } from '../state/AppStore'
|
||||
|
||||
/** The two things the window remembers: its language and whether the menu is open. */
|
||||
export class PreferencesController {
|
||||
public constructor (
|
||||
private readonly bridge: BridgeApi,
|
||||
private readonly store: AppStore
|
||||
) {}
|
||||
|
||||
public async selectLocale (candidate: string): Promise<void> {
|
||||
const selection = await this.bridge.updateLocale(candidate)
|
||||
this.store.applyMessages(selection.locale, selection.messages)
|
||||
}
|
||||
|
||||
public async toggleNavigation (): Promise<void> {
|
||||
const open = !this.store.readState().navigationOpen
|
||||
this.store.applyNavigationOpen(open)
|
||||
await this.bridge.updateNavOpen(open)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { BridgeApi } from '../../shared/contracts/BridgeApi'
|
||||
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
|
||||
import type { AppStore } from '../state/AppStore'
|
||||
import type { GateView } from '../views/GateView'
|
||||
import type { LogDrawerView } from '../views/LogDrawerView'
|
||||
import type { CatalogController } from './CatalogController'
|
||||
|
||||
/**
|
||||
* Which store the window drives, and how one gets onto the machine.
|
||||
*
|
||||
* The gate screens live here because they are all the same decision seen from
|
||||
* different angles: there is no store to show a catalog for, and this is what can be
|
||||
* done about it.
|
||||
*/
|
||||
export class StoreController {
|
||||
public constructor (
|
||||
private readonly bridge: BridgeApi,
|
||||
private readonly store: AppStore,
|
||||
private readonly gate: GateView,
|
||||
private readonly log: LogDrawerView,
|
||||
private readonly catalog: CatalogController
|
||||
) {}
|
||||
|
||||
/** Open another store that is already on this machine. */
|
||||
public async selectStore (home: string): Promise<void> {
|
||||
const messages = this.store.readState().messages
|
||||
try {
|
||||
const selection = await this.bridge.selectStore(home)
|
||||
this.store.applySelectedStore(selection.store, selection.engine)
|
||||
if (selection.engine !== null && !selection.engine.supported) {
|
||||
this.showOutdatedEngineGate()
|
||||
return
|
||||
}
|
||||
this.gate.hide()
|
||||
await this.catalog.refresh()
|
||||
} catch (error: unknown) {
|
||||
this.log.appendLine(`${messages.switchFailed}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which stores exist is the site's answer, not this client's: the registry is asked
|
||||
* for it, and its records carry the catalog and the config repository.
|
||||
*/
|
||||
public async offerStores (): Promise<void> {
|
||||
const state = this.store.readState()
|
||||
const messages = state.messages
|
||||
const result = await this.bridge.listRegistryStores()
|
||||
|
||||
if (result.error !== null) {
|
||||
this.gate.show({
|
||||
title: messages.registryFailed,
|
||||
body: `${result.sourceUrl}\n\n${result.error}`,
|
||||
action: { label: messages.registryRetry, perform: (): void => { void this.offerStores() } }
|
||||
}, messages)
|
||||
return
|
||||
}
|
||||
|
||||
if (result.stores.length === 0) {
|
||||
this.gate.show({
|
||||
title: messages.setupTitle,
|
||||
body: `${messages.registryEmpty}\n\n${result.sourceUrl}`
|
||||
}, messages)
|
||||
return
|
||||
}
|
||||
|
||||
this.gate.show({
|
||||
title: messages.setupTitle,
|
||||
body: `${messages.setupBody}\n\n${state.defaultStoreRoot}`,
|
||||
action: {
|
||||
label: messages.setupAction,
|
||||
perform: (chosen: RegistryStoreDto | null): void => {
|
||||
if (chosen !== null) void this.installStore(chosen)
|
||||
}
|
||||
},
|
||||
choices: result.stores
|
||||
}, messages)
|
||||
}
|
||||
|
||||
public showOutdatedEngineGate (): void {
|
||||
const state = this.store.readState()
|
||||
const engineText = state.engine === null ? '' : state.engine.text
|
||||
this.gate.show({
|
||||
title: state.messages.oldEngineTitle,
|
||||
body: `${state.messages.oldEngineBody}\n\n${engineText} → ${state.minimumEngineVersion}`,
|
||||
action: { label: state.messages.oldEngineAction, perform: (): void => { void this.offerStores() } }
|
||||
}, state.messages)
|
||||
}
|
||||
|
||||
public showMissingPythonGate (): void {
|
||||
const messages = this.store.readState().messages
|
||||
this.gate.show({
|
||||
title: messages.noPythonTitle,
|
||||
body: messages.noPythonBody,
|
||||
link: { label: messages.pythonLink, url: 'https://www.python.org/downloads/' }
|
||||
}, messages)
|
||||
}
|
||||
|
||||
private async installStore (chosen: RegistryStoreDto): Promise<void> {
|
||||
const messages = this.store.readState().messages
|
||||
this.store.applyProgress({ total: 0, done: 0, label: messages.setupWorking })
|
||||
try {
|
||||
await this.bridge.installStore(chosen)
|
||||
this.store.applyAppState(await this.bridge.readState())
|
||||
this.gate.hide()
|
||||
await this.catalog.refresh()
|
||||
await this.catalog.syncGames([])
|
||||
} catch (error: unknown) {
|
||||
this.log.appendLine(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
this.store.applyProgress(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* The DOM chores this window has, in one place.
|
||||
*
|
||||
* `requireElement` throws rather than returning null, and checks what it found
|
||||
* against the element type asked for: every id it is called with is in index.html, so
|
||||
* a missing or retyped one is a mistake in this repository and should say so loudly
|
||||
* instead of silently rendering half a window.
|
||||
*/
|
||||
export function requireElement<TElement extends HTMLElement> (
|
||||
id: string,
|
||||
type: abstract new () => TElement
|
||||
): TElement {
|
||||
const found = document.getElementById(id)
|
||||
if (found === null) throw new Error(`the element #${id} is missing from index.html`)
|
||||
if (!(found instanceof type)) throw new Error(`#${id} is not a ${type.name}`)
|
||||
return found
|
||||
}
|
||||
|
||||
export function createElement<TTag extends keyof HTMLElementTagNameMap> (
|
||||
tag: TTag,
|
||||
className?: string,
|
||||
text?: string
|
||||
): HTMLElementTagNameMap[TTag] {
|
||||
const node = document.createElement(tag)
|
||||
if (className !== undefined) node.className = className
|
||||
if (text !== undefined) node.textContent = text
|
||||
return node
|
||||
}
|
||||
|
||||
export function setText (node: HTMLElement, value: string | number | null): void {
|
||||
node.textContent = value === null ? '' : String(value)
|
||||
}
|
||||
|
||||
export function setHidden (node: HTMLElement, hidden: boolean): void {
|
||||
node.hidden = hidden
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!-- Nothing is loaded from the network except box art, and no inline code
|
||||
runs: the app ships its own script and stylesheet. -->
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' https: data:; font-src 'self'; connect-src 'none'">
|
||||
<title>WarpEngine Store</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<button id="nav-toggle" class="nav-toggle" aria-controls="side" aria-expanded="true">
|
||||
<span aria-hidden="true">☰</span>
|
||||
</button>
|
||||
<div class="bar-title">
|
||||
<span class="logo" aria-hidden="true">▚</span>
|
||||
<span id="app-name">WarpEngine Store</span>
|
||||
<span class="store-id" id="store-id"></span>
|
||||
</div>
|
||||
<div class="bar-actions">
|
||||
<span class="progress" id="progress" hidden></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="shell">
|
||||
<!-- The side menu: which store, what to do with it, and what to look at.
|
||||
Collapsed with the button in the bar; the choice is remembered. -->
|
||||
<aside class="side" id="side">
|
||||
<section class="side-block">
|
||||
<h2 class="side-head" id="head-stores"></h2>
|
||||
<div class="store-list" id="store-list"></div>
|
||||
<button id="add-store" class="btn btn-ghost btn-wide"></button>
|
||||
</section>
|
||||
|
||||
<section class="side-block">
|
||||
<h2 class="side-head" id="head-actions"></h2>
|
||||
<button id="sync-all" class="btn btn-primary btn-wide" disabled></button>
|
||||
<button id="refresh" class="btn btn-wide" disabled></button>
|
||||
</section>
|
||||
|
||||
<section class="side-block side-cats">
|
||||
<h2 class="side-head" id="head-cats"></h2>
|
||||
<nav class="cats" id="cats"></nav>
|
||||
</section>
|
||||
|
||||
<section class="side-block side-foot">
|
||||
<label class="side-lang">
|
||||
<span id="head-lang"></span>
|
||||
<select id="locale" class="select" aria-label="Language"></select>
|
||||
</label>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div class="content">
|
||||
<!-- Shown instead of the grid when there is nothing to drive yet. -->
|
||||
<section id="gate" class="gate" hidden>
|
||||
<h1 id="gate-title"></h1>
|
||||
<p id="gate-body"></p>
|
||||
<div class="gate-actions">
|
||||
<label id="gate-choice" class="gate-choice" hidden>
|
||||
<span id="gate-choice-label"></span>
|
||||
<select id="gate-select" class="select"></select>
|
||||
</label>
|
||||
<button id="gate-action" class="btn btn-primary" hidden></button>
|
||||
<a id="gate-link" class="link" href="#" hidden></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main id="grid" class="grid" hidden></main>
|
||||
|
||||
<section id="empty" class="empty" hidden></section>
|
||||
|
||||
<footer class="log">
|
||||
<button id="log-toggle" class="log-toggle" aria-expanded="false"></button>
|
||||
<div class="log-lines" id="log-lines" hidden></div>
|
||||
<div class="log-paths" id="log-paths"></div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { RendererApplication } from './RendererApplication'
|
||||
|
||||
// The renderer's entry point. Errors here would otherwise be invisible: the main
|
||||
// process log stays empty when the page throws, so the window says it out loud.
|
||||
void new RendererApplication().start().catch((error: unknown): void => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto'
|
||||
import type { EngineVersionDto } from '../../shared/contracts/dto/EngineVersionDto'
|
||||
import type { GameDto } from '../../shared/contracts/dto/GameDto'
|
||||
import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto'
|
||||
import type { StorePathsDto } from '../../shared/contracts/dto/StorePathsDto'
|
||||
import { ENGLISH_MESSAGES } from '../../shared/i18n/EnglishMessages'
|
||||
import type { Locale, MessageBundle } from '../../shared/i18n/MessageBundle'
|
||||
import { ALL_CATEGORIES, type CategoryFilter } from './CategoryFilter'
|
||||
|
||||
/** How far a running sync has got, for the counter in the bar. */
|
||||
export interface SyncProgress {
|
||||
readonly total: number
|
||||
readonly done: number
|
||||
readonly label: string
|
||||
}
|
||||
|
||||
/** Everything the window draws itself from. Read-only to everyone but the store. */
|
||||
export interface AppState {
|
||||
readonly locale: Locale
|
||||
readonly locales: readonly Locale[]
|
||||
readonly messages: MessageBundle
|
||||
readonly navigationOpen: boolean
|
||||
readonly pythonVersion: string | null
|
||||
readonly stores: readonly InstalledStoreDto[]
|
||||
readonly currentStore: InstalledStoreDto | null
|
||||
readonly engine: EngineVersionDto | null
|
||||
readonly minimumEngineVersion: string
|
||||
readonly registryUrl: string
|
||||
readonly defaultStoreRoot: string
|
||||
readonly games: readonly GameDto[]
|
||||
readonly paths: StorePathsDto | null
|
||||
readonly filter: CategoryFilter
|
||||
readonly busy: boolean
|
||||
readonly progress: SyncProgress | null
|
||||
}
|
||||
|
||||
const INITIAL_STATE: AppState = {
|
||||
locale: 'en',
|
||||
locales: ['en'],
|
||||
messages: ENGLISH_MESSAGES,
|
||||
navigationOpen: true,
|
||||
pythonVersion: null,
|
||||
stores: [],
|
||||
currentStore: null,
|
||||
engine: null,
|
||||
minimumEngineVersion: '',
|
||||
registryUrl: '',
|
||||
defaultStoreRoot: '',
|
||||
games: [],
|
||||
paths: null,
|
||||
filter: ALL_CATEGORIES,
|
||||
busy: false,
|
||||
progress: null
|
||||
}
|
||||
|
||||
export type AppStateListener = (state: AppState) => void
|
||||
|
||||
/**
|
||||
* The window's single source of truth.
|
||||
*
|
||||
* Every mutator is named after what it changes, and every one of them notifies: the
|
||||
* views re-render from the state rather than being poked individually, so there is no
|
||||
* way to update the model and forget the screen.
|
||||
*/
|
||||
export class AppStore {
|
||||
private state: AppState = INITIAL_STATE
|
||||
private readonly listeners: AppStateListener[] = []
|
||||
|
||||
public readState (): AppState {
|
||||
return this.state
|
||||
}
|
||||
|
||||
public subscribe (listener: AppStateListener): void {
|
||||
this.listeners.push(listener)
|
||||
}
|
||||
|
||||
public applyAppState (dto: AppStateDto): void {
|
||||
this.state = {
|
||||
...this.state,
|
||||
locale: dto.locale,
|
||||
locales: dto.locales,
|
||||
messages: dto.messages,
|
||||
navigationOpen: dto.navigationOpen,
|
||||
pythonVersion: dto.pythonVersion,
|
||||
stores: dto.stores,
|
||||
currentStore: dto.currentStore,
|
||||
engine: dto.engine,
|
||||
minimumEngineVersion: dto.minimumEngineVersion,
|
||||
registryUrl: dto.registryUrl,
|
||||
defaultStoreRoot: dto.defaultStoreRoot
|
||||
}
|
||||
this.notify()
|
||||
}
|
||||
|
||||
public applyMessages (locale: Locale, messages: MessageBundle): void {
|
||||
this.state = { ...this.state, locale, messages }
|
||||
this.notify()
|
||||
}
|
||||
|
||||
public applyNavigationOpen (open: boolean): void {
|
||||
this.state = { ...this.state, navigationOpen: open }
|
||||
this.notify()
|
||||
}
|
||||
|
||||
public applyCatalog (games: readonly GameDto[], paths: StorePathsDto | null): void {
|
||||
this.state = { ...this.state, games, paths: paths ?? this.state.paths }
|
||||
this.notify()
|
||||
}
|
||||
|
||||
public applySelectedStore (store: InstalledStoreDto, engine: EngineVersionDto | null): void {
|
||||
this.state = { ...this.state, currentStore: store, engine, games: [], paths: null, filter: ALL_CATEGORIES }
|
||||
this.notify()
|
||||
}
|
||||
|
||||
public applyFilter (filter: CategoryFilter): void {
|
||||
this.state = { ...this.state, filter }
|
||||
this.notify()
|
||||
}
|
||||
|
||||
public applyBusy (busy: boolean): void {
|
||||
this.state = { ...this.state, busy, progress: busy ? this.state.progress : null }
|
||||
this.notify()
|
||||
}
|
||||
|
||||
public applyProgress (progress: SyncProgress | null): void {
|
||||
this.state = { ...this.state, progress }
|
||||
this.notify()
|
||||
}
|
||||
|
||||
private notify (): void {
|
||||
for (const listener of this.listeners) listener(this.state)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { GameDto } from '../../shared/contracts/dto/GameDto'
|
||||
import type { MessageBundle } from '../../shared/i18n/MessageBundle'
|
||||
|
||||
/** Which axis a category narrows the grid along. */
|
||||
export type CategoryKind = 'group' | 'platform' | 'mode'
|
||||
|
||||
export interface CategoryFilter {
|
||||
readonly kind: CategoryKind
|
||||
readonly value: string
|
||||
}
|
||||
|
||||
export interface CategoryItem extends CategoryFilter {
|
||||
readonly label: string
|
||||
readonly count: number
|
||||
}
|
||||
|
||||
export interface CategorySection {
|
||||
readonly title: string | null
|
||||
readonly items: readonly CategoryItem[]
|
||||
}
|
||||
|
||||
export const ALL_CATEGORIES: CategoryFilter = { kind: 'group', value: 'all' }
|
||||
|
||||
export function isSameFilter (left: CategoryFilter, right: CategoryFilter): boolean {
|
||||
return left.kind === right.kind && left.value === right.value
|
||||
}
|
||||
|
||||
export function matchesFilter (game: GameDto, filter: CategoryFilter): boolean {
|
||||
switch (filter.kind) {
|
||||
case 'platform':
|
||||
return game.platform === filter.value
|
||||
case 'mode':
|
||||
return game.mode === filter.value
|
||||
case 'group':
|
||||
return matchesGroup(game, filter.value)
|
||||
}
|
||||
}
|
||||
|
||||
function matchesGroup (game: GameDto, group: string): boolean {
|
||||
switch (group) {
|
||||
case 'installed':
|
||||
return game.installed
|
||||
case 'updates':
|
||||
return game.updateAvailable
|
||||
case 'available':
|
||||
return !game.installed
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The categories, built from what the catalog actually contains.
|
||||
*
|
||||
* There is no genre in a WarpEngine catalog, so the useful axes are the state of a
|
||||
* title on this machine, the platform it was built with, and whether it runs here or
|
||||
* in a browser. Empty axes are left out rather than shown as zeroes, and an axis with
|
||||
* a single value is left out too — a filter that changes nothing is noise.
|
||||
*/
|
||||
export function buildCategorySections (
|
||||
games: readonly GameDto[],
|
||||
messages: MessageBundle
|
||||
): readonly CategorySection[] {
|
||||
const count = (predicate: (game: GameDto) => boolean): number => games.filter(predicate).length
|
||||
const sections: CategorySection[] = []
|
||||
|
||||
const groups: readonly CategoryItem[] = [
|
||||
{ kind: 'group', value: 'all', label: messages.catAll, count: games.length },
|
||||
{ kind: 'group', value: 'installed', label: messages.catInstalled, count: count((game: GameDto): boolean => game.installed) },
|
||||
{ kind: 'group', value: 'updates', label: messages.catUpdates, count: count((game: GameDto): boolean => game.updateAvailable) },
|
||||
{ kind: 'group', value: 'available', label: messages.catAvailable, count: count((game: GameDto): boolean => !game.installed) }
|
||||
]
|
||||
sections.push({
|
||||
title: null,
|
||||
items: groups.filter((item: CategoryItem): boolean => item.value === 'all' || item.count > 0)
|
||||
})
|
||||
|
||||
const platforms = [...new Set(games.map((game: GameDto): string => game.platform))]
|
||||
.filter((platform: string): boolean => platform.length > 0)
|
||||
.sort((left: string, right: string): number => left.localeCompare(right))
|
||||
if (platforms.length > 1) {
|
||||
sections.push({
|
||||
title: messages.catPlatform,
|
||||
items: platforms.map((platform: string): CategoryItem => ({
|
||||
kind: 'platform',
|
||||
value: platform,
|
||||
label: platform,
|
||||
count: count((game: GameDto): boolean => game.platform === platform)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
const modes = [...new Set(games.map((game: GameDto): string => game.mode))]
|
||||
if (modes.length > 1) {
|
||||
sections.push({
|
||||
title: messages.catMode,
|
||||
items: modes.map((mode: string): CategoryItem => ({
|
||||
kind: 'mode',
|
||||
value: mode,
|
||||
label: mode === 'web' ? messages.hosted : messages.native,
|
||||
count: count((game: GameDto): boolean => game.mode === mode)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
/**
|
||||
* A category can vanish under us — the last title of a platform is removed, or an
|
||||
* update is applied — and a filter matching nothing would look like an empty
|
||||
* catalog. Falling back to everything is the honest answer.
|
||||
*/
|
||||
export function resolveFilter (
|
||||
filter: CategoryFilter,
|
||||
sections: readonly CategorySection[]
|
||||
): CategoryFilter {
|
||||
const known = sections
|
||||
.flatMap((section: CategorySection): readonly CategoryItem[] => section.items)
|
||||
.some((item: CategoryItem): boolean => isSameFilter(item, filter))
|
||||
return known ? filter : ALL_CATEGORIES
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
/* One dark theme, no assets: the box art is the only image the app loads. */
|
||||
:root {
|
||||
--bg: #11151c;
|
||||
--panel: #182029;
|
||||
--panel-2: #1e2732;
|
||||
--line: #2a3440;
|
||||
--ink: #e8eef5;
|
||||
--ink-dim: #93a4b8;
|
||||
--accent: #37b98a;
|
||||
--accent-ink: #05130d;
|
||||
--warn: #e0a44a;
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- shell: the side menu and everything else --------------------------- */
|
||||
.shell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden; /* so the collapsed menu is clipped rather than scrolled to */
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.side {
|
||||
--side-width: 244px;
|
||||
width: var(--side-width);
|
||||
flex: none;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--line);
|
||||
/* The menu itself does not scroll: the category list does. Letting the whole
|
||||
column scroll made the bottom block — pinned there with margin-top: auto —
|
||||
sit on top of the overflowing categories. */
|
||||
overflow: hidden;
|
||||
padding: 14px 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
transition: margin-left .16s ease-out;
|
||||
}
|
||||
body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
|
||||
.side-block { display: flex; flex-direction: column; gap: 6px; flex: none; }
|
||||
.side-cats { flex: 1; min-height: 0; }
|
||||
.side-foot { padding-top: 12px; border-top: 1px solid var(--line); }
|
||||
.side-head {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-dim);
|
||||
margin: 0 0 2px 4px;
|
||||
}
|
||||
.btn-wide { width: 100%; text-align: left; }
|
||||
|
||||
.nav-toggle {
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
color: var(--ink-dim);
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.nav-toggle:hover { color: var(--ink); border-color: #3a4757; }
|
||||
|
||||
/* Store switcher: one row per store on this machine, the open one marked. */
|
||||
.store-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.store-row {
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.store-row:hover:not(:disabled) { background: var(--panel-2); }
|
||||
.store-row.is-active {
|
||||
background: var(--panel-2);
|
||||
border-color: #2f5a49;
|
||||
}
|
||||
.store-row .store-row-name { font-weight: 600; }
|
||||
.store-row .store-row-id {
|
||||
font-size: 11px;
|
||||
color: var(--ink-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.store-row:disabled { opacity: .55; cursor: default; }
|
||||
|
||||
/* Categories: what the catalog is filtered down to. */
|
||||
.cats { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; min-height: 0; }
|
||||
.cat {
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
color: var(--ink-dim);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.cat:hover { background: var(--panel-2); color: var(--ink); }
|
||||
.cat.is-active { background: var(--panel-2); color: var(--ink); font-weight: 600; }
|
||||
.cat-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cat-count { margin-left: auto; font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.cat-group {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .07em;
|
||||
text-transform: uppercase;
|
||||
color: #6b7d92;
|
||||
padding: 8px 10px 2px;
|
||||
}
|
||||
|
||||
.side-lang { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-dim); }
|
||||
.side-lang .select { margin-left: auto; }
|
||||
|
||||
/* --- top bar ------------------------------------------------------------ */
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 18px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex: none;
|
||||
}
|
||||
.bar-title { display: flex; align-items: baseline; gap: 10px; font-weight: 700; margin-right: auto; }
|
||||
.logo { color: var(--accent); font-size: 18px; }
|
||||
.store-id {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
color: var(--ink-dim);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
}
|
||||
.bar-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.progress { color: var(--ink-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* --- controls ----------------------------------------------------------- */
|
||||
.btn {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 7px 14px;
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s, transform .05s;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: #26313e; border-color: #3a4757; }
|
||||
.btn:active:not(:disabled) { transform: scale(.97); }
|
||||
.btn:disabled { opacity: .45; cursor: default; }
|
||||
.btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
|
||||
.btn-primary:hover:not(:disabled) { background: #45cd9b; }
|
||||
.btn-ghost { background: transparent; color: var(--ink-dim); }
|
||||
.btn-tiny { padding: 3px 9px; font-size: 12px; font-weight: 500; }
|
||||
.select {
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.link { color: var(--accent); cursor: pointer; text-decoration: underline; }
|
||||
|
||||
/* --- gate (no python, or no store yet) ---------------------------------- */
|
||||
.gate {
|
||||
margin: auto;
|
||||
max-width: 520px;
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
.gate h1 { font-size: 20px; margin: 0 0 10px; }
|
||||
.gate p { color: var(--ink-dim); white-space: pre-line; margin: 0 0 20px; word-break: break-all; }
|
||||
.gate-actions { display: flex; gap: 14px; justify-content: center; align-items: center; flex-wrap: wrap; }
|
||||
.gate-choice { display: inline-flex; align-items: center; gap: 8px; color: var(--ink-dim); font-size: 13px; }
|
||||
|
||||
/* --- the grid ----------------------------------------------------------- */
|
||||
.grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
/* Narrower than it was: the side menu takes 244px off the window, and at 260px
|
||||
a 1040px window had room for only two columns. */
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
/* Content-sized rows, spelled out. Left to `auto` the implicit rows split the
|
||||
grid's height evenly — every card came out 94px tall with its box art
|
||||
collapsed to nothing and its buttons clipped away, which is how the grid
|
||||
looked before this was measured. */
|
||||
grid-auto-rows: max-content;
|
||||
align-content: start;
|
||||
}
|
||||
.empty { margin: auto; color: var(--ink-dim); }
|
||||
.gate { overflow-y: auto; }
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.card.is-installed { border-color: #2f5a49; }
|
||||
|
||||
.art {
|
||||
/* One height for every card, art or not, so the titles and the buttons line up
|
||||
across a row. The images are cropped anyway (object-fit: cover). */
|
||||
height: 148px;
|
||||
flex: none;
|
||||
background: #0d1117;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.art img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.art-glyph { font-size: 44px; font-weight: 700; color: #263341; }
|
||||
|
||||
.body { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
|
||||
.body h2 { font-size: 15px; margin: 0; }
|
||||
.meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.badge-app { color: var(--accent); border-color: #2f5a49; }
|
||||
.badge-web { color: var(--warn); border-color: #5a4a2f; }
|
||||
.version { font-size: 12px; color: var(--ink-dim); margin-left: auto; }
|
||||
.desc {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-dim);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.actions { display: flex; gap: 8px; margin-top: auto; }
|
||||
|
||||
/* --- log ---------------------------------------------------------------- */
|
||||
.log {
|
||||
flex: none;
|
||||
background: var(--panel);
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 8px 18px 10px;
|
||||
}
|
||||
.log-toggle {
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-dim);
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0 0 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.log-lines {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11.5px;
|
||||
color: var(--ink-dim);
|
||||
background: #0d1117;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.log-line { white-space: pre-wrap; word-break: break-all; }
|
||||
.log-paths { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.paths-line {
|
||||
font-size: 11.5px;
|
||||
color: var(--ink-dim);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { GameDto } from '../../shared/contracts/dto/GameDto'
|
||||
import { requireElement, setHidden, setText } from '../dom/Dom'
|
||||
import type { AppState } from '../state/AppStore'
|
||||
import { matchesFilter } from '../state/CategoryFilter'
|
||||
import type { GameCardView } from './GameCardView'
|
||||
|
||||
/** The grid, and the sentence that stands in for it when there is nothing to show. */
|
||||
export class CatalogGridView {
|
||||
private readonly grid = requireElement('grid', HTMLElement)
|
||||
private readonly empty = requireElement('empty', HTMLElement)
|
||||
|
||||
public constructor (private readonly cards: GameCardView) {}
|
||||
|
||||
public render (state: AppState): void {
|
||||
const shown = state.games.filter((game: GameDto): boolean => matchesFilter(game, state.filter))
|
||||
this.grid.replaceChildren(...shown.map((game: GameDto): HTMLElement =>
|
||||
this.cards.createCard(game, state.messages, state.busy)))
|
||||
setHidden(this.grid, shown.length === 0)
|
||||
this.grid.scrollTop = 0
|
||||
setHidden(this.empty, shown.length !== 0)
|
||||
setText(this.empty, state.games.length === 0 ? state.messages.noGames : state.messages.noMatch)
|
||||
}
|
||||
|
||||
public hide (): void {
|
||||
setHidden(this.grid, true)
|
||||
setHidden(this.empty, true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { GameDto } from '../../shared/contracts/dto/GameDto'
|
||||
import type { MessageBundle } from '../../shared/i18n/MessageBundle'
|
||||
import { createElement } from '../dom/Dom'
|
||||
|
||||
export interface GameCardViewCallbacks {
|
||||
readonly onInstall: (name: string) => void
|
||||
readonly onLaunch: (name: string) => void
|
||||
readonly onRemove: (name: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* One card.
|
||||
*
|
||||
* A card is a function of a title and the strings: it holds no state of its own, so
|
||||
* the grid can throw the lot away and rebuild after every listing.
|
||||
*/
|
||||
export class GameCardView {
|
||||
public constructor (private readonly callbacks: GameCardViewCallbacks) {}
|
||||
|
||||
public createCard (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||
const card = createElement('article', 'card')
|
||||
if (game.installed) card.classList.add('is-installed')
|
||||
card.appendChild(this.createArt(game))
|
||||
card.appendChild(this.createBody(game, messages, busy))
|
||||
return card
|
||||
}
|
||||
|
||||
private createArt (game: GameDto): HTMLElement {
|
||||
const art = createElement('div', 'art')
|
||||
if (game.imageUrl !== null) {
|
||||
const image = createElement('img')
|
||||
image.src = game.imageUrl
|
||||
image.alt = ''
|
||||
image.loading = 'lazy'
|
||||
art.appendChild(image)
|
||||
return art
|
||||
}
|
||||
// No box art in the catalog: the first letter, on the same band an image would
|
||||
// fill, so a row of cards stays aligned either way.
|
||||
art.appendChild(createElement('span', 'art-glyph', game.title.slice(0, 1).toUpperCase()))
|
||||
return art
|
||||
}
|
||||
|
||||
private createBody (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||
const body = createElement('div', 'body')
|
||||
body.appendChild(createElement('h2', undefined, game.title))
|
||||
body.appendChild(this.createMeta(game, messages))
|
||||
if (game.description.length > 0) {
|
||||
body.appendChild(createElement('p', 'desc', game.description))
|
||||
}
|
||||
body.appendChild(this.createActions(game, messages, busy))
|
||||
return body
|
||||
}
|
||||
|
||||
private createMeta (game: GameDto, messages: MessageBundle): HTMLElement {
|
||||
const meta = createElement('div', 'meta')
|
||||
const mode = createElement('span', `badge badge-${game.mode}`,
|
||||
game.mode === 'web' ? messages.hosted : messages.native)
|
||||
mode.title = game.mode === 'web' ? messages.hostedHint : messages.nativeHint
|
||||
meta.appendChild(mode)
|
||||
meta.appendChild(createElement('span', 'badge badge-plain', game.platform))
|
||||
meta.appendChild(createElement('span', 'version',
|
||||
game.installed && game.installedVersion !== null
|
||||
? `${game.installedVersion} · ${messages.installed}`
|
||||
: game.version))
|
||||
return meta
|
||||
}
|
||||
|
||||
private createActions (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||
const actions = createElement('div', 'actions')
|
||||
const primary = createElement('button', 'btn btn-primary')
|
||||
primary.disabled = busy
|
||||
|
||||
if (game.installed && !game.updateAvailable) {
|
||||
primary.textContent = game.mode === 'web' ? messages.open : messages.play
|
||||
primary.disabled = busy || !game.launchable
|
||||
primary.addEventListener('click', (): void => { this.callbacks.onLaunch(game.name) })
|
||||
} else {
|
||||
primary.textContent = game.updateAvailable ? messages.update : messages.install
|
||||
primary.addEventListener('click', (): void => { this.callbacks.onInstall(game.name) })
|
||||
}
|
||||
actions.appendChild(primary)
|
||||
|
||||
if (game.installed) {
|
||||
const remove = createElement('button', 'btn btn-ghost', messages.remove)
|
||||
remove.disabled = busy
|
||||
remove.addEventListener('click', (): void => { this.callbacks.onRemove(game.name) })
|
||||
actions.appendChild(remove)
|
||||
}
|
||||
return actions
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
|
||||
import { createElement, requireElement, setHidden, setText } from '../dom/Dom'
|
||||
import type { MessageBundle } from '../../shared/i18n/MessageBundle'
|
||||
|
||||
/** A button on the gate, and what choosing it does. */
|
||||
export interface GateAction {
|
||||
readonly label: string
|
||||
readonly perform: (chosen: RegistryStoreDto | null) => void
|
||||
}
|
||||
|
||||
export interface GateLink {
|
||||
readonly label: string
|
||||
readonly url: string
|
||||
}
|
||||
|
||||
export interface GatePresentation {
|
||||
readonly title: string
|
||||
readonly body: string
|
||||
readonly action?: GateAction
|
||||
readonly link?: GateLink
|
||||
readonly choices?: readonly RegistryStoreDto[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The screen shown instead of the grid when there is nothing to drive: no Python, no
|
||||
* store yet, an engine too old, or a registry that cannot be reached.
|
||||
*/
|
||||
export class GateView {
|
||||
private readonly section = requireElement('gate', HTMLElement)
|
||||
private readonly title = requireElement('gate-title', HTMLElement)
|
||||
private readonly body = requireElement('gate-body', HTMLElement)
|
||||
private readonly choice = requireElement('gate-choice', HTMLElement)
|
||||
private readonly choiceLabel = requireElement('gate-choice-label', HTMLElement)
|
||||
private readonly select = requireElement('gate-select', HTMLSelectElement)
|
||||
private readonly button = requireElement('gate-action', HTMLButtonElement)
|
||||
private readonly link = requireElement('gate-link', HTMLAnchorElement)
|
||||
|
||||
public constructor (private readonly onOpenUrl: (url: string) => void) {}
|
||||
|
||||
public show (presentation: GatePresentation, messages: MessageBundle): void {
|
||||
setHidden(this.section, false)
|
||||
setText(this.title, presentation.title)
|
||||
setText(this.body, presentation.body)
|
||||
this.renderChoices(presentation.choices ?? [], messages)
|
||||
this.renderAction(presentation)
|
||||
this.renderLink(presentation.link ?? null)
|
||||
}
|
||||
|
||||
public hide (): void {
|
||||
setHidden(this.section, true)
|
||||
}
|
||||
|
||||
public get visible (): boolean {
|
||||
return !this.section.hidden
|
||||
}
|
||||
|
||||
/** Only shown when the registry offers more than one store; with a single one there is nothing to decide. */
|
||||
private renderChoices (choices: readonly RegistryStoreDto[], messages: MessageBundle): void {
|
||||
setHidden(this.choice, choices.length < 2)
|
||||
if (choices.length < 2) return
|
||||
setText(this.choiceLabel, messages.setupChoose)
|
||||
this.select.replaceChildren(...choices.map((store: RegistryStoreDto, index: number): HTMLOptionElement => {
|
||||
const option = createElement('option', undefined, store.name)
|
||||
option.value = String(index)
|
||||
return option
|
||||
}))
|
||||
}
|
||||
|
||||
private renderAction (presentation: GatePresentation): void {
|
||||
const action = presentation.action
|
||||
setHidden(this.button, action === undefined)
|
||||
this.button.disabled = false
|
||||
if (action === undefined) return
|
||||
setText(this.button, action.label)
|
||||
this.button.onclick = (): void => {
|
||||
const choices = presentation.choices ?? []
|
||||
const index = Number(this.select.value)
|
||||
action.perform(choices[Number.isFinite(index) ? index : 0] ?? choices[0] ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
private renderLink (link: GateLink | null): void {
|
||||
setHidden(this.link, link === null)
|
||||
if (link === null) return
|
||||
setText(this.link, link.label)
|
||||
this.link.onclick = (event: MouseEvent): void => {
|
||||
event.preventDefault()
|
||||
this.onOpenUrl(link.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createElement, requireElement, setText } from '../dom/Dom'
|
||||
import type { AppState } from '../state/AppStore'
|
||||
|
||||
const MAX_LOG_LINES = 400
|
||||
|
||||
export interface LogDrawerViewCallbacks {
|
||||
readonly onOpenFolder: (directory: string) => void
|
||||
}
|
||||
|
||||
/** The store's own output, verbatim, and the folders everything lands in. */
|
||||
export class LogDrawerView {
|
||||
private readonly toggle = requireElement('log-toggle', HTMLButtonElement)
|
||||
private readonly lines = requireElement('log-lines', HTMLElement)
|
||||
private readonly pathsBox = requireElement('log-paths', HTMLElement)
|
||||
|
||||
public constructor (private readonly callbacks: LogDrawerViewCallbacks) {
|
||||
this.toggle.addEventListener('click', (): void => {
|
||||
this.lines.hidden = !this.lines.hidden
|
||||
this.toggle.setAttribute('aria-expanded', String(!this.lines.hidden))
|
||||
})
|
||||
}
|
||||
|
||||
public render (state: AppState): void {
|
||||
setText(this.toggle, state.messages.log)
|
||||
this.pathsBox.replaceChildren()
|
||||
const paths = state.paths
|
||||
if (paths === null) return
|
||||
|
||||
this.pathsBox.appendChild(createElement('div', 'paths-line',
|
||||
`${state.messages.paths}: ${paths.storeFolder} · ${paths.menuGroup}`))
|
||||
|
||||
const folders: readonly (readonly [string, string])[] = [
|
||||
[state.messages.openStoreFolder, paths.storeFolder],
|
||||
[state.messages.openMenuFolder, paths.menuGroup]
|
||||
]
|
||||
for (const [label, directory] of folders) {
|
||||
const button = createElement('button', 'btn btn-tiny', label)
|
||||
button.addEventListener('click', (): void => { this.callbacks.onOpenFolder(directory) })
|
||||
this.pathsBox.appendChild(button)
|
||||
}
|
||||
}
|
||||
|
||||
public appendLine (line: string): void {
|
||||
this.lines.appendChild(createElement('div', 'log-line', line))
|
||||
while (this.lines.childElementCount > MAX_LOG_LINES) {
|
||||
const first = this.lines.firstElementChild
|
||||
if (first === null) break
|
||||
first.remove()
|
||||
}
|
||||
this.lines.scrollTop = this.lines.scrollHeight
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createElement, requireElement, setText } from '../dom/Dom'
|
||||
import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto'
|
||||
import type { Locale } from '../../shared/i18n/MessageBundle'
|
||||
import {
|
||||
buildCategorySections, isSameFilter, type CategoryFilter, type CategoryItem
|
||||
} from '../state/CategoryFilter'
|
||||
import type { AppState } from '../state/AppStore'
|
||||
|
||||
export interface SideMenuViewCallbacks {
|
||||
readonly onSelectStore: (home: string) => void
|
||||
readonly onAddStore: () => void
|
||||
readonly onSyncAll: () => void
|
||||
readonly onRefresh: () => void
|
||||
readonly onSelectCategory: (filter: CategoryFilter) => void
|
||||
readonly onSelectLocale: (locale: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The side menu: which store, what to do with it, and what to look at.
|
||||
*
|
||||
* Everything that is not a title lives here, and the bar's button folds it away.
|
||||
*/
|
||||
export class SideMenuView {
|
||||
private readonly storesHead = requireElement('head-stores', HTMLElement)
|
||||
private readonly actionsHead = requireElement('head-actions', HTMLElement)
|
||||
private readonly categoriesHead = requireElement('head-cats', HTMLElement)
|
||||
private readonly languageHead = requireElement('head-lang', HTMLElement)
|
||||
private readonly storeList = requireElement('store-list', HTMLElement)
|
||||
private readonly addStore = requireElement('add-store', HTMLButtonElement)
|
||||
private readonly syncAll = requireElement('sync-all', HTMLButtonElement)
|
||||
private readonly refresh = requireElement('refresh', HTMLButtonElement)
|
||||
private readonly categories = requireElement('cats', HTMLElement)
|
||||
private readonly locale = requireElement('locale', HTMLSelectElement)
|
||||
|
||||
public constructor (private readonly callbacks: SideMenuViewCallbacks) {
|
||||
this.addStore.addEventListener('click', callbacks.onAddStore)
|
||||
this.syncAll.addEventListener('click', callbacks.onSyncAll)
|
||||
this.refresh.addEventListener('click', callbacks.onRefresh)
|
||||
this.locale.addEventListener('change', (): void => { callbacks.onSelectLocale(this.locale.value) })
|
||||
}
|
||||
|
||||
public render (state: AppState): void {
|
||||
setText(this.storesHead, state.messages.stores)
|
||||
setText(this.actionsHead, state.messages.actions)
|
||||
setText(this.categoriesHead, state.messages.categories)
|
||||
setText(this.languageHead, state.messages.language)
|
||||
setText(this.addStore, state.messages.addStore)
|
||||
setText(this.syncAll, state.messages.syncAll)
|
||||
setText(this.refresh, state.messages.refresh)
|
||||
|
||||
this.renderStores(state)
|
||||
this.renderCategories(state)
|
||||
this.renderLocales(state)
|
||||
this.renderEnabled(state)
|
||||
}
|
||||
|
||||
private renderStores (state: AppState): void {
|
||||
const activeHome = state.currentStore === null ? null : state.currentStore.home
|
||||
// Two stores can carry the same id in different roots — the same catalog
|
||||
// installed twice. Then the id says nothing and the folder is what tells them
|
||||
// apart, so that is what the row shows.
|
||||
const ambiguousIds = new Set(state.stores
|
||||
.filter((store: InstalledStoreDto, index: number): boolean =>
|
||||
state.stores.findIndex((other: InstalledStoreDto): boolean => other.id === store.id) !== index)
|
||||
.map((store: InstalledStoreDto): string => store.id))
|
||||
|
||||
this.storeList.replaceChildren(...state.stores.map((store: InstalledStoreDto): HTMLElement => {
|
||||
const row = createElement('button', 'store-row')
|
||||
if (store.home === activeHome) row.classList.add('is-active')
|
||||
row.appendChild(createElement('span', 'store-row-name', store.name))
|
||||
row.appendChild(createElement('span', 'store-row-id',
|
||||
ambiguousIds.has(store.id) ? store.home : store.id))
|
||||
row.title = store.home
|
||||
row.addEventListener('click', (): void => {
|
||||
if (store.home !== activeHome) this.callbacks.onSelectStore(store.home)
|
||||
})
|
||||
return row
|
||||
}))
|
||||
}
|
||||
|
||||
private renderCategories (state: AppState): void {
|
||||
const sections = buildCategorySections(state.games, state.messages)
|
||||
const nodes: HTMLElement[] = []
|
||||
for (const section of sections) {
|
||||
if (section.title !== null) nodes.push(createElement('div', 'cat-group', section.title))
|
||||
for (const item of section.items) nodes.push(this.createCategoryRow(item, state.filter))
|
||||
}
|
||||
this.categories.replaceChildren(...nodes)
|
||||
}
|
||||
|
||||
private createCategoryRow (item: CategoryItem, active: CategoryFilter): HTMLElement {
|
||||
const row = createElement('button', 'cat')
|
||||
if (isSameFilter(item, active)) row.classList.add('is-active')
|
||||
row.appendChild(createElement('span', 'cat-label', item.label))
|
||||
row.appendChild(createElement('span', 'cat-count', String(item.count)))
|
||||
row.addEventListener('click', (): void => {
|
||||
this.callbacks.onSelectCategory({ kind: item.kind, value: item.value })
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
private renderLocales (state: AppState): void {
|
||||
const current = this.locale.value
|
||||
if (current === state.locale && this.locale.options.length === state.locales.length) return
|
||||
this.locale.replaceChildren(...state.locales.map((locale: Locale): HTMLOptionElement => {
|
||||
const option = createElement('option', undefined, locale.toUpperCase())
|
||||
option.value = locale
|
||||
option.selected = locale === state.locale
|
||||
return option
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* While the store is working only what would start a second call is disabled; the
|
||||
* filters stay live because they change what is on screen and nothing on disk.
|
||||
*/
|
||||
private renderEnabled (state: AppState): void {
|
||||
const hasStore = state.currentStore !== null
|
||||
this.syncAll.disabled = state.busy || !hasStore
|
||||
this.refresh.disabled = state.busy || !hasStore
|
||||
this.addStore.disabled = state.busy
|
||||
for (const row of this.storeList.querySelectorAll('button')) row.disabled = state.busy
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { requireElement, setHidden, setText } from '../dom/Dom'
|
||||
import type { AppState } from '../state/AppStore'
|
||||
|
||||
export interface TopBarViewCallbacks {
|
||||
readonly onToggleNavigation: () => void
|
||||
}
|
||||
|
||||
/** The bar: what this is, which store is open, and how far a sync has got. */
|
||||
export class TopBarView {
|
||||
private readonly appName = requireElement('app-name', HTMLElement)
|
||||
private readonly storeId = requireElement('store-id', HTMLElement)
|
||||
private readonly progress = requireElement('progress', HTMLElement)
|
||||
private readonly navToggle = requireElement('nav-toggle', HTMLButtonElement)
|
||||
|
||||
public constructor (callbacks: TopBarViewCallbacks) {
|
||||
this.navToggle.addEventListener('click', callbacks.onToggleNavigation)
|
||||
}
|
||||
|
||||
public render (state: AppState): void {
|
||||
setText(this.appName, state.messages.appName)
|
||||
setText(this.storeId, state.currentStore === null ? '' : state.currentStore.id)
|
||||
this.navToggle.title = state.messages.menu
|
||||
this.navToggle.setAttribute('aria-label', state.messages.menu)
|
||||
this.navToggle.setAttribute('aria-expanded', String(state.navigationOpen))
|
||||
|
||||
const progress = state.progress
|
||||
setHidden(this.progress, progress === null)
|
||||
setText(this.progress, progress === null ? '' : progress.label)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { GameDtoMapper } from '../application/mappers/GameDtoMapper'
|
||||
import type { CatalogListing } from '../domain/models/CatalogListing'
|
||||
import type { InstalledStore } from '../domain/models/InstalledStore'
|
||||
import type { RegistryStore } from '../domain/models/RegistryStore'
|
||||
import { DESKTOP_STORE_ENGINE } from '../domain/models/StoreEngine'
|
||||
import { deriveStoreId } from '../domain/models/StoreIdentity'
|
||||
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 { HttpStoreRegistryRepository } from '../infrastructure/repositories/HttpStoreRegistryRepository'
|
||||
import { PythonStoreCatalogGateway } from '../infrastructure/repositories/PythonStoreCatalogGateway'
|
||||
import type { GameDto } from '../shared/contracts/dto/GameDto'
|
||||
import { ENGLISH_MESSAGES } from '../shared/i18n/EnglishMessages'
|
||||
import { HUNGARIAN_MESSAGES } from '../shared/i18n/HungarianMessages'
|
||||
import { LOCALES } from '../shared/i18n/MessageBundle'
|
||||
|
||||
/**
|
||||
* Drives the store with no window and no Electron at all.
|
||||
*
|
||||
* This is the second composition root, and the reason the layers are worth having:
|
||||
* the same services the window uses are assembled here against the same ports, so an
|
||||
* integration mistake shows up in a terminal rather than in a screenshot.
|
||||
*
|
||||
* npm run smoke the store on this machine
|
||||
* SMOKE_HOME=/path/to/store-home npm run smoke a sandbox store
|
||||
*/
|
||||
class SmokeTest {
|
||||
private failed = false
|
||||
|
||||
private readonly stores = new FileSystemInstalledStoreRepository()
|
||||
private readonly pythonLocator = new SystemPythonRuntimeLocator()
|
||||
private readonly catalogGateway = new PythonStoreCatalogGateway(
|
||||
new PythonEngineProcessRunner(this.pythonLocator)
|
||||
)
|
||||
private readonly httpClient = new HttpTextClient()
|
||||
private readonly registry = new HttpStoreRegistryRepository(this.httpClient)
|
||||
private readonly gameMapper = new GameDtoMapper()
|
||||
|
||||
public async run (): Promise<number> {
|
||||
console.log('warp-engine-desktop-gui smoke test')
|
||||
|
||||
if (!this.checkPython()) return 1
|
||||
this.checkMessages()
|
||||
await this.checkRegistry()
|
||||
|
||||
const store = this.findStore()
|
||||
if (store === null) return this.failed ? 1 : 0
|
||||
await this.checkCatalog(store)
|
||||
|
||||
return this.failed ? 1 : 0
|
||||
}
|
||||
|
||||
private checkPython (): boolean {
|
||||
const runtime = this.pythonLocator.findRuntime()
|
||||
if (runtime === null) {
|
||||
this.reportBad('python', 'not found — the store cannot run')
|
||||
return false
|
||||
}
|
||||
this.reportOk('python', runtime.version)
|
||||
return true
|
||||
}
|
||||
|
||||
/** The bundles are typed against one key set, so this only counts them. */
|
||||
private checkMessages (): void {
|
||||
const english = Object.keys(ENGLISH_MESSAGES).length
|
||||
const hungarian = Object.keys(HUNGARIAN_MESSAGES).length
|
||||
if (english === hungarian) this.reportOk('strings', `${String(english)} keys × ${String(LOCALES.length)} languages`)
|
||||
else this.reportBad('strings', `en has ${String(english)}, hu has ${String(hungarian)}`)
|
||||
}
|
||||
|
||||
private async checkRegistry (): Promise<void> {
|
||||
try {
|
||||
const stores = await this.registry.listStores()
|
||||
if (stores.length === 0) {
|
||||
this.reportBad('registry', `${this.registry.sourceUrl} returned no stores`)
|
||||
return
|
||||
}
|
||||
this.reportOk('registry', `${String(stores.length)} store(s) from ${this.registry.sourceUrl}`)
|
||||
for (const store of stores) {
|
||||
this.reportOk(` ${store.name}`, `${store.catalogUrl} · ${deriveStoreId(store)}`)
|
||||
await this.checkStoreConfig(store)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
this.reportBad('registry', `${this.registry.sourceUrl}: ${this.describe(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A store repository without a config.json still installs — the engine merges what
|
||||
* it is given onto its defaults — so an absent file is reported, not failed.
|
||||
*/
|
||||
private async checkStoreConfig (store: RegistryStore): Promise<void> {
|
||||
const url = `${store.storeRepositoryUrl.replace(/\/+$/, '')}/raw/branch/master/config.json`
|
||||
try {
|
||||
const config: unknown = JSON.parse(await this.httpClient.readText(url))
|
||||
const sections = typeof config === 'object' && config !== null ? Object.keys(config).length : 0
|
||||
this.reportOk(' config.json', `${String(sections)} sections`)
|
||||
} catch (error: unknown) {
|
||||
this.reportOk(' config.json', `absent (${this.describe(error)}) — defaults would be used`)
|
||||
}
|
||||
}
|
||||
|
||||
private findStore (): InstalledStore | null {
|
||||
const sandbox = process.env['SMOKE_HOME']
|
||||
if (sandbox !== undefined && sandbox.length > 0) {
|
||||
const home = path.resolve(sandbox)
|
||||
const store: InstalledStore = {
|
||||
id: path.basename(home).replace(DESKTOP_STORE_ENGINE.homeSuffix, ''),
|
||||
name: path.basename(home),
|
||||
home,
|
||||
scriptPath: path.join(home, DESKTOP_STORE_ENGINE.scriptFileName),
|
||||
configPath: path.join(home, 'config.json'),
|
||||
engine: DESKTOP_STORE_ENGINE.id
|
||||
}
|
||||
for (const file of [store.scriptPath, store.configPath]) {
|
||||
if (!fs.existsSync(file)) {
|
||||
this.reportBad('SMOKE_HOME', `${file} is missing`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
this.reportOk('store (SMOKE_HOME)', store.home)
|
||||
return store
|
||||
}
|
||||
|
||||
const found = this.stores.findAll()[0]
|
||||
if (found === undefined) {
|
||||
console.log(' skip no store installed — run the app once, or set SMOKE_HOME')
|
||||
console.log(` it would be installed in ${this.stores.resolveDefaultHome('ttg')}`)
|
||||
return null
|
||||
}
|
||||
this.reportOk('store found', `${found.id} in ${found.home}`)
|
||||
return found
|
||||
}
|
||||
|
||||
private async checkCatalog (store: InstalledStore): Promise<void> {
|
||||
const logLines: string[] = []
|
||||
const progress = { onLog: (line: string): void => { logLines.push(line) } }
|
||||
|
||||
const engine = this.catalogGateway.readEngineVersion(store)
|
||||
if (engine === null) this.reportBad('engine', 'the store did not answer --version')
|
||||
else if (!engine.supported) this.reportBad('engine', `${engine.text} is too old for this client`)
|
||||
else this.reportOk('engine', engine.text)
|
||||
|
||||
const paths = await this.catalogGateway.readPaths(store, progress)
|
||||
if (paths.operatingSystem.length > 0 && paths.storeFolder.length > 0) {
|
||||
this.reportOk('paths', `${paths.operatingSystem} → ${paths.storeFolder}`)
|
||||
} else {
|
||||
this.reportBad('paths', JSON.stringify(paths))
|
||||
}
|
||||
|
||||
const listing: CatalogListing = await this.catalogGateway.listGames(store, progress)
|
||||
if (listing.games.length === 0) {
|
||||
this.reportBad('list', 'no games came back')
|
||||
return
|
||||
}
|
||||
const games = this.gameMapper.toDtoList(listing.games, listing.paths?.catalogBaseUrl ?? '')
|
||||
this.reportListing(games)
|
||||
|
||||
if (logLines.length > 0) this.reportOk('stderr log', `${String(logLines.length)} lines (kept off stdout)`)
|
||||
}
|
||||
|
||||
private reportListing (games: readonly GameDto[]): void {
|
||||
const native = games.filter((game: GameDto): boolean => game.mode === 'app').length
|
||||
const hosted = games.length - native
|
||||
this.reportOk('list', `${String(games.length)} titles (app:${String(native)}, web:${String(hosted)})`)
|
||||
|
||||
const withoutTitle = games.filter((game: GameDto): boolean =>
|
||||
game.name.length === 0 || game.title.length === 0 || game.platform.length === 0)
|
||||
if (withoutTitle.length > 0) this.reportBad('game shape', `${String(withoutTitle.length)} entries are incomplete`)
|
||||
else this.reportOk('game shape', 'name, title, platform, version, mode, installed, updateAvailable')
|
||||
|
||||
const installed = games.filter((game: GameDto): boolean => game.installed)
|
||||
this.reportOk('installed', `${String(installed.length)} of ${String(games.length)}`)
|
||||
|
||||
const unlaunchable = installed.filter((game: GameDto): boolean => !game.launchable)
|
||||
if (unlaunchable.length > 0) this.reportBad('launch targets', `${String(unlaunchable.length)} installed titles have nothing to launch`)
|
||||
else if (installed.length > 0) this.reportOk('launch targets', 'every installed title has one')
|
||||
|
||||
const withArt = games.filter((game: GameDto): boolean => game.imageUrl !== null)
|
||||
if (withArt.length > 0) {
|
||||
const first = withArt[0]
|
||||
if (first !== undefined) this.reportOk('box art', first.imageUrl ?? '')
|
||||
}
|
||||
}
|
||||
|
||||
private describe (error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
private reportOk (label: string, value?: string): void {
|
||||
console.log(` ok ${label}${value === undefined ? '' : `: ${value}`}`)
|
||||
}
|
||||
|
||||
private reportBad (label: string, value: string): void {
|
||||
this.failed = true
|
||||
console.log(` FAIL ${label}: ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
void new SmokeTest().run().then(
|
||||
(code: number): void => { process.exitCode = code },
|
||||
(error: unknown): void => {
|
||||
console.log(` FAIL error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { AppStateDto } from './dto/AppStateDto'
|
||||
import type { CatalogListingDto } from './dto/CatalogListingDto'
|
||||
import type { InstalledStoreDto } from './dto/InstalledStoreDto'
|
||||
import type { LocaleSelectionDto } from './dto/LocaleSelectionDto'
|
||||
import type { RegistryResultDto } from './dto/RegistryResultDto'
|
||||
import type { RegistryStoreDto } from './dto/RegistryStoreDto'
|
||||
import type { StorePathsDto } from './dto/StorePathsDto'
|
||||
import type { StoreSelectionDto } from './dto/StoreSelectionDto'
|
||||
import type { SyncEventDto } from './dto/SyncEventDto'
|
||||
|
||||
/** A stream the main process pushes; the window subscribes and never replies. */
|
||||
export type StreamListener<TPayload> = (payload: TPayload) => void
|
||||
|
||||
/**
|
||||
* The entire surface the window gets.
|
||||
*
|
||||
* Nothing else reaches it: no Node, no filesystem, no child processes. Both sides
|
||||
* compile against this interface, so a method the preload does not expose is a
|
||||
* compile error in the renderer rather than an `undefined is not a function` at
|
||||
* runtime.
|
||||
*/
|
||||
export interface BridgeApi {
|
||||
readState: () => Promise<AppStateDto>
|
||||
updateLocale: (locale: string) => Promise<LocaleSelectionDto>
|
||||
updateNavOpen: (open: boolean) => Promise<boolean>
|
||||
|
||||
listGames: () => Promise<CatalogListingDto>
|
||||
readPaths: () => Promise<StorePathsDto>
|
||||
syncGames: (names: readonly string[]) => Promise<void>
|
||||
removeGame: (name: string) => Promise<void>
|
||||
launchGame: (name: string) => Promise<boolean>
|
||||
|
||||
listRegistryStores: () => Promise<RegistryResultDto>
|
||||
installStore: (store: RegistryStoreDto) => Promise<InstalledStoreDto>
|
||||
selectStore: (home: string) => Promise<StoreSelectionDto>
|
||||
|
||||
openFolder: (directory: string) => Promise<boolean>
|
||||
openUrl: (url: string) => Promise<boolean>
|
||||
|
||||
onLog: (listener: StreamListener<string>) => void
|
||||
onSyncEvent: (listener: StreamListener<SyncEventDto>) => void
|
||||
onBusyChanged: (listener: StreamListener<boolean>) => void
|
||||
}
|
||||
|
||||
/** The name the bridge is published under on `window`. */
|
||||
export const BRIDGE_GLOBAL_NAME = 'storeApi'
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Every channel name, in one frozen table.
|
||||
*
|
||||
* The pattern is `<domain>:<verb><Subject>` — the same verbs the services use, so
|
||||
* a channel, the bridge method it backs and the service call behind it read as one
|
||||
* sentence. Renderer and main both import this table; a typo cannot make a channel
|
||||
* that only one side knows about.
|
||||
*/
|
||||
export const IPC_CHANNELS = {
|
||||
appReadState: 'app:readState',
|
||||
appUpdateLocale: 'app:updateLocale',
|
||||
appUpdateNavOpen: 'app:updateNavOpen',
|
||||
appOpenFolder: 'app:openFolder',
|
||||
appOpenUrl: 'app:openUrl',
|
||||
|
||||
catalogListGames: 'catalog:listGames',
|
||||
catalogReadPaths: 'catalog:readPaths',
|
||||
catalogSyncGames: 'catalog:syncGames',
|
||||
catalogRemoveGame: 'catalog:removeGame',
|
||||
catalogLaunchGame: 'catalog:launchGame',
|
||||
|
||||
storeListRegistry: 'store:listRegistry',
|
||||
storeInstallStore: 'store:installStore',
|
||||
storeSelectStore: 'store:selectStore',
|
||||
|
||||
/** Main to renderer, one way. */
|
||||
streamLog: 'stream:log',
|
||||
streamSyncEvent: 'stream:syncEvent',
|
||||
streamBusyChanged: 'stream:busyChanged'
|
||||
} as const
|
||||
|
||||
export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Locale } from '../../i18n/MessageBundle'
|
||||
import type { MessageBundle } from '../../i18n/MessageBundle'
|
||||
import type { EngineVersionDto } from './EngineVersionDto'
|
||||
import type { InstalledStoreDto } from './InstalledStoreDto'
|
||||
|
||||
/** Everything the window needs before it can paint anything. */
|
||||
export interface AppStateDto {
|
||||
readonly locale: Locale
|
||||
readonly locales: readonly Locale[]
|
||||
readonly messages: MessageBundle
|
||||
readonly navigationOpen: boolean
|
||||
/** The Python 3 version string, or null when there is none to drive the store. */
|
||||
readonly pythonVersion: string | null
|
||||
readonly currentStore: InstalledStoreDto | null
|
||||
readonly stores: readonly InstalledStoreDto[]
|
||||
readonly engine: EngineVersionDto | null
|
||||
readonly minimumEngineVersion: string
|
||||
readonly registryUrl: string
|
||||
readonly defaultStoreRoot: string
|
||||
readonly appVersion: string
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { GameDto } from './GameDto'
|
||||
import type { StorePathsDto } from './StorePathsDto'
|
||||
|
||||
/** One `listGames` answer: what the catalog offers, and what was left out. */
|
||||
export interface CatalogListingDto {
|
||||
readonly games: readonly GameDto[]
|
||||
readonly skipped: readonly string[]
|
||||
readonly paths: StorePathsDto | null
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** The installed engine's version, and whether this client can drive it. */
|
||||
export interface EngineVersionDto {
|
||||
readonly text: string
|
||||
readonly supported: boolean
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** How a title runs: unpacked on this machine, or served as a web build. */
|
||||
export type GameModeDto = 'app' | 'web'
|
||||
|
||||
/**
|
||||
* A catalog entry as the window needs it.
|
||||
*
|
||||
* Deliberately free of filesystem paths: what to launch is resolved in the main
|
||||
* process from the store's own state, so the window never learns where anything
|
||||
* lives and cannot be talked into opening it.
|
||||
*/
|
||||
export interface GameDto {
|
||||
readonly name: string
|
||||
readonly title: string
|
||||
readonly platform: string
|
||||
readonly version: string
|
||||
readonly mode: GameModeDto
|
||||
readonly kind: string
|
||||
readonly description: string
|
||||
readonly author: string
|
||||
/** Absolute, already resolved against the catalog's base URL. */
|
||||
readonly imageUrl: string | null
|
||||
readonly installed: boolean
|
||||
readonly updateAvailable: boolean
|
||||
readonly installedVersion: string | null
|
||||
readonly launchable: boolean
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** A store on this machine, as the switcher lists it. */
|
||||
export interface InstalledStoreDto {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly home: string
|
||||
readonly engine: string
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Locale, MessageBundle } from '../../i18n/MessageBundle'
|
||||
|
||||
/** The answer to a language change: what was stored, and the strings for it. */
|
||||
export interface LocaleSelectionDto {
|
||||
readonly locale: Locale
|
||||
readonly messages: MessageBundle
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { RegistryStoreDto } from './RegistryStoreDto'
|
||||
|
||||
/**
|
||||
* The registry lookup, failure included.
|
||||
*
|
||||
* The window has to say *why* there is nothing to install — an unreachable site
|
||||
* and an empty list need different words — so the error travels as data rather
|
||||
* than as a rejected promise.
|
||||
*/
|
||||
export interface RegistryResultDto {
|
||||
readonly stores: readonly RegistryStoreDto[]
|
||||
readonly sourceUrl: string
|
||||
readonly error: string | null
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** A store the registry offers, before anything is installed. */
|
||||
export interface RegistryStoreDto {
|
||||
readonly name: string
|
||||
readonly catalogUrl: string
|
||||
readonly storeRepositoryUrl: string
|
||||
/** Derived from the repository name, so the picker can show what it will become. */
|
||||
readonly storeId: string
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Where a store puts things on this machine, as the log drawer reports it. */
|
||||
export interface StorePathsDto {
|
||||
readonly operatingSystem: string
|
||||
readonly architecture: string
|
||||
readonly storeFolder: string
|
||||
readonly menuGroup: string
|
||||
readonly catalogBaseUrl: string
|
||||
readonly storeName: string
|
||||
readonly storeId: string
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { EngineVersionDto } from './EngineVersionDto'
|
||||
import type { InstalledStoreDto } from './InstalledStoreDto'
|
||||
|
||||
/** The answer to a store switch: which store is open, and can it be driven. */
|
||||
export interface StoreSelectionDto {
|
||||
readonly store: InstalledStoreDto
|
||||
readonly engine: EngineVersionDto | null
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* One line of the engine's progress stream.
|
||||
*
|
||||
* The engine emits JSONL on stdout; these are the events the window reacts to.
|
||||
* `plan` gives the count, `begin`/`installed` drive the counter, and the rest end
|
||||
* up in the log drawer.
|
||||
*/
|
||||
export type SyncEventDto =
|
||||
| { readonly event: 'plan'; readonly count: number }
|
||||
| { readonly event: 'begin'; readonly name: string; readonly title: string }
|
||||
| { readonly event: 'installed'; readonly name: string; readonly title: string; readonly changed: boolean }
|
||||
| { readonly event: 'failed'; readonly name: string; readonly error: string }
|
||||
| { readonly event: 'removed'; readonly name: string }
|
||||
| { readonly event: 'pruned'; readonly name: string }
|
||||
| { readonly event: 'finished'; readonly installed: number; readonly failed: number }
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* The English message bundle, and the source of the key set.
|
||||
*
|
||||
* Every other language is typed against these keys, so a missing or misspelled
|
||||
* translation is a compile error rather than a blank label at runtime.
|
||||
*/
|
||||
export const ENGLISH_MESSAGES = {
|
||||
appName: 'WarpEngine Store',
|
||||
syncAll: 'Install all',
|
||||
refresh: 'Refresh',
|
||||
install: 'Install',
|
||||
update: 'Update',
|
||||
play: 'Play',
|
||||
open: 'Open',
|
||||
remove: 'Remove',
|
||||
installed: 'installed',
|
||||
native: 'native',
|
||||
hosted: 'hosted',
|
||||
hostedHint: 'Opens in your browser — needs the network',
|
||||
nativeHint: 'Installed on this machine — works offline',
|
||||
updateAvailable: 'update available',
|
||||
log: 'Log',
|
||||
menu: 'Menu',
|
||||
stores: 'Stores',
|
||||
addStore: 'Add a store…',
|
||||
switchFailed: 'That store could not be opened',
|
||||
actions: 'Actions',
|
||||
categories: 'Categories',
|
||||
catAll: 'Everything',
|
||||
catInstalled: 'Installed',
|
||||
catUpdates: 'Updates',
|
||||
catAvailable: 'Not installed',
|
||||
catPlatform: 'Platform',
|
||||
catMode: 'Kind',
|
||||
language: 'Language',
|
||||
noGames: 'No installable titles in the catalog.',
|
||||
noMatch: 'Nothing in this category.',
|
||||
setupTitle: 'Set up a store',
|
||||
setupBody: 'No store on this machine yet. Pick one and it will be downloaded — the same files the shell installer would place, in the same folder.',
|
||||
setupAction: 'Download the store',
|
||||
setupWorking: 'Setting up…',
|
||||
setupChoose: 'Store',
|
||||
registryFailed: 'The list of stores could not be fetched',
|
||||
registryEmpty: 'The list of stores came back empty. Nothing to install from yet.',
|
||||
registryRetry: 'Try again',
|
||||
oldEngineTitle: 'The store needs refreshing',
|
||||
oldEngineBody: 'The store engine on this machine is older than this client can drive. Refreshing it downloads the current engine and keeps your settings and installed games.',
|
||||
oldEngineAction: 'Refresh the store',
|
||||
noPythonTitle: 'Python 3 is required',
|
||||
noPythonBody: 'The store is a Python program, so Python 3 has to be installed. Install it, then reopen this window.',
|
||||
pythonLink: 'python.org/downloads',
|
||||
paths: 'Where things go',
|
||||
openStoreFolder: 'Open the store folder',
|
||||
openMenuFolder: 'Open the menu folder',
|
||||
busy: 'Working…',
|
||||
failed: 'failed',
|
||||
removed: 'removed',
|
||||
upToDate: 'Everything is up to date.',
|
||||
of: 'of'
|
||||
} as const
|
||||
|
||||
/** Every string the window can show, by key. */
|
||||
export type MessageKey = keyof typeof ENGLISH_MESSAGES
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { MessageBundle } from './MessageBundle'
|
||||
|
||||
/**
|
||||
* Hungarian. Catalog text — titles, descriptions — is never translated here: it
|
||||
* arrives from the store as it was published.
|
||||
*/
|
||||
export const HUNGARIAN_MESSAGES: MessageBundle = {
|
||||
appName: 'WarpEngine Store',
|
||||
syncAll: 'Mind telepítése',
|
||||
refresh: 'Frissítés',
|
||||
install: 'Telepítés',
|
||||
update: 'Frissítés',
|
||||
play: 'Indítás',
|
||||
open: 'Megnyitás',
|
||||
remove: 'Eltávolítás',
|
||||
installed: 'telepítve',
|
||||
native: 'natív',
|
||||
hosted: 'hosztolt',
|
||||
hostedHint: 'A böngészőben nyílik meg — internet kell hozzá',
|
||||
nativeHint: 'Erre a gépre telepítve — internet nélkül is megy',
|
||||
updateAvailable: 'frissítés elérhető',
|
||||
log: 'Napló',
|
||||
menu: 'Menü',
|
||||
stores: 'Store-ok',
|
||||
addStore: 'Store hozzáadása…',
|
||||
switchFailed: 'Ez a store nem nyitható meg',
|
||||
actions: 'Műveletek',
|
||||
categories: 'Kategóriák',
|
||||
catAll: 'Minden',
|
||||
catInstalled: 'Telepítve',
|
||||
catUpdates: 'Frissítés',
|
||||
catAvailable: 'Nincs telepítve',
|
||||
catPlatform: 'Platform',
|
||||
catMode: 'Fajta',
|
||||
language: 'Nyelv',
|
||||
noGames: 'Nincs telepíthető cím a katalógusban.',
|
||||
noMatch: 'Ebben a kategóriában nincs semmi.',
|
||||
setupTitle: 'Store beállítása',
|
||||
setupBody: 'Ezen a gépen még nincs store. Válassz egyet, és letöltöm — ugyanazokat a fájlokat, ugyanabba a könyvtárba, ahová a shell-telepítő tenné.',
|
||||
setupAction: 'Store letöltése',
|
||||
setupWorking: 'Beállítás…',
|
||||
setupChoose: 'Store',
|
||||
registryFailed: 'A store-ok listája nem érhető el',
|
||||
registryEmpty: 'A store-ok listája üresen jött vissza. Egyelőre nincs miből telepíteni.',
|
||||
registryRetry: 'Újra',
|
||||
oldEngineTitle: 'A store frissítésre vár',
|
||||
oldEngineBody: 'A gépen lévő store-motor régebbi, mint amit ez a kliens vezérelni tud. A frissítés letölti a mostani motort, a beállításaid és a telepített játékok pedig megmaradnak.',
|
||||
oldEngineAction: 'Store frissítése',
|
||||
noPythonTitle: 'Python 3 kell hozzá',
|
||||
noPythonBody: 'A store egy Python program, tehát Python 3 kell a gépre. Telepítsd, majd nyisd meg újra ezt az ablakot.',
|
||||
pythonLink: 'python.org/downloads',
|
||||
paths: 'Hova kerül',
|
||||
openStoreFolder: 'Store könyvtár megnyitása',
|
||||
openMenuFolder: 'Menü könyvtár megnyitása',
|
||||
busy: 'Dolgozom…',
|
||||
failed: 'hiba',
|
||||
removed: 'eltávolítva',
|
||||
upToDate: 'Minden naprakész.',
|
||||
of: '/'
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { MessageKey } from './EnglishMessages'
|
||||
|
||||
/** One complete language. Partial bundles are not a thing: the type forbids them. */
|
||||
export type MessageBundle = Readonly<Record<MessageKey, string>>
|
||||
|
||||
/** The languages the window speaks. The public site has the same two. */
|
||||
export const LOCALES = ['en', 'hu'] as const
|
||||
|
||||
export type Locale = (typeof LOCALES)[number]
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ENGLISH_MESSAGES } from './EnglishMessages'
|
||||
import { HUNGARIAN_MESSAGES } from './HungarianMessages'
|
||||
import { LOCALES, type Locale, type MessageBundle } from './MessageBundle'
|
||||
|
||||
const BUNDLES: Readonly<Record<Locale, MessageBundle>> = {
|
||||
en: ENGLISH_MESSAGES,
|
||||
hu: HUNGARIAN_MESSAGES
|
||||
}
|
||||
|
||||
const FALLBACK_LOCALE: Locale = 'en'
|
||||
|
||||
/**
|
||||
* Which language, and its strings.
|
||||
*
|
||||
* Used on both sides of the bridge: the main process resolves the locale and
|
||||
* ships the bundle, the window renders from it.
|
||||
*/
|
||||
export class TranslationCatalog {
|
||||
public readonly locales: readonly Locale[] = LOCALES
|
||||
|
||||
/** A system locale like `hu-HU`, or anything at all, mapped onto a language. */
|
||||
public resolveLocale (candidate: string | null | undefined): Locale {
|
||||
const short = (candidate ?? '').slice(0, 2).toLowerCase()
|
||||
return LOCALES.find((locale: Locale): boolean => locale === short) ?? FALLBACK_LOCALE
|
||||
}
|
||||
|
||||
public readBundle (candidate: string | null | undefined): MessageBundle {
|
||||
return BUNDLES[this.resolveLocale(candidate)]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user