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,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
}
}