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>
115 lines
4.2 KiB
TypeScript
115 lines
4.2 KiB
TypeScript
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
|
|
}
|
|
}
|