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
@@ -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))
}