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

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

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

Three things worth knowing about the new code:

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

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

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

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

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

110 lines
4.7 KiB
TypeScript

/**
* Which WarpEngine a catalog is served by, and whether this client knows it.
*
* Every WarpEngine API response carries the engine's version in a header, set before
* the action runs so that even an error response has it. That is what lets a client
* branch on the engine's age without a round trip to ask — and this file is where the
* branching starts.
*/
/** `WarpEngine::VERSION_HEADER` on the server side. */
export const WARP_ENGINE_VERSION_HEADER = 'warpengine-version'
/**
* The engine versions this client is written against, oldest first.
*
* Minor precision, because that is the granularity the engine changes its API at: a
* patch release fixes something behind the same shapes. Adding an entry here is a
* compile error until `selectCatalogDialect` says which dialect it gets, which is the
* point — a new engine version should not be able to arrive silently.
*/
export const SUPPORTED_WARP_ENGINE_VERSIONS = ['0.2', '0.3', '0.4'] as const
export type SupportedWarpEngineVersion = typeof SUPPORTED_WARP_ENGINE_VERSIONS[number]
/**
* Why the version this client will use is not simply the one the server named.
*
* - `exact` — the header named a version in the supported list;
* - `absent` — no header at all. An engine older than 0.4.0 does not send one, so
* this means "old", not "broken", and the oldest dialect is the honest
* reading of it;
* - `older` — a version below everything here: same treatment, but it said so;
* - `newer` — a version above everything here. The newest dialect is tried anyway,
* because listing nothing is worse than listing what still parses, but
* this is the case worth putting in the log.
*/
export type WarpEngineVersionMatch = 'exact' | 'absent' | 'older' | 'newer'
export interface WarpEngineVersion {
/** As the header spelled it, or null when there was none. */
readonly text: string | null
/** The supported version whose dialect will be used. Never null: one always applies. */
readonly resolved: SupportedWarpEngineVersion
readonly match: WarpEngineVersionMatch
readonly supported: boolean
}
const OLDEST: SupportedWarpEngineVersion = SUPPORTED_WARP_ENGINE_VERSIONS[0]
const NEWEST: SupportedWarpEngineVersion =
SUPPORTED_WARP_ENGINE_VERSIONS[SUPPORTED_WARP_ENGINE_VERSIONS.length - 1] ?? OLDEST
/**
* Read the header into a decision.
*
* An unparseable value is treated as an absent one: a header that does not look like a
* version tells us nothing about the engine, and guessing from a malformed string is
* worse than admitting we do not know.
*/
export function readWarpEngineVersion (headerValue: string | null): WarpEngineVersion {
if (headerValue === null || headerValue.trim().length === 0) {
return { text: null, resolved: OLDEST, match: 'absent', supported: false }
}
const text = headerValue.trim()
const numbers = parseVersion(text)
if (numbers === null) {
return { text, resolved: OLDEST, match: 'absent', supported: false }
}
const key = `${String(numbers[0])}.${String(numbers[1])}`
const exact = SUPPORTED_WARP_ENGINE_VERSIONS
.find((candidate: SupportedWarpEngineVersion): boolean => candidate === key)
if (exact !== undefined) {
return { text, resolved: exact, match: 'exact', supported: true }
}
const newer = compareVersions(numbers, parseVersion(NEWEST) ?? [0, 0]) > 0
return newer
? { text, resolved: NEWEST, match: 'newer', supported: false }
: { text, resolved: OLDEST, match: 'older', supported: false }
}
/** One sentence for the log, which is where an unsupported engine has to show up. */
export function describeWarpEngineVersion (version: WarpEngineVersion): string {
const supported = SUPPORTED_WARP_ENGINE_VERSIONS.join(', ')
switch (version.match) {
case 'exact':
return `WarpEngine ${version.text ?? ''}`
case 'absent':
return 'the catalog sent no WarpEngine-Version header — reading it as ' +
`${OLDEST}, which is what an engine older than 0.4.0 is`
case 'older':
return `WarpEngine ${version.text ?? ''} is older than anything this client knows ` +
`(${supported}) — reading it as ${OLDEST}`
case 'newer':
return `WarpEngine ${version.text ?? ''} is newer than this client knows ` +
`(${supported}) — reading it as ${NEWEST}, so some titles may be missed`
}
}
function parseVersion (text: string): readonly [number, number] | null {
const match = /(\d+)\.(\d+)/.exec(text)
if (match === null) return null
return [Number(match[1]), Number(match[2])]
}
function compareVersions (left: readonly [number, number], right: readonly [number, number]): number {
if (left[0] !== right[0]) return left[0] - right[0]
return left[1] - right[1]
}