Files
warp-engine-client/src/infrastructure/engine/CatalogClient.ts
T
mr.zeroandClaude Opus 5 06f3f2a3b1
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
The store engine moves into the client, and Python goes with it
Reading the catalog, choosing the release that fits this machine, unpacking it,
writing the menu entry and remembering what went where all happen in process now.
There is no interpreter to find, no child process, and no JSON-lines protocol
between the two halves — `PythonEngineProcessRunner`, the runtime locator, the two
engine mappers and the version negotiation are all gone, and with them the one
unchecked cast this codebase had (engine stdout to a typed event).

What that buys a person: on Windows and on a fresh Mac the app simply works. It
used to look for `python3`, `python` and `py -3` and draw a link to python.org
where none answered.

What lands on disk is unchanged, deliberately. `config.json` and `state.json` keep
the shell engine's snake_case shape, its `<scope>:<name>` keys and its file modes,
so a machine whose library was installed by the CLI keeps it — verified against the
Python engine on the same catalog: the same 13-title listing with zero field
differences, byte-identical payloads, identical modes and an identical Info.plist,
and a re-sync over a Python-installed home that writes nothing. Remove, prune,
prune-suppression on a named sync and the v1 state migration were each exercised.

Three things worth knowing about the new code:

  - the zip reader is ~150 lines over `node:zlib`, because Node has none and this
    application has no runtime dependencies. It restores the executable bit from
    each entry's external attributes, without which nothing installed can start,
    and it refuses zip64, unknown compression and paths that escape the
    destination rather than guessing;

  - `SUPPORTED_WARP_ENGINE_VERSIONS` names the engine versions this client is
    written against, checked against the `WarpEngine-Version` header every
    response carries. `selectCatalogDialect` switches over that list exhaustively,
    so adding a version fails the build — type checker and linter both — until
    somebody says what its catalog reads like. An absent header is read as the
    oldest version, which is what an engine before 0.4.0 is;

  - refresh and the language picker are icons at the foot of the side menu now,
    both named for a tooltip and a screen reader, the picker still a real
    `<select>` under its glyph.

The repository is free of Python as well: the Makefile, the CI check and the
release script read package.json and the forge's JSON with Node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:36:25 +02:00

122 lines
4.8 KiB
TypeScript

import type { StoreConfiguration } from '../../domain/models/StoreConfiguration'
import {
WARP_ENGINE_VERSION_HEADER, describeWarpEngineVersion, readWarpEngineVersion,
type WarpEngineVersion
} from '../../domain/models/WarpEngineVersion'
import { describe, type StoreFileSystem } from '../files/StoreFileSystem'
import { StoreHttpClient } from '../http/StoreHttpClient'
import { asRecord } from '../json/JsonRecord'
const CLIENT_VERSION = '2.0.0'
const IMAGE_EXTENSIONS: Readonly<Record<string, string>> = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'image/gif': '.gif'
}
export interface DownloadedImage {
readonly body: Buffer
readonly extension: string
}
/** A catalog, and which engine served it. */
export interface FetchedCatalog {
readonly catalog: unknown
readonly engineVersion: WarpEngineVersion
}
/**
* The catalog API, as this store talks to it.
*
* Every URL the store fetches is built here, and the catalog is cached next to the
* config so a sync survives an outage — the machine that lost its network still has
* a library, and being told what is installed matters more than being current.
*/
export class CatalogClient {
private readonly http: StoreHttpClient
public constructor (
private readonly configuration: StoreConfiguration,
private readonly files: StoreFileSystem,
private readonly cachePath: string,
private readonly log: (line: string) => void
) {
this.http = new StoreHttpClient({
userAgent: `warp-engine-client/${CLIENT_VERSION} (${configuration.store.id})`,
timeout: configuration.behavior.timeout,
insecure: configuration.behavior.insecure
})
}
public apiUrl (endpoint: 'catalog' | 'download', parameters?: Readonly<Record<string, string>>): string {
const { baseUrl, api } = this.configuration.store
const url = `${baseUrl}/${api[endpoint].replace(/^\/+/, '')}`
if (parameters === undefined) return url
return `${url}?${new URLSearchParams(parameters).toString()}`
}
/** `GET /api/download` rather than `/file/`, so downloads are counted. */
public downloadUrl (asset: string): string {
return this.apiUrl('download', { path: asset })
}
/**
* The catalog, and the version of the engine that served it.
*
* The version comes from the response header, so it costs no extra request. A cache
* hit has no header — the cache file holds the catalog exactly as the engine sent it,
* which is the format the shell engine wrote and worth keeping — and then the version
* is reported as absent, which resolves to the oldest dialect this client knows.
*/
public async fetchCatalog (): Promise<FetchedCatalog> {
const ownerId = this.configuration.catalog.ownerId
const url = this.apiUrl('catalog', ownerId === null ? undefined : { owner_id: String(ownerId) })
try {
const { body, headers } = await this.http.readBytes(url)
const catalog = asRecord(JSON.parse(body.toString('utf8')))
if (catalog === null) throw new Error('the catalog is not a JSON object')
this.files.writeJson(this.cachePath, catalog)
const engineVersion = readWarpEngineVersion(headers[WARP_ENGINE_VERSION_HEADER] ?? null)
const sentence = describeWarpEngineVersion(engineVersion)
if (engineVersion.supported) this.log(sentence)
else this.log(`warning: ${sentence}`)
return { catalog, engineVersion }
} catch (error: unknown) {
const cached = asRecord(this.files.readJson(this.cachePath))
if (cached === null) throw new Error(`cannot fetch the catalog from ${url}: ${describe(error)}`)
this.log(`warning: catalog fetch failed (${describe(error)}) — using the cached copy`)
return { catalog: cached, engineVersion: readWarpEngineVersion(null) }
}
}
public async downloadAsset (asset: string, destination: string): Promise<number> {
return await this.http.download(this.downloadUrl(asset), destination)
}
/**
* Box art, or null if it cannot be had.
*
* Missing box art is not a reason to fail an install: the menu entry gets the
* generic icon and the game still starts.
*/
public async downloadImage (imageUrl: string, name: string): Promise<DownloadedImage | null> {
try {
const { body, contentType } = await this.http.readBytes(this.absoluteImageUrl(imageUrl))
const mediaType = contentType.split(';')[0]?.trim().toLowerCase() ?? ''
return { body, extension: IMAGE_EXTENSIONS[mediaType] ?? '.png' }
} catch (error: unknown) {
this.log(`warning: box art for ${name} failed: ${describe(error)}`)
return null
}
}
/** The catalog gives a server-relative `imageUrl`; absolute ones pass through. */
public absoluteImageUrl (imageUrl: string): string {
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) return imageUrl
return this.configuration.store.baseUrl + imageUrl
}
}