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>
192 lines
7.3 KiB
TypeScript
192 lines
7.3 KiB
TypeScript
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)
|
|
}
|