The store engine moves into the client, and Python goes with it
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful

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>
This commit is contained in:
2026-08-18 23:36:25 +02:00
co-authored by Claude Opus 5
parent 8511ccbef8
commit 06f3f2a3b1
66 changed files with 3327 additions and 762 deletions
+121
View File
@@ -0,0 +1,121 @@
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
}
}
@@ -0,0 +1,191 @@
import { describeHost, resolveForHost, type HostMachine } from '../../domain/models/HostMachine'
import type { UnavailableReason } from '../../domain/models/Game'
import { gameKey } from '../../domain/models/InstalledRecord'
import type {
CatalogSurvey, SelectedGame, UnavailableEntry
} from '../../domain/models/SelectedGame'
import type { AssetSpecification, StoreConfiguration } from '../../domain/models/StoreConfiguration'
import type { CatalogEntry, CatalogSoftware } from './dialects/CatalogDialect'
import { pickRelease } from './ReleasePicker'
/**
* The catalog, turned into what this machine can and cannot install.
*
* Two decisions layered on each other. The inner one asks, per install mode, "which
* titles have *this* asset for this host"; the outer one asks it once per mode and
* merges the answers in the config's mode order, so a title with no native build for
* this machine is still installable as a hosted page. That fallback is what makes a
* desktop store the only one that can carry the whole catalog.
*
* A title counts as unavailable only when every mode failed it, and then the *first*
* mode's verdict is the one kept: `app` comes first, so a person is told "no native
* build for this machine" rather than the web mode's complaint about the same title.
*
* The entries arrive already typed, from whichever `CatalogDialect` the serving engine
* version selected — nothing here knows what the catalog's JSON looks like.
*/
export class CatalogSurveyor {
public constructor (
private readonly configuration: StoreConfiguration,
private readonly log: (line: string) => void
) {}
public survey (entries: readonly CatalogEntry[], host: HostMachine): CatalogSurvey {
const chosen = new Map<string, SelectedGame>()
const unmet = new Map<string, UnavailableEntry>()
const reasonsByName = new Map<string, string[]>()
for (const mode of this.configuration.install.modes) {
const specification = this.configuration.install.specifications[mode]
if (specification === undefined) {
this.log(`warning: install mode '${mode}' has no asset spec — ignoring it`)
continue
}
const pass = this.surveyMode(entries, host, mode, specification)
for (const game of pass.games) {
const key = gameKey(game)
if (!chosen.has(key)) chosen.set(key, game)
}
for (const [name, reason] of pass.reasons) {
const collected = reasonsByName.get(name) ?? []
collected.push(`${mode}: ${reason}`)
reasonsByName.set(name, collected)
}
for (const entry of pass.unavailable) {
if (!unmet.has(entry.name)) unmet.set(entry.name, entry)
}
}
// A title that some later mode could serve is not skipped at all: nobody needs to
// hear that the native build was missing when the game installed anyway.
const installedNames = new Set([...chosen.values()].map((game: SelectedGame): string => game.name))
const skipped = [...reasonsByName.entries()]
.filter(([name]: readonly [string, readonly string[]]): boolean => !installedNames.has(name))
.map(([name, reasons]: readonly [string, readonly string[]]): string =>
`${name}: ${reasons.join('; ')}`)
return {
games: [...chosen.values()],
skipped,
unavailable: [...unmet.values()]
.filter((entry: UnavailableEntry): boolean => !installedNames.has(entry.name))
}
}
/** One pass over the catalog, asking for one mode's asset. */
private surveyMode (
entries: readonly CatalogEntry[],
host: HostMachine,
mode: string,
specification: AssetSpecification
): ModeSurvey {
const { statuses, only, exclude } = this.filters()
const games: SelectedGame[] = []
const reasons: [string, string][] = []
const unavailable: UnavailableEntry[] = []
for (const entry of entries) {
const software = entry.software
const name = software.name
// Editorial filters: a title excluded here is in none of the three lists, because
// that is the store's choice rather than a limit of the machine.
if (statuses.size > 0 && !statuses.has(software.status.toLowerCase())) continue
if (only.size > 0 && !only.has(name.toLowerCase())) continue
if (exclude.has(name.toLowerCase())) continue
const platform = this.configuration.platforms[software.platform]
if (platform?.enabled !== true) {
// Reported rather than hidden: to somebody looking at a catalog, a platform
// switched off reads as "not supported here".
unavailable.push(toUnavailable(entry, 'platformOff',
`${software.platform} is not carried by this store`))
continue
}
const wanted = readKinds(resolveForHost(specification.kind, host))
if (wanted.length === 0) {
const reason = `${software.platform} has no asset kind for ${describeHost(host)}`
reasons.push([name, reason])
unavailable.push(toUnavailable(entry, 'hostAsset', reason))
continue
}
const extension = resolveForHost(specification.extension, host) ?? ''
const release = pickRelease(entry.releaseCandidates, wanted, extension)
if (release === null) {
const reason = `no '${wanted.join('/')}' asset in any release`
reasons.push([name, reason])
unavailable.push(toUnavailable(entry, 'noAsset', reason))
continue
}
games.push({
name,
scope: software.platform,
platform: software.platform,
kind: release.kind,
version: release.version,
asset: release.assetName,
assetPath: release.assetPath,
title: software.title,
description: software.description,
author: software.author,
imageUrl: software.imageUrl,
createdAt: release.createdAt,
mode
})
}
return { games, reasons, unavailable }
}
private filters (): CatalogFilters {
const lower = (values: readonly string[]): ReadonlySet<string> =>
new Set(values.map((value: string): string => value.toLowerCase()))
return {
statuses: lower(this.configuration.catalog.statuses),
only: lower(this.configuration.catalog.only),
exclude: lower(this.configuration.catalog.exclude)
}
}
}
interface CatalogFilters {
readonly statuses: ReadonlySet<string>
readonly only: ReadonlySet<string>
readonly exclude: ReadonlySet<string>
}
interface ModeSurvey {
readonly games: readonly SelectedGame[]
/** `[name, reason]`, kept per name so several modes' complaints can be joined. */
readonly reasons: readonly (readonly [string, string])[]
readonly unavailable: readonly UnavailableEntry[]
}
/** One unavailable record, with enough for a client to draw a card. */
function toUnavailable (
entry: CatalogEntry,
reason: UnavailableReason,
detail: string
): UnavailableEntry {
const software: CatalogSoftware = entry.software
return {
name: software.name,
title: software.title,
platform: software.platform,
description: software.description,
author: software.author,
imageUrl: software.imageUrl,
version: entry.latestRelease?.version ?? '',
reason,
detail
}
}
function readKinds (value: string | readonly string[] | null): readonly string[] {
if (value === null) return []
const kinds = typeof value === 'string' ? [value] : value
return kinds.filter((kind: string): boolean => kind.length > 0)
}
@@ -0,0 +1,78 @@
import path from 'node:path'
import {
OPERATING_SYSTEM_PATHS, toSafeFileName, type DesktopLayout
} from '../../domain/models/DesktopLayout'
import type { InstalledRecord } from '../../domain/models/InstalledRecord'
import type { SelectedGame } from '../../domain/models/SelectedGame'
import type { StoreConfiguration } from '../../domain/models/StoreConfiguration'
import { expandHome, expandPathSpecification } from '../files/StoreFileSystem'
import type { HostMachineDetector } from './HostMachineDetector'
/**
* Where this store writes on this machine.
*
* The XDG and Windows environment variables are the correct answer when they are
* set, and a hardcoded path is only the fallback: a machine that moved its data
* directory should still get its own menu. A value pinned in the config beats both.
*/
export class DesktopLayoutResolver {
public constructor (
private readonly configuration: StoreConfiguration,
private readonly hosts: HostMachineDetector
) {}
public resolveLayout (): DesktopLayout {
const operatingSystem = this.hosts.findHost().operatingSystem
const defaults = OPERATING_SYSTEM_PATHS[operatingSystem]
if (defaults === undefined) {
throw new Error(
`no desktop layout is known for '${operatingSystem}' — set paths.install_root and paths.menu_dir`)
}
const sources: Record<string, string> = { os: operatingSystem }
const pick = (key: 'installRoot' | 'menuDirectory' | 'iconDirectory'): string | null => {
const configured = this.configuration.paths[key]
if (configured !== null && configured.length > 0) {
sources[key] = 'config'
return path.resolve(expandHome(configured))
}
sources[key] = 'default'
return expandPathSpecification(defaults[key] ?? null)
}
const installRoot = pick('installRoot')
const menuDirectory = pick('menuDirectory')
const iconDirectory = pick('iconDirectory')
if (installRoot === null || menuDirectory === null) {
throw new Error(`cannot resolve the install root or menu directory for '${operatingSystem}'`)
}
return { operatingSystem, installRoot, menuDirectory, iconDirectory, sources }
}
/** The only payload subtree this store may delete from. */
public ownedRoot (layout: DesktopLayout): string {
return path.join(layout.installRoot, this.configuration.paths.subfolder)
}
public payloadDirectory (layout: DesktopLayout, game: SelectedGame | InstalledRecord): string {
return path.join(this.ownedRoot(layout), game.name)
}
/**
* Box art path.
*
* Kept inside our own folder rather than the shared icon theme, so an uninstall
* never has to reach into it.
*/
public iconPath (layout: DesktopLayout, game: SelectedGame | InstalledRecord): string {
return path.join(this.ownedRoot(layout), 'icons', `${game.name}.png`)
}
/** Windows puts programs in a Start-menu folder; the others do not. */
public menuGroup (layout: DesktopLayout): string {
if (layout.operatingSystem === 'windows') {
return path.join(layout.menuDirectory, toSafeFileName(this.configuration.store.name))
}
return layout.menuDirectory
}
}
+216
View File
@@ -0,0 +1,216 @@
import fs from 'node:fs'
import path from 'node:path'
import type { DesktopLayout } from '../../domain/models/DesktopLayout'
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
import { gameKey, type InstalledRecord } from '../../domain/models/InstalledRecord'
import type { SelectedGame } from '../../domain/models/SelectedGame'
import { APP_MODE, type StoreConfiguration } from '../../domain/models/StoreConfiguration'
import { describe, type StoreFileSystem } from '../files/StoreFileSystem'
import type { CatalogClient } from './CatalogClient'
import type { DesktopLayoutResolver } from './DesktopLayoutResolver'
import type { LauncherWriter } from './launchers/LauncherWriter'
import type { PayloadInstaller } from './PayloadInstaller'
/**
* Installing and uninstalling one title, and the sweep over all of them.
*
* The rule the whole class turns on: an install writes three things — a payload, an
* icon and a menu entry — and the record of what was written is what an uninstall
* reads. Nothing is ever deleted by reconstructing a path from a name, because a
* path built from a name is a guess and this runs inside the user's home directory.
*/
export class GameInstaller {
public constructor (
private readonly configuration: StoreConfiguration,
private readonly layouts: DesktopLayoutResolver,
private readonly payloads: PayloadInstaller,
private readonly launchers: LauncherWriter,
private readonly catalog: CatalogClient,
private readonly files: StoreFileSystem,
private readonly log: (line: string) => void
) {}
/**
* Install every game in `games`, updating `installed` in place.
*
* Returns whether anything changed, which is what decides between "done" and
* "already up to date" — a sync that rewrites nothing should say so.
*/
public async installAll (
layout: DesktopLayout,
games: readonly SelectedGame[],
installed: Map<string, InstalledRecord>,
prune: boolean,
progress: EngineProgressListener
): Promise<boolean> {
let changed = false
let failed = 0
progress.onEvent?.({ event: 'plan', count: games.length })
for (const game of games) {
const key = gameKey(game)
let previous = installed.get(key) ?? null
// A different asset or a different mode is not an update in place: the old
// payload and the old kind of menu entry both have to go first.
if (previous !== null && (previous.asset !== game.asset || previous.mode !== game.mode)) {
this.log(`${game.name}: ${previous.version} (${previous.mode}) -> ` +
`${game.version} (${game.mode}), removing the old install`)
this.removeGame(layout, previous)
previous = null
changed = true
}
progress.onEvent?.({ event: 'begin', name: game.name, title: game.title })
try {
const result = await this.installGame(layout, game, previous)
progress.onEvent?.({
event: 'installed', name: game.name, title: game.title, changed: result.changed
})
if (result.changed || !sameRecord(installed.get(key) ?? null, result.record)) changed = true
installed.set(key, result.record)
} catch (error: unknown) {
failed += 1
const reason = describe(error)
this.log(`warning: ${game.name} failed: ${reason}`)
progress.onEvent?.({ event: 'failed', name: game.name, error: reason })
}
}
if (prune) {
const keep = new Set(games.map((game: SelectedGame): string => gameKey(game)))
for (const [key, record] of [...installed]) {
if (keep.has(key)) continue
this.log(`pruning ${key} (no longer in the catalog or filtered out)`)
this.removeGame(layout, record)
progress.onEvent?.({ event: 'pruned', name: record.name })
installed.delete(key)
changed = true
}
}
progress.onEvent?.({ event: 'finished', installed: installed.size, failed })
return changed
}
/** Install one title in its chosen mode. */
public async installGame (
layout: DesktopLayout,
game: SelectedGame,
previous: InstalledRecord | null
): Promise<{ readonly record: InstalledRecord; readonly changed: boolean }> {
const payloadDirectory = this.layouts.payloadDirectory(layout, game)
let changed = false
let payload: string | null = null
let executable: string | null = null
let executableKind: string | null = null
let url: string | null = null
if (game.mode === APP_MODE) {
if (previous !== null && isStillInstalled(previous, game, payloadDirectory)) {
executable = previous.executable
executableKind = previous.executableKind
} else {
const size = await this.payloads.unpack(game, payloadDirectory)
changed = true
const program = this.payloads.findProgram(payloadDirectory, game.name, layout.operatingSystem)
if (program === null) {
fs.rmSync(payloadDirectory, { recursive: true, force: true })
throw new Error(`no program was found inside ${game.asset}`)
}
executable = program.executablePath
executableKind = program.kind
this.log(`installed ${game.name} ${game.version} (${String(size)} bytes) -> ${payloadDirectory}`)
}
payload = payloadDirectory
} else {
// Nothing to unpack: the browser build is hosted, so the entry is a link.
url = this.launchers.webUrl(game)
if (previous?.url !== url) {
changed = true
this.log(`added ${game.name} ${game.version} as a web entry -> ${url}`)
}
}
const withoutMenu: InstalledRecord = {
...game,
payload,
executable,
executableKind,
icon: await this.installIcon(layout, game),
menuEntry: null,
url
}
const menuEntry = this.launchers.writeLauncher(layout, withoutMenu)
if ((previous?.menuEntry ?? null) !== menuEntry) changed = true
return { record: { ...withoutMenu, menuEntry }, changed }
}
/** Delete one title's payload, icon and menu entry — and nothing else. */
public removeGame (layout: DesktopLayout, record: InstalledRecord): void {
const owned = this.layouts.ownedRoot(layout)
for (const target of [record.payload, record.icon]) {
if (target !== null) this.files.removeWithin(target, owned)
}
if (record.menuEntry !== null) {
this.files.removeWithin(record.menuEntry, layout.menuDirectory)
}
}
/** Remove everything this store installed, folders included. */
public purge (layout: DesktopLayout, installed: Map<string, InstalledRecord>): void {
for (const [key, record] of [...installed]) {
this.removeGame(layout, record)
installed.delete(key)
}
const owned = this.layouts.ownedRoot(layout)
this.files.pruneEmptyDirectories([path.join(owned, 'icons'), owned], layout.installRoot)
if (layout.operatingSystem === 'windows') {
this.files.pruneEmptyDirectories([this.layouts.menuGroup(layout)], layout.menuDirectory)
}
}
private async installIcon (layout: DesktopLayout, game: SelectedGame): Promise<string | null> {
if (game.imageUrl === null) return null
const iconPath = this.layouts.iconPath(layout, game)
if (hasContent(iconPath)) return iconPath
const image = await this.catalog.downloadImage(game.imageUrl, game.name)
if (image === null) return null
// The name says .png because that is what a desktop entry and `sips` expect; a
// non-PNG cover still displays on Linux, which sniffs the content.
this.files.writeAtomic(iconPath, image.body)
return iconPath
}
}
/**
* Whether the previous install is still there and still the right one.
*
* All four conditions matter: the same asset, a payload directory that exists, a
* recorded executable, and that executable still on disk. A user who deleted the
* folder by hand should get a reinstall rather than a menu entry that does nothing.
*/
function isStillInstalled (
previous: InstalledRecord,
game: SelectedGame,
payloadDirectory: string
): boolean {
return previous.asset === game.asset &&
fs.existsSync(payloadDirectory) &&
previous.executable !== null &&
fs.existsSync(previous.executable)
}
function hasContent (filePath: string): boolean {
try {
return fs.statSync(filePath).size > 0
} catch {
return false
}
}
function sameRecord (left: InstalledRecord | null, right: InstalledRecord): boolean {
return left !== null && JSON.stringify(left) === JSON.stringify(right)
}
@@ -0,0 +1,47 @@
import type { HostMachine } from '../../domain/models/HostMachine'
/**
* This machine, as the release picker needs to know it.
*
* Node already normalises what `uname -m` reports, but not to the names the catalog
* uses, and the catalog's names are the ones the asset kinds are keyed by.
*/
export class HostMachineDetector {
private cached: HostMachine | null = null
public findHost (): HostMachine {
this.cached ??= {
operatingSystem: readOperatingSystem(),
architecture: readArchitecture()
}
return this.cached
}
}
/**
* Node's names for the architectures the catalog has a name for.
*
* Anything not in the table passes through unchanged: it will match no asset kind,
* and the survey then reports the titles as unavailable on this machine — which is
* the honest answer for a host nobody has built for.
*/
const ARCHITECTURE_NAMES: Readonly<Record<string, string>> = {
x64: 'x86_64',
arm64: 'aarch64',
arm: 'armhf',
ia32: 'x86'
}
const OPERATING_SYSTEM_NAMES: Readonly<Record<string, string>> = {
darwin: 'darwin',
win32: 'windows',
linux: 'linux'
}
function readArchitecture (): string {
return ARCHITECTURE_NAMES[process.arch] ?? process.arch
}
function readOperatingSystem (): string {
return OPERATING_SYSTEM_NAMES[process.platform] ?? process.platform
}
@@ -0,0 +1,267 @@
import path from 'node:path'
import type { CatalogListing } from '../../domain/models/CatalogListing'
import type { DesktopLayout } from '../../domain/models/DesktopLayout'
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
import type { Game, GameMode } from '../../domain/models/Game'
import {
gameKey, limitToNames, matchStateKeys, type InstalledRecord
} from '../../domain/models/InstalledRecord'
import type { InstalledStore } from '../../domain/models/InstalledStore'
import type {
CatalogSurvey, SelectedGame, UnavailableEntry
} from '../../domain/models/SelectedGame'
import { APP_MODE, WEB_MODE, type StoreConfiguration } from '../../domain/models/StoreConfiguration'
import type { StorePaths } from '../../domain/models/StorePaths'
import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
import { StoreFileSystem } from '../files/StoreFileSystem'
import { CatalogClient, type FetchedCatalog } from './CatalogClient'
import { CatalogSurveyor } from './CatalogSurveyor'
import type { CatalogEntry } from './dialects/CatalogDialect'
import { selectCatalogDialect } from './dialects/CatalogDialectSelector'
import { DesktopLayoutResolver } from './DesktopLayoutResolver'
import { GameInstaller } from './GameInstaller'
import { HostMachineDetector } from './HostMachineDetector'
import { LauncherWriter } from './launchers/LauncherWriter'
import { PayloadInstaller } from './PayloadInstaller'
import { StoreConfigurationReader } from './StoreConfigurationReader'
import { StoreStateRepository } from './StoreStateRepository'
const STATE_FILE_NAME = 'state.json'
const CATALOG_CACHE_FILE_NAME = 'catalog.json'
/**
* The store engine, in process.
*
* This is the whole of what used to be `desktop_store.py` and `warpstore.py` driven
* as a child process: the catalog, the release choice, the host match, the unpacking,
* the menu entry and the state. Nothing is serialised to JSON lines and parsed back,
* so the progress events below are the typed events the window already expects, and
* a `Game` is built rather than read out of somebody else's field names.
*
* What has *not* changed is what lands on disk. `config.json` and `state.json` keep
* the shell engine's snake_case shape, so a machine whose library was installed by
* the CLI keeps it.
*/
export class NativeStoreCatalogGateway implements StoreCatalogGateway {
private readonly hosts = new HostMachineDetector()
public async listGames (
store: InstalledStore,
progress: EngineProgressListener = {}
): Promise<CatalogListing> {
const engine = this.openStore(store, progress)
const host = this.hosts.findHost()
engine.log(`host: ${host.operatingSystem}/${host.architecture}`)
const survey = engine.surveyor.survey(await this.readEntries(engine), host)
const installed = engine.state.readState()
// One list, both kinds: a client that hides what it cannot install leaves the
// visitor wondering whether the catalog is small or their machine is unusual.
const games = [
...survey.games.map((game: SelectedGame): Game => this.toGame(engine, game, installed)),
...survey.unavailable.map((entry: UnavailableEntry): Game => toUnavailableGame(entry))
].sort((left: Game, right: Game): number =>
left.title.toLowerCase().localeCompare(right.title.toLowerCase()))
return { games, skipped: survey.skipped, paths: this.toPaths(engine) }
}
/**
* Where this store's things go.
*
* Synchronous work behind an async port, which is deliberate: the port was shaped
* for a child process and stays shaped for one, so a future engine that has to go
* to the network for this needs no change above.
*/
public readPaths (
store: InstalledStore,
progress: EngineProgressListener = {}
): Promise<StorePaths> {
return Promise.resolve(this.toPaths(this.openStore(store, progress)))
}
public async syncGames (
store: InstalledStore,
names: readonly string[],
progress: EngineProgressListener = {}
): Promise<void> {
const engine = this.openStore(store, progress)
const survey: CatalogSurvey = engine.surveyor.survey(
await this.readEntries(engine), this.hosts.findHost())
for (const reason of survey.skipped) engine.log(`skipped ${reason}`)
const wanted = names.length > 0 ? limitToNames(survey.games, names) : survey.games
const installed = engine.state.readState()
// Pruning is for a full sync only: asked for two titles, the store must not take
// the absence of the rest as a reason to uninstall them.
const prune = engine.configuration.behavior.prune && names.length === 0
const changed = await engine.installer.installAll(
engine.layout, wanted, installed, prune, progress)
engine.state.writeState(installed)
if (!changed) {
engine.log('already up to date')
return
}
engine.launchers.refreshMenu(engine.layout)
engine.log(`done — the games are in ${engine.layouts.menuGroup(engine.layout)}`)
}
public removeGame (
store: InstalledStore,
name: string,
progress: EngineProgressListener = {}
): Promise<void> {
const engine = this.openStore(store, progress)
const installed = engine.state.readState()
const keys = matchStateKeys(installed, [name])
if (keys.length === 0) {
engine.log(`not installed: ${name}`)
return Promise.resolve()
}
for (const key of keys) {
const record = installed.get(key)
if (record === undefined) continue
engine.installer.removeGame(engine.layout, record)
progress.onEvent?.({ event: 'removed', name: record.name })
installed.delete(key)
}
engine.state.writeState(installed)
engine.launchers.refreshMenu(engine.layout)
return Promise.resolve()
}
/**
* Fetch the catalog and read it with the dialect its engine version calls for.
*
* This is the one place a WarpEngine version turns into behaviour: the header decides
* which dialect parses the response, and everything downstream sees typed entries.
*/
private async readEntries (engine: StoreEngineContext): Promise<readonly CatalogEntry[]> {
const fetched: FetchedCatalog = await engine.catalog.fetchCatalog()
return selectCatalogDialect(fetched.engineVersion.resolved).listEntries(fetched.catalog)
}
/**
* Assemble the engine for one store.
*
* Per call rather than cached: the config on disk is the authority and the user may
* have edited it, and a store's home is cheap to read.
*/
private openStore (store: InstalledStore, progress: EngineProgressListener): StoreEngineContext {
const log = (line: string): void => { progress.onLog?.(`[${store.id}-store] ${line}`) }
const files = new StoreFileSystem(`${store.id}-store`, log)
const configuration = new StoreConfigurationReader(files).readConfiguration(store.configPath)
const layouts = new DesktopLayoutResolver(configuration, this.hosts)
const layout = layouts.resolveLayout()
const catalog = new CatalogClient(
configuration, files, path.join(store.home, CATALOG_CACHE_FILE_NAME), log)
const launchers = new LauncherWriter(configuration, layouts, files, log)
return {
configuration,
layout,
layouts,
catalog,
launchers,
log,
surveyor: new CatalogSurveyor(configuration, log),
state: new StoreStateRepository(files, path.join(store.home, STATE_FILE_NAME), log),
installer: new GameInstaller(
configuration, layouts,
new PayloadInstaller(catalog, files, log),
launchers, catalog, files, log)
}
}
private toGame (
engine: StoreEngineContext,
game: SelectedGame,
installed: ReadonlyMap<string, InstalledRecord>
): Game {
const record = installed.get(gameKey(game)) ?? null
return {
name: game.name,
title: game.title,
platform: game.platform,
version: game.version,
mode: toMode(game.mode),
kind: game.kind,
description: game.description,
author: game.author,
imagePath: game.imageUrl,
installed: record !== null,
updateAvailable: record !== null && record.asset !== game.asset,
installedVersion: record?.version ?? null,
menuEntryPath: record?.menuEntry ?? null,
executablePath: record?.executable ?? null,
hostedUrl: game.mode === WEB_MODE ? engine.launchers.webUrl(game) : null,
installable: true,
unavailableReason: null,
unavailableDetail: null
}
}
private toPaths (engine: StoreEngineContext): StorePaths {
const host = this.hosts.findHost()
return {
operatingSystem: engine.layout.operatingSystem,
architecture: host.architecture,
installRoot: engine.layout.installRoot,
menuDirectory: engine.layout.menuDirectory,
storeFolder: engine.layouts.ownedRoot(engine.layout),
menuGroup: engine.layouts.menuGroup(engine.layout),
catalogBaseUrl: engine.configuration.store.baseUrl,
storeName: engine.configuration.store.name,
storeId: engine.configuration.store.id
}
}
}
/** Everything one store's operations need, assembled from its home. */
interface StoreEngineContext {
readonly configuration: StoreConfiguration
readonly layout: DesktopLayout
readonly layouts: DesktopLayoutResolver
readonly catalog: CatalogClient
readonly launchers: LauncherWriter
readonly surveyor: CatalogSurveyor
readonly state: StoreStateRepository
readonly installer: GameInstaller
readonly log: (line: string) => void
}
/**
* A title this machine cannot install, in the same shape as an installable one.
*
* The mode is `app` for want of a truer answer: a title with no build has no mode,
* and nothing reads this one because `installable` is false.
*/
function toUnavailableGame (entry: UnavailableEntry): Game {
return {
name: entry.name,
title: entry.title,
platform: entry.platform,
version: entry.version,
mode: APP_MODE,
kind: '',
description: entry.description,
author: entry.author,
imagePath: entry.imageUrl,
installed: false,
updateAvailable: false,
installedVersion: null,
menuEntryPath: null,
executablePath: null,
hostedUrl: null,
installable: false,
unavailableReason: entry.reason,
unavailableDetail: entry.detail
}
}
function toMode (mode: string): GameMode {
return mode === WEB_MODE ? WEB_MODE : APP_MODE
}
@@ -0,0 +1,159 @@
import fs from 'node:fs'
import path from 'node:path'
import type { SelectedGame } from '../../domain/models/SelectedGame'
import { ZipArchive } from '../archive/ZipArchive'
import type { StoreFileSystem } from '../files/StoreFileSystem'
import type { CatalogClient } from './CatalogClient'
/** Files that are never the program: data, libraries and documentation. */
const NEVER_A_PROGRAM: readonly string[] =
['.txt', '.md', '.json', '.so', '.dll', '.dylib', '.pck', '.dat']
export type ExecutableKind = 'bundle' | 'exe'
export interface FoundProgram {
readonly kind: ExecutableKind
readonly executablePath: string
}
/**
* Getting a native build onto the disk and finding what to launch in it.
*
* The archive is downloaded to a part file beside the payload and unpacked only once
* it is complete, and the payload directory is replaced rather than merged: a build
* that dropped a file between releases would otherwise keep the old one around and
* the game would load it.
*/
export class PayloadInstaller {
public constructor (
private readonly catalog: CatalogClient,
private readonly files: StoreFileSystem,
private readonly log: (line: string) => void
) {}
/** Download and unpack into `destination`. Returns bytes downloaded. */
public async unpack (game: SelectedGame, destination: string): Promise<number> {
const parent = path.dirname(destination)
fs.mkdirSync(parent, { recursive: true })
const archivePath = this.files.temporaryPath(parent, game.name, '.zip')
try {
const size = await this.catalog.downloadAsset(game.asset, archivePath)
fs.rmSync(destination, { recursive: true, force: true })
fs.mkdirSync(destination, { recursive: true })
ZipArchive.open(archivePath).extractAll(destination)
return size
} finally {
fs.rmSync(archivePath, { force: true })
}
}
/**
* The thing to launch inside an unpacked payload.
*
* A `bundle` is a macOS `.app` the archive already contained — a LÖVE or Godot
* build ships one — and then it *is* the launcher rather than something to wrap.
* Otherwise the answer is a single executable, and "single" is the whole
* difficulty: an archive holding one candidate is unambiguous, and where there are
* several the one named after the game wins. Anything else is reported as not
* found, because launching the wrong binary is worse than failing the install.
*/
public findProgram (root: string, name: string, operatingSystem: string): FoundProgram | null {
if (operatingSystem === 'darwin') {
const bundle = this.findBundle(root)
if (bundle !== null) return { kind: 'bundle', executablePath: bundle }
}
const executables: string[] = []
const namedAfterTheGame: string[] = []
this.walk(root, (filePath: string): void => {
const fileName = path.basename(filePath)
const lowered = fileName.toLowerCase()
if (NEVER_A_PROGRAM.some((extension: string): boolean => lowered.endsWith(extension))) return
if (operatingSystem === 'windows') {
if (lowered.endsWith('.exe')) executables.push(filePath)
} else if (isExecutable(filePath)) {
executables.push(filePath)
}
if (stem(fileName) === name) namedAfterTheGame.push(filePath)
})
for (const candidates of [executables, namedAfterTheGame]) {
if (candidates.length === 1 && candidates[0] !== undefined) {
return { kind: 'exe', executablePath: candidates[0] }
}
const exact = candidates.filter((candidate: string): boolean =>
stem(path.basename(candidate)) === name)
if (exact.length === 1 && exact[0] !== undefined) {
return { kind: 'exe', executablePath: exact[0] }
}
}
this.log(`found no single program to launch inside ${root}`)
return null
}
/** The shallowest `.app`, without descending into one we have already found. */
private findBundle (root: string): string | null {
let level = [root]
while (level.length > 0) {
const next: string[] = []
for (const directory of level) {
const bundles = readDirectories(directory)
.filter((entry: string): boolean => entry.endsWith('.app')).sort()
const first = bundles[0]
if (first !== undefined) return path.join(directory, first)
for (const entry of readDirectories(directory)) next.push(path.join(directory, entry))
}
level = next
}
return null
}
/** Every file under `root`, never entering a `.app` bundle. */
private walk (root: string, visit: (filePath: string) => void): void {
const pending = [root]
while (pending.length > 0) {
const directory = pending.pop()
if (directory === undefined) continue
let entries: fs.Dirent[]
try {
entries = fs.readdirSync(directory, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = path.join(directory, entry.name)
if (entry.isDirectory()) {
if (!entry.name.endsWith('.app')) pending.push(full)
} else if (entry.isFile()) {
visit(full)
}
}
}
}
}
function readDirectories (directory: string): readonly string[] {
try {
return fs.readdirSync(directory, { withFileTypes: true })
.filter((entry: fs.Dirent): boolean => entry.isDirectory())
.map((entry: fs.Dirent): string => entry.name)
} catch {
return []
}
}
function isExecutable (filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.X_OK)
return true
} catch {
return false
}
}
function stem (fileName: string): string {
return path.basename(fileName, path.extname(fileName))
}
@@ -0,0 +1,55 @@
import path from 'node:path'
import type { CatalogRelease } from './dialects/CatalogDialect'
export interface PickedRelease {
readonly version: string
readonly createdAt: string | null
readonly assetName: string
readonly kind: string
readonly assetPath: string
}
/** `/file/blessingofra-2.0.0.prg` -> `blessingofra-2.0.0.prg`. */
export function assetBasename (assetPath: string): string {
return path.posix.basename(assetPath.replace(/\/+$/, ''))
}
/**
* The newest non-dev release that carries an asset kind we can use.
*
* The candidates arrive newest-first from the dialect, which is where knowing that the
* API sorts them lives. Two rules decide the rest:
*
* **Release order wins over kind order.** The newest release that has *any* acceptable
* kind is taken, and within it the most preferred kind. That is what a desktop host
* wants: on Apple Silicon `mac_universal` beats `mac_x64`, which would need Rosetta,
* but not at the price of installing an older release.
*
* **A `dev-` build is never taken.** It is a moving target, and installing one would
* leave a menu entry pointing at an archive that is replaced without a version change.
*/
export function pickRelease (
candidates: readonly CatalogRelease[],
kinds: readonly string[],
extension: string
): PickedRelease | null {
for (const release of candidates) {
if (release.version.startsWith('dev-')) continue
for (const kind of kinds) {
for (const asset of release.assets) {
if (asset.kind !== kind) continue
const assetName = assetBasename(asset.path)
if (assetName.length === 0) continue
if (extension.length > 0 && !assetName.toLowerCase().endsWith(extension.toLowerCase())) continue
return {
version: release.version,
createdAt: release.createdAt,
assetName,
kind,
assetPath: asset.path
}
}
}
}
return null
}
@@ -0,0 +1,148 @@
import type { HostSpecific } from '../../domain/models/HostMachine'
import {
APP_MODE, DEFAULT_STORE_CONFIGURATION, WEB_MODE,
type AssetSpecification, type BehaviorConfiguration, type CatalogConfiguration,
type InstallConfiguration, type PathsConfiguration, type PlatformConfiguration,
type StoreConfiguration, type StoreDescriptor
} from '../../domain/models/StoreConfiguration'
import {
asRecord, readBoolean, readNumber, readOptionalString, readRecord, readString,
readStringArray, type JsonRecord
} from '../json/JsonRecord'
import type { StoreFileSystem } from '../files/StoreFileSystem'
/**
* A store's `config.json`, read onto the defaults.
*
* The file is **snake_case** and this is the only place that knows it: that is the
* format the store repositories publish, and it stays the format on disk so a store
* config written for the shell engine is still a valid config here. Rename a field
* there and this reader is the single file that follows.
*
* Reading replaces the deep merge the shell engine did: every field states its own
* default, so a config that omits a section gets the whole section rather than a
* half-populated one.
*/
export class StoreConfigurationReader {
public constructor (private readonly files: StoreFileSystem) {}
public readConfiguration (configPath: string): StoreConfiguration {
const record = asRecord(this.files.readJson(configPath)) ?? {}
return {
store: readStore(readRecord(record, 'store') ?? {}),
paths: readPaths(readRecord(record, 'paths') ?? {}),
install: readInstall(readRecord(record, 'install') ?? {}),
catalog: readCatalog(readRecord(record, 'catalog') ?? {}),
platforms: readPlatforms(readRecord(record, 'platforms')),
behavior: readBehavior(readRecord(record, 'behavior') ?? {})
}
}
}
function readStore (record: JsonRecord): StoreDescriptor {
const defaults = DEFAULT_STORE_CONFIGURATION.store
const api = readRecord(record, 'api') ?? {}
return {
id: readString(record, 'id', defaults.id),
name: readString(record, 'name', defaults.name),
// Trailing slashes are stripped once, here, so every URL built from it joins
// with exactly one separator.
baseUrl: readString(record, 'base_url', defaults.baseUrl).replace(/\/+$/, ''),
api: {
catalog: readString(api, 'catalog', defaults.api.catalog),
download: readString(api, 'download', defaults.api.download)
}
}
}
function readPaths (record: JsonRecord): PathsConfiguration {
const defaults = DEFAULT_STORE_CONFIGURATION.paths
return {
installRoot: readOptionalString(record, 'install_root'),
menuDirectory: readOptionalString(record, 'menu_dir'),
iconDirectory: readOptionalString(record, 'icon_dir'),
subfolder: readString(record, 'subfolder', defaults.subfolder)
}
}
function readInstall (record: JsonRecord): InstallConfiguration {
const defaults = DEFAULT_STORE_CONFIGURATION.install
const modes = readStringArray(record, 'modes')
const specifications: Record<string, AssetSpecification> = {}
for (const [key, value] of Object.entries(record)) {
if (key === 'modes') continue
const specification = asRecord(value)
if (specification === null) continue
specifications[key] = {
kind: readAssetKind(specification['kind']),
extension: readHostSpecificString(specification['ext'])
}
}
for (const mode of [APP_MODE, WEB_MODE]) {
specifications[mode] ??= defaults.specifications[mode] ?? { kind: null, extension: null }
}
return { modes: modes.length > 0 ? modes : defaults.modes, specifications }
}
function readCatalog (record: JsonRecord): CatalogConfiguration {
const defaults = DEFAULT_STORE_CONFIGURATION.catalog
const statuses = readStringArray(record, 'statuses')
const ownerId = record['owner_id']
return {
// An explicit empty list means "every status", so only a missing key falls back.
statuses: record['statuses'] === undefined ? defaults.statuses : statuses,
ownerId: typeof ownerId === 'number' && Number.isFinite(ownerId) ? ownerId : null,
only: readStringArray(record, 'only'),
exclude: readStringArray(record, 'exclude')
}
}
function readPlatforms (record: JsonRecord | null): Readonly<Record<string, PlatformConfiguration>> {
if (record === null) return DEFAULT_STORE_CONFIGURATION.platforms
const platforms: Record<string, PlatformConfiguration> = {}
for (const [name, value] of Object.entries(record)) {
const entry = asRecord(value)
platforms[name] = { enabled: entry === null ? true : readBoolean(entry, 'enabled', true) }
}
return platforms
}
function readBehavior (record: JsonRecord): BehaviorConfiguration {
const defaults = DEFAULT_STORE_CONFIGURATION.behavior
return {
prune: readBoolean(record, 'prune', defaults.prune),
timeout: readNumber(record, 'timeout', defaults.timeout),
insecure: readBoolean(record, 'insecure', defaults.insecure)
}
}
/** `"linux_x64"`, `["win_x64", "win_x86"]`, or either of those keyed by host. */
function readAssetKind (value: unknown): HostSpecific<string | readonly string[]> | null {
if (typeof value === 'string') return value
if (Array.isArray(value)) return readStrings(value)
const record = asRecord(value)
if (record === null) return null
const map: Record<string, string | readonly string[]> = {}
for (const [key, entry] of Object.entries(record)) {
if (typeof entry === 'string') map[key] = entry
else if (Array.isArray(entry)) map[key] = readStrings(entry)
}
return map
}
function readHostSpecificString (value: unknown): HostSpecific<string> | null {
if (typeof value === 'string') return value
const record = asRecord(value)
if (record === null) return null
const map: Record<string, string> = {}
for (const [key, entry] of Object.entries(record)) {
if (typeof entry === 'string') map[key] = entry
}
return map
}
function readStrings (values: readonly unknown[]): readonly string[] {
return values.filter((item: unknown): item is string => typeof item === 'string')
}
@@ -0,0 +1,114 @@
import {
gameKey, recordScope, type InstalledRecord
} from '../../domain/models/InstalledRecord'
import {
asRecord, readOptionalString, readString, type JsonRecord
} from '../json/JsonRecord'
import type { StoreFileSystem } from '../files/StoreFileSystem'
/** Bumped when the on-disk shape changes. v1 keyed `installed` by bare software name. */
const STATE_VERSION = 2
/**
* `state.json`: what this store put on this machine, and where.
*
* The file is **snake_case**, and deliberately so: it is the same file the shell
* store engine wrote, so a machine that installed games through the CLI keeps its
* library when the client takes over. This class is the only place that knows the
* on-disk field names — everything above it sees `InstalledRecord`.
*
* A `version: 1` file, keyed by bare software name, is re-keyed on first read.
*/
export class StoreStateRepository {
public constructor (
private readonly files: StoreFileSystem,
private readonly statePath: string,
private readonly log: (line: string) => void
) {}
public readState (): Map<string, InstalledRecord> {
const state = asRecord(this.files.readJson(this.statePath))
if (state === null) return new Map<string, InstalledRecord>()
const installed = asRecord(state['installed'])
if (installed === null) return new Map<string, InstalledRecord>()
const version = state['version']
if (version === 1) return this.migrateFromVersionOne(installed)
if (version !== STATE_VERSION) return new Map<string, InstalledRecord>()
const records = new Map<string, InstalledRecord>()
for (const [key, value] of Object.entries(installed)) {
const record = asRecord(value)
if (record !== null) records.set(key, toRecord(record))
}
return records
}
public writeState (installed: ReadonlyMap<string, InstalledRecord>): void {
const serialised: Record<string, JsonRecord> = {}
for (const [key, record] of installed) serialised[key] = fromRecord(record)
this.files.writeJson(this.statePath, { version: STATE_VERSION, installed: serialised })
}
private migrateFromVersionOne (installed: JsonRecord): Map<string, InstalledRecord> {
const records = new Map<string, InstalledRecord>()
for (const value of Object.values(installed)) {
const raw = asRecord(value)
if (raw === null) continue
const record = toRecord(raw)
if (record.name.length > 0 && record.scope.length > 0) records.set(gameKey(record), record)
}
this.log(`migrated ${String(records.size)} state entries to the per-scope key format`)
return records
}
}
function toRecord (raw: JsonRecord): InstalledRecord {
return {
name: readString(raw, 'name'),
// `system` is what the Batocera store wrote before the shared core existed, and
// there the two were the same string — so an installed machine needs no migration.
scope: recordScope(readOptionalString(raw, 'scope'), readOptionalString(raw, 'system')),
platform: readString(raw, 'platform'),
kind: readString(raw, 'kind'),
version: readString(raw, 'version'),
asset: readString(raw, 'asset'),
assetPath: readString(raw, 'asset_path'),
title: readString(raw, 'title'),
description: readString(raw, 'desc'),
author: readString(raw, 'author'),
imageUrl: readOptionalString(raw, 'image_url'),
createdAt: readOptionalString(raw, 'created_at'),
mode: readString(raw, 'mode'),
payload: readOptionalString(raw, 'payload'),
executable: readOptionalString(raw, 'exe'),
executableKind: readOptionalString(raw, 'exe_kind'),
icon: readOptionalString(raw, 'icon'),
menuEntry: readOptionalString(raw, 'menu'),
url: readOptionalString(raw, 'url')
}
}
function fromRecord (record: InstalledRecord): JsonRecord {
return {
name: record.name,
scope: record.scope,
platform: record.platform,
kind: record.kind,
version: record.version,
asset: record.asset,
asset_path: record.assetPath,
title: record.title,
desc: record.description,
author: record.author,
image_url: record.imageUrl,
created_at: record.createdAt,
mode: record.mode,
payload: record.payload,
exe: record.executable,
exe_kind: record.executableKind,
icon: record.icon,
menu: record.menuEntry,
url: record.url
}
}
@@ -0,0 +1,56 @@
import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngineVersion'
/**
* How one WarpEngine version's catalog is shaped.
*
* The catalog is the one thing this client reads that it does not own the shape of, so
* it is the one thing an engine version can change under it. A dialect turns that
* foreign JSON into the typed records below, and everything downstream — the survey,
* the release choice — sees only those. When a future engine renames a field or nests a
* release differently, a new dialect is the whole change.
*/
export interface CatalogDialect {
readonly version: SupportedWarpEngineVersion
/** One entry per title in the catalog. */
listEntries: (catalog: unknown) => readonly CatalogEntry[]
}
export interface CatalogEntry {
readonly software: CatalogSoftware
/**
* The release the catalog itself calls newest-and-stable, or null when it names none.
*
* Kept separate from the candidates because it is also what an unavailable title's
* card shows a version from — and a catalog with releases but no `latestRelease` has
* nothing to show there.
*/
readonly latestRelease: CatalogRelease | null
/**
* Every release worth trying, newest first and deduplicated.
*
* `latestRelease` comes first where there is one, then the rest, so that a game whose
* newest build is missing an asset still installs from an older one.
*/
readonly releaseCandidates: readonly CatalogRelease[]
}
export interface CatalogSoftware {
readonly name: string
readonly title: string
readonly platform: string
readonly status: string
readonly description: string
readonly author: string
readonly imageUrl: string | null
}
export interface CatalogRelease {
readonly version: string
readonly createdAt: string | null
readonly assets: readonly CatalogAsset[]
}
export interface CatalogAsset {
readonly kind: string
readonly path: string
}
@@ -0,0 +1,21 @@
import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngineVersion'
import type { CatalogDialect } from './CatalogDialect'
import { SoftwareListCatalogDialect } from './SoftwareListCatalogDialect'
/**
* Which dialect reads a catalog served by which engine version.
*
* The switch is exhaustive over `SUPPORTED_WARP_ENGINE_VERSIONS`, which is the whole
* mechanism: adding a version to that list stops compiling here until somebody decides
* what it reads like. Three versions share one dialect today because the catalog's
* shape has not changed across them — and one class serving three versions is the
* honest way to say that, rather than three identical ones pretending otherwise.
*/
export function selectCatalogDialect (version: SupportedWarpEngineVersion): CatalogDialect {
switch (version) {
case '0.2':
case '0.3':
case '0.4':
return new SoftwareListCatalogDialect(version)
}
}
@@ -0,0 +1,112 @@
import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngineVersion'
import {
asRecord, readOptionalString, readRecord, readString, type JsonRecord
} from '../../json/JsonRecord'
import type {
CatalogAsset, CatalogDialect, CatalogEntry, CatalogRelease, CatalogSoftware
} from './CatalogDialect'
/**
* The catalog as every WarpEngine from 0.2 to 0.4 serves it.
*
* `{ softwares: [ { software: {…}, latestRelease: {…}, releases: [ { assets: [] } ] } ] }`,
* camelCase throughout. The three versions differ in what they *contain* — 0.3 added
* the `linux_arm64` asset kinds, so an older engine simply has fewer kinds to offer,
* and that needs no code: an absent kind is a title reported as unavailable on this
* machine, which is already the honest answer.
*
* The version is carried rather than assumed, so a log line can name which dialect read
* a catalog even while one class serves several.
*/
export class SoftwareListCatalogDialect implements CatalogDialect {
public constructor (public readonly version: SupportedWarpEngineVersion) {}
public listEntries (catalog: unknown): readonly CatalogEntry[] {
const record = asRecord(catalog)
if (record === null) return []
const entries = record['softwares']
if (!Array.isArray(entries)) return []
const found: CatalogEntry[] = []
for (const item of entries) {
const entry = asRecord(item)
if (entry === null) continue
const software = this.readSoftware(entry)
if (software === null) continue
found.push({
software,
latestRelease: this.readLatestRelease(entry),
releaseCandidates: this.readCandidates(entry)
})
}
return found
}
/** A title with no name is not a title: nothing could be keyed by it. */
private readSoftware (entry: JsonRecord): CatalogSoftware | null {
const software = readRecord(entry, 'software')
if (software === null) return null
const name = readOptionalString(software, 'name')
if (name === null) return null
return {
name,
title: readOptionalString(software, 'title') ?? name,
platform: readString(software, 'platform'),
status: readString(software, 'status'),
description: readString(software, 'desc').trim(),
author: readString(software, 'author').trim(),
imageUrl: readOptionalString(software, 'imageUrl')
}
}
private readLatestRelease (entry: JsonRecord): CatalogRelease | null {
const latest = readRecord(entry, 'latestRelease')
return latest === null ? null : this.readRelease(latest)
}
/**
* The releases to try, newest first and without repeats.
*
* `releases` arrives newest-first from the API and `latestRelease` is usually its
* first element, so identity is settled on the release's own id where it has one.
*/
private readCandidates (entry: JsonRecord): readonly CatalogRelease[] {
const records: JsonRecord[] = []
const latest = readRecord(entry, 'latestRelease')
if (latest !== null) records.push(latest)
const releases = entry['releases']
if (Array.isArray(releases)) {
for (const item of releases) {
const record = asRecord(item)
if (record !== null) records.push(record)
}
}
const seen = new Set<string>()
const candidates: CatalogRelease[] = []
for (const record of records) {
const identity = JSON.stringify(record['id'] ?? null)
if (seen.has(identity)) continue
seen.add(identity)
candidates.push(this.readRelease(record))
}
return candidates
}
private readRelease (release: JsonRecord): CatalogRelease {
const assets: CatalogAsset[] = []
const listed = release['assets']
if (Array.isArray(listed)) {
for (const item of listed) {
const asset = asRecord(item)
if (asset === null) continue
assets.push({ kind: readString(asset, 'kind'), path: readString(asset, 'path') })
}
}
return {
version: readString(release, 'version'),
createdAt: readOptionalString(release, 'createdAt'),
assets
}
}
}
@@ -0,0 +1,45 @@
import fs from 'node:fs'
import path from 'node:path'
import type { InstalledRecord } from '../../../domain/models/InstalledRecord'
import { WEB_MODE } from '../../../domain/models/StoreConfiguration'
import type { StoreFileSystem } from '../../files/StoreFileSystem'
import { quoteForShell } from './ShellQuoting'
const LAUNCHER_MODE = 0o755
const COMMENT_LIMIT = 120
/**
* Linux: an XDG desktop entry.
*
* `Path=` is what gives the game its working directory — a Godot or LÖVE build
* looks for its `.pck` next to the binary, and started from anywhere else it exits
* without a window and without a message.
*/
export class DesktopEntryWriter {
public constructor (
private readonly storeId: string,
private readonly files: StoreFileSystem
) {}
public write (record: InstalledRecord, entryPath: string, webUrl: string): string {
const lines: string[] = ['[Desktop Entry]', 'Type=Application', 'Version=1.0', `Name=${record.title}`]
if (record.description.length > 0) {
// One line only, and short: the menu shows it as a tooltip.
const firstLine = record.description.split('\n')[0] ?? ''
lines.push(`Comment=${firstLine.slice(0, COMMENT_LIMIT)}`)
}
if (record.mode === WEB_MODE) {
lines.push(`Exec=xdg-open ${quoteForShell(webUrl)}`)
} else if (record.executable !== null) {
lines.push(`Exec=${quoteForShell(record.executable)}`)
lines.push(`Path=${quoteForShell(path.dirname(record.executable))}`)
}
if (record.icon !== null) lines.push(`Icon=${record.icon}`)
lines.push('Terminal=false', 'Categories=Game;', `X-WarpStore=${this.storeId}`, '')
fs.mkdirSync(path.dirname(entryPath), { recursive: true })
this.files.writeAtomic(entryPath, Buffer.from(lines.join('\n'), 'utf8'), LAUNCHER_MODE)
return entryPath
}
}
@@ -0,0 +1,85 @@
import { spawnSync } from 'node:child_process'
import path from 'node:path'
import type { DesktopLayout } from '../../../domain/models/DesktopLayout'
import { toSafeFileName } from '../../../domain/models/DesktopLayout'
import type { InstalledRecord } from '../../../domain/models/InstalledRecord'
import type { SelectedGame } from '../../../domain/models/SelectedGame'
import type { StoreConfiguration } from '../../../domain/models/StoreConfiguration'
import type { StoreFileSystem } from '../../files/StoreFileSystem'
import type { DesktopLayoutResolver } from '../DesktopLayoutResolver'
import { DesktopEntryWriter } from './DesktopEntryWriter'
import { MacBundleWriter } from './MacBundleWriter'
import { WindowsShortcutWriter } from './WindowsShortcutWriter'
const REFRESH_TIMEOUT_MS = 30_000
/**
* The menu entry, in whichever form this machine's desktop understands.
*
* This is the whole point of a desktop store, and the one place where the three
* hosts genuinely differ rather than merely differing in paths.
*/
export class LauncherWriter {
private readonly desktopEntries: DesktopEntryWriter
private readonly macBundles: MacBundleWriter
private readonly windowsShortcuts: WindowsShortcutWriter
public constructor (
private readonly configuration: StoreConfiguration,
private readonly layouts: DesktopLayoutResolver,
files: StoreFileSystem,
private readonly log: (line: string) => void
) {
this.desktopEntries = new DesktopEntryWriter(configuration.store.id, files)
this.macBundles = new MacBundleWriter(configuration.store.id, files, log)
this.windowsShortcuts = new WindowsShortcutWriter(files, log)
}
/**
* The published page of a browser build.
*
* The catalog serves these as a directory rather than an archive, so the entry is
* a link to it — which also means a web title needs the network.
*/
public webUrl (game: SelectedGame | InstalledRecord): string {
const assetPath = game.assetPath.length > 0 ? game.assetPath : `/file/${game.asset}`
return `${this.configuration.store.baseUrl}/${assetPath.replace(/^\/+|\/+$/g, '')}/`
}
public launcherPath (layout: DesktopLayout, game: SelectedGame | InstalledRecord): string {
const group = this.layouts.menuGroup(layout)
if (layout.operatingSystem === 'linux') {
return path.join(group, `${this.configuration.store.id}-${game.name}.desktop`)
}
if (layout.operatingSystem === 'darwin') {
return path.join(group, `${toSafeFileName(game.title)}.app`)
}
return path.join(group, `${toSafeFileName(game.title)}.lnk`)
}
/**
* Write the menu entry. Returns the path actually written.
*
* Not always the path we intended: on Windows a `.lnk` can fall back to a `.cmd`,
* and the state has to record what really exists or an uninstall would leave it
* behind.
*/
public writeLauncher (layout: DesktopLayout, record: InstalledRecord): string {
const entryPath = this.launcherPath(layout, record)
const url = this.webUrl(record)
if (layout.operatingSystem === 'linux') return this.desktopEntries.write(record, entryPath, url)
if (layout.operatingSystem === 'darwin') return this.macBundles.write(record, entryPath, url)
return this.windowsShortcuts.write(record, entryPath, url)
}
/** Ask the desktop to notice the change, where that is a thing we can do. */
public refreshMenu (layout: DesktopLayout): void {
if (layout.operatingSystem !== 'linux') return
try {
spawnSync('update-desktop-database', [layout.menuDirectory], { timeout: REFRESH_TIMEOUT_MS })
} catch {
// Not every Linux has it, and a menu that updates on next login is fine.
this.log('update-desktop-database is not available — the menu may need a re-login')
}
}
}
@@ -0,0 +1,148 @@
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import type { InstalledRecord } from '../../../domain/models/InstalledRecord'
import { WEB_MODE } from '../../../domain/models/StoreConfiguration'
import type { StoreFileSystem } from '../../files/StoreFileSystem'
import { escapeForXml, quoteForShell } from './ShellQuoting'
const RUNNER_MODE = 0o755
const PLIST_MODE = 0o644
const ICON_SIZE = '512'
const SIPS_TIMEOUT_MS = 60_000
const NAME_LIMIT = 255
/**
* macOS: link the archive's own bundle, or wrap a bare binary in one.
*
* A LÖVE or Godot build already ships a signed `.app`; copying it again would double
* the disk use and lose nothing but the icon, so it is symlinked instead. A TIC-80
* export is a bare executable, and for that a four-file bundle is what makes it
* double-clickable and Dock-able.
*/
export class MacBundleWriter {
public constructor (
private readonly storeId: string,
private readonly files: StoreFileSystem,
private readonly log: (line: string) => void
) {}
public write (record: InstalledRecord, bundlePath: string, webUrl: string): string {
replaceExisting(bundlePath)
if (record.mode !== WEB_MODE && record.executableKind === 'bundle' && record.executable !== null) {
fs.mkdirSync(path.dirname(bundlePath), { recursive: true })
fs.symlinkSync(record.executable, bundlePath)
return bundlePath
}
const contents = path.join(bundlePath, 'Contents')
const macOsDirectory = path.join(contents, 'MacOS')
const resources = path.join(contents, 'Resources')
fs.mkdirSync(macOsDirectory, { recursive: true })
fs.mkdirSync(resources, { recursive: true })
this.files.writeAtomic(
path.join(macOsDirectory, 'run'),
Buffer.from(this.runnerScript(record, webUrl), 'utf8'),
RUNNER_MODE
)
const iconWritten = this.writeIcon(record.icon, path.join(resources, 'icon.icns'))
this.files.writeAtomic(
path.join(contents, 'Info.plist'),
Buffer.from(this.infoPlist(record, iconWritten), 'utf8'),
PLIST_MODE
)
return bundlePath
}
private runnerScript (record: InstalledRecord, webUrl: string): string {
if (record.mode === WEB_MODE || record.executable === null) {
return `#!/bin/sh\nexec open ${quoteForShell(webUrl)}\n`
}
const directory = path.dirname(record.executable)
const program = `./${path.basename(record.executable)}`
return `#!/bin/sh\ncd ${quoteForShell(directory)} || exit 1\nexec ${quoteForShell(program)} "$@"\n`
}
/**
* The bundle's `Info.plist`.
*
* Keys are emitted in alphabetical order because that is what wrote these files
* before — `plistlib` sorts a dict — and a plist dict is unordered, so sorting costs
* nothing and makes a bundle regenerated by either engine the same file.
*/
private infoPlist (record: InstalledRecord, iconWritten: boolean): string {
const version = record.version.length > 0 ? record.version : '1.0'
const name = record.title.slice(0, NAME_LIMIT)
const entries: readonly (readonly [string, string])[] = [
['CFBundleName', `<string>${escapeForXml(name)}</string>`],
['CFBundleDisplayName', `<string>${escapeForXml(name)}</string>`],
['CFBundleExecutable', '<string>run</string>'],
['CFBundleIdentifier', `<string>org.${this.storeId}.store.${record.name}</string>`],
['CFBundleInfoDictionaryVersion', '<string>6.0</string>'],
['CFBundlePackageType', '<string>APPL</string>'],
['CFBundleShortVersionString', `<string>${escapeForXml(version)}</string>`],
['CFBundleVersion', `<string>${escapeForXml(version)}</string>`],
['NSHighResolutionCapable', '<true/>'],
...(iconWritten ? [['CFBundleIconFile', '<string>icon</string>'] as const] : [])
]
const body = [...entries]
.sort((left: readonly [string, string], right: readonly [string, string]): number =>
left[0] < right[0] ? -1 : 1)
.map(([key, value]: readonly [string, string]): string => `\t<key>${key}</key>\n\t${value}`)
.join('\n')
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
'<plist version="1.0">',
'<dict>',
body,
'</dict>',
'</plist>',
''
].join('\n')
}
/**
* Box art → `.icns` with `sips`, which needs a square source first.
*
* Without this a generated bundle gets the generic application icon. `sips` is part
* of macOS, so there is nothing to install; if it fails we simply go without.
*/
private writeIcon (iconPath: string | null, destination: string): boolean {
if (iconPath === null || !fs.existsSync(iconPath)) return false
const square = `${destination}.square.png`
try {
const steps: readonly (readonly string[])[] = [
['-z', ICON_SIZE, ICON_SIZE, iconPath, '--out', square],
['-s', 'format', 'icns', square, '--out', destination]
]
for (const step of steps) {
const result = spawnSync('sips', [...step], { timeout: SIPS_TIMEOUT_MS })
if (result.status !== 0) {
this.log('sips could not convert the box art — the bundle gets the generic icon')
return false
}
}
return fs.existsSync(destination)
} catch {
return false
} finally {
fs.rmSync(square, { force: true })
}
}
}
/** A bundle is a directory, so replacing one is not a plain overwrite. */
function replaceExisting (bundlePath: string): void {
let stats: fs.Stats | null = null
try {
stats = fs.lstatSync(bundlePath)
} catch {
return
}
if (stats.isDirectory() && !stats.isSymbolicLink()) fs.rmSync(bundlePath, { recursive: true, force: true })
else fs.rmSync(bundlePath, { force: true })
}
@@ -0,0 +1,26 @@
/**
* Quoting for the two shells a desktop launcher goes through.
*
* A game title is user data that ends up inside an `Exec=` line and a `/bin/sh`
* script, and titles contain apostrophes. These are the same rules Python's
* `shlex.quote` and a PowerShell single-quoted string follow.
*/
const SAFE_UNQUOTED = /^[A-Za-z0-9_@%+=:,./-]+$/
export function quoteForShell (value: string): string {
if (value.length === 0) return "''"
if (SAFE_UNQUOTED.test(value)) return value
return `'${value.replace(/'/g, "'\"'\"'")}'`
}
export function quoteForPowerShell (value: string): string {
return `'${value.replace(/'/g, "''")}'`
}
export function escapeForXml (value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
@@ -0,0 +1,78 @@
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import type { InstalledRecord } from '../../../domain/models/InstalledRecord'
import { WEB_MODE } from '../../../domain/models/StoreConfiguration'
import type { StoreFileSystem } from '../../files/StoreFileSystem'
import { quoteForPowerShell } from './ShellQuoting'
const POWERSHELL_TIMEOUT_MS = 60_000
const DESCRIPTION_LIMIT = 250
/**
* Windows: a real `.lnk` through PowerShell, or a `.cmd` if that is missing.
*
* A `.lnk` is the only artifact that carries a working directory *and* shows up the
* way users expect, but it is a binary format with no writer in the standard library
* of any language here — PowerShell's `WScript.Shell` is the one tool every Windows
* has. When even that is unavailable a `.cmd` still appears in the Start menu, which
* is worth more than a correct file nobody can see.
*/
export class WindowsShortcutWriter {
public constructor (
private readonly files: StoreFileSystem,
private readonly log: (line: string) => void
) {}
public write (record: InstalledRecord, shortcutPath: string, webUrl: string): string {
const target = record.mode === WEB_MODE ? webUrl : record.executable ?? ''
const workingDirectory = record.mode === WEB_MODE || record.executable === null
? ''
: path.dirname(record.executable)
fs.mkdirSync(path.dirname(shortcutPath), { recursive: true })
if (this.writeShortcut(record, shortcutPath, target, workingDirectory)) return shortcutPath
return this.writeCommandFile(record, shortcutPath, target, workingDirectory)
}
private writeShortcut (
record: InstalledRecord,
shortcutPath: string,
target: string,
workingDirectory: string
): boolean {
const script = [
`$s = (New-Object -ComObject WScript.Shell).CreateShortcut(${quoteForPowerShell(shortcutPath)});`,
`$s.TargetPath = ${quoteForPowerShell(target)};`,
workingDirectory.length > 0 ? `$s.WorkingDirectory = ${quoteForPowerShell(workingDirectory)};` : '',
`$s.Description = ${quoteForPowerShell(record.title.slice(0, DESCRIPTION_LIMIT))};`,
'$s.Save()'
].join('')
try {
const result = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
timeout: POWERSHELL_TIMEOUT_MS
})
if (result.status === 0 && fs.existsSync(shortcutPath)) return true
this.log(`powershell could not write ${shortcutPath}`)
} catch {
this.log('powershell is not available — falling back to a .cmd launcher')
}
return false
}
private writeCommandFile (
record: InstalledRecord,
shortcutPath: string,
target: string,
workingDirectory: string
): string {
const commandPath = `${shortcutPath.slice(0, shortcutPath.length - path.extname(shortcutPath).length)}.cmd`
const body = record.mode === WEB_MODE || workingDirectory.length === 0
? `@echo off\r\nstart "" "${target}"\r\n`
: `@echo off\r\ncd /d "${workingDirectory}"\r\nstart "" "${target}"\r\n`
this.files.writeAtomic(commandPath, Buffer.from(body, 'utf8'))
this.log(`wrote a .cmd launcher instead of a .lnk: ${commandPath}`)
return commandPath
}
}