Registry-only stores, a quieter window, and a CI that builds
ci/woodpecker/manual/woodpecker Pipeline was successful

A store no longer needs a repository of its own. The engine's built-in defaults
already cover the host-to-asset mapping, the install modes, the platforms and the
behaviour; what they cannot know is identity — a slug, a name and a catalog URL — and
that is exactly what a registry record carries. So `storeRepositoryUrl` is optional: a
record with a name and a catalog is a complete store, the id falls back from the
repository name to the catalog host (`teletypegames.org` becomes `teletypegames`) to the
display name, and the client writes a three-section config. Given a repository it still
reads it, and that file stays the authority on how the store behaves; a repository
without a config.json is treated as no repository at all.

Measured end to end against a local registry serving one record with a null repository:
the engine and the core downloaded, engine 1.1.0 accepted the written config, it listed
the same ten titles the configured store does, and a hosted title synced into a sandbox
with its menu entry written.

The "Install all" button is gone, and with it the string it used. Titles are installed
one at a time from their own cards.

No footer. The window carried a bar at the bottom at all times — a toggle and a line of
absolute paths — for something most sessions never need. The log is still there, folder
buttons included, behind a quiet switch at the bottom of the side menu; it takes no room
until it is opened, and an arriving line does not open it, because the store logs on
every refresh and a window that unfolds panels by itself is worse than one that keeps
quiet.

Three faults that every automated count had passed, found by photographing the setup
screen: the store badge rendered as an empty pill with no store open; the gate's picker
showed as an empty dropdown stub, because an explicit `display` beats the browser's own
`[hidden]` rule; and the gate went up while the empty-catalog line stayed on screen
underneath it. The last was a design fault — whether the gate is up was a call on a
view rather than state, so the two could disagree. The setup screen is now a field in
the state store, and that one field decides which of the gate and the grid is drawn.
The window test's gate assertion was wrong too: it demanded a store picker, which only
appears when the registry offers more than one store, so one store — the ordinary case —
failed it.

CI builds the packages this machine cannot. `.woodpecker.yaml` runs the checks on every
push and, on a tag or by hand, builds the Linux packages in
`electronuserland/builder:22` and the Windows ones in `:22-wine`, then attaches them to
the release with scripts/ci-upload.sh. The pipeline lives here rather than in the update
server's `/build/config` extension, which serves game-platform pipelines publishing into
the site's catalog — a different product with a different target. macOS stays a local
build: Apple's toolchain and its signing exist only on a Mac.

Both build steps verify what they produced, because a half-finished Wine build leaves a
162 KB stub named like the real installer and `ls` is happy with it. The Linux step was
rehearsed locally in the same image (AppImage 128 MB, deb 100 MB); the Wine step cannot
be rehearsed on Apple Silicon, where 16 KB host pages break Wine's 4 KB assumption, so
the runner is where it is proven. The size check was tested against both outcomes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 16:52:59 +02:00
co-authored by Claude Opus 5
parent 3d63c8a0b0
commit 526c67b069
29 changed files with 581 additions and 184 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ import https from 'node:https'
const REQUEST_TIMEOUT_MS = 60_000
const MAX_REDIRECTS = 5
const USER_AGENT = 'warp-engine-desktop-gui'
const USER_AGENT = 'warp-engine-client'
/** A response that arrived but said no. The status matters: 404 is not a failure everywhere. */
export class HttpStatusError extends Error {
@@ -76,30 +76,26 @@ export class HttpStoreEngineInstaller implements StoreEngineInstaller {
/**
* The store's configuration.
*
* Its repository is the authority on how the store behaves — which platforms,
* which statuses, where things land. A repository without a config.json still
* works: the engine merges whatever it is given onto its own defaults, so a
* three-field config is a complete one. The registry wins on identity and on
* which catalog to read.
* Three cases, and all of them install:
*
* - **a repository with a config.json** — that file is the authority on how the
* store behaves: which platforms it offers, which statuses it shows, where
* things land;
* - **a repository without one** (404) — the engine's defaults, as below;
* - **no repository at all** — the same defaults, without the round trip.
*
* The engine's built-in defaults already cover the host-to-asset mapping, the
* modes, the platforms and the behaviour, so what a store actually has to supply
* is identity: a slug, a name and a catalog. That is exactly what a registry
* record carries, which is why a store needs no repository of its own. The
* registry always wins on those three, whatever a config file says.
*/
private async readStoreConfig (
store: RegistryStore,
progress: EngineProgressListener
): Promise<Record<string, unknown>> {
const storeId = deriveStoreId(store)
let config: Record<string, unknown>
try {
progress.onLog?.(`reading the store config from ${store.storeRepositoryUrl}`)
const body = await this.httpClient.readText(this.configUrl(store.storeRepositoryUrl))
config = { ...(asRecord(JSON.parse(body)) ?? {}) }
} catch (error: unknown) {
if (!(error instanceof HttpStatusError) || error.statusCode !== 404) throw error
progress.onLog?.('no config.json in the store repository — using the engine defaults')
config = {
paths: { subfolder: storeId },
catalog: { statuses: ['released', 'archived', 'demo'] }
}
}
const config = await this.readPublishedConfig(store, storeId, progress)
const existing = asRecord(config['store']) ?? {}
config['store'] = {
@@ -111,6 +107,43 @@ export class HttpStoreEngineInstaller implements StoreEngineInstaller {
return config
}
private async readPublishedConfig (
store: RegistryStore,
storeId: string,
progress: EngineProgressListener
): Promise<Record<string, unknown>> {
const repositoryUrl = store.storeRepositoryUrl
if (repositoryUrl === null) {
progress.onLog?.(`${store.name} has no store repository — using the engine defaults`)
return this.defaultConfig(storeId)
}
try {
progress.onLog?.(`reading the store config from ${repositoryUrl}`)
const body = await this.httpClient.readText(this.configUrl(repositoryUrl))
return { ...(asRecord(JSON.parse(body)) ?? {}) }
} catch (error: unknown) {
if (!(error instanceof HttpStatusError) || error.statusCode !== 404) throw error
progress.onLog?.('no config.json in the store repository — using the engine defaults')
return this.defaultConfig(storeId)
}
}
/**
* What a store gets when nothing else says otherwise.
*
* Two fields, on top of the identity added by the caller. The subfolder keeps two
* stores on one machine out of each other's files, and it is the prune boundary,
* so it must be the store's own. Demo titles are listed because a catalog that
* publishes them means them to be played — the engine defaults to released and
* archived only, which is the safer default for a store nobody configured.
*/
private defaultConfig (storeId: string): Record<string, unknown> {
return {
paths: { subfolder: storeId },
catalog: { statuses: ['released', 'archived', 'demo'] }
}
}
private configUrl (repositoryUrl: string, branch: string = DEFAULT_BRANCH): string {
return `${repositoryUrl.replace(/\/+$/, '')}/raw/branch/${branch}/${CONFIG_FILE_NAME}`
}
@@ -10,8 +10,12 @@ const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores'
* The registry: `GET /api/stores` on the site.
*
* The one address this client knows, and even that is overridable — `STORES_API`
* points it at another site or at a local endpoint. Records missing any of the
* three fields are dropped rather than half-used.
* points it at another site or at a local endpoint.
*
* A record needs a name and a catalog URL; those two make a store. The repository
* is optional and arrives as null when absent — a store configured by nothing but
* this record installs on the engine's defaults. Records missing either of the two
* required fields are dropped rather than half-used.
*/
export class HttpStoreRegistryRepository implements StoreRegistryRepository {
public readonly sourceUrl: string
@@ -32,16 +36,19 @@ export class HttpStoreRegistryRepository implements StoreRegistryRepository {
return parsed
.map((row: unknown): JsonRecord | null => asRecord(row))
.filter((row: JsonRecord | null): row is JsonRecord => row !== null)
.map((row: JsonRecord): RegistryStore => ({
name: readString(row, 'name').trim(),
.map((row: JsonRecord): RegistryStore => {
// Both spellings, because a registry is someone else's API: ours answers
// camelCase, and a hand-rolled one may not.
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim(),
storeRepositoryUrl: (
const repository = (
readString(row, 'storeRepositoryUrl') || readString(row, 'store_repository_url')
).trim()
}))
return {
name: readString(row, 'name').trim(),
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim(),
storeRepositoryUrl: repository.length > 0 ? repository : null
}
})
.filter((store: RegistryStore): boolean =>
store.name.length > 0 && store.catalogUrl.length > 0 && store.storeRepositoryUrl.length > 0)
store.name.length > 0 && store.catalogUrl.length > 0)
}
}