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,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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user