WarpEngine Client: the whole catalog, and a build that can point elsewhere
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful

**The app is called WarpEngine Client.** "Store" named the thing it opens rather than the
thing you run, and the store is a catalog on a site, not a window on your machine. The
window title, the bundle, the packages and the menu entry follow; the repository already
did. The store being driven is named in the side menu, so the bar stopped repeating it as
a badge — the element stays in the page, hidden, because the window check reads it.

**Every title is listed, including the ones this machine cannot install.** They arrive
from the engine with `installable: false` and a reason, and they are drawn dimmed, with an
*unsupported platform* or *no build for this machine* badge, the engine's own sentence
underneath, and nothing to press: a disabled Install would invite a click that can never
work. They get a category of their own — *Not for this machine* — and they are kept out of
the native/hosted categories and counts, because a title with no build has no mode to be
counted under. An engine older than desktop 1.2.0 is unaffected: a missing `installable`
field reads as installable, which is what those engines mean.

**A build can be pointed at another site's registry:**

    make dist STORES_API=https://games.example.org/api/stores

BuildConfiguration reads the packaged package.json, where electron-builder's
extraMetadata writes that address, so a client for somebody else's catalog needs no source
change and nothing set on the user's machine. Precedence is runtime environment, then
build, then ours — three audiences, most specific first.

Also: the scrollbars are the window's own, because the platform's light track down the
side menu of a dark window looked like a mistake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 19:45:54 +02:00
co-authored by Claude Opus 5
parent a364a5ce5f
commit 8511ccbef8
23 changed files with 292 additions and 118 deletions
@@ -0,0 +1,49 @@
import fs from 'node:fs'
import path from 'node:path'
import { asRecord, readRecord, readString } from '../json/JsonRecord'
/**
* What was decided when this package was built.
*
* The registry address is the one thing about a particular site left in the client, and
* a build for a different site should not need a different source tree. So it is a field
* in `package.json`, which `electron-builder` can overwrite at packaging time:
*
* make dist STORES_API=https://staging.example.org/api/stores
*
* Read from the package.json that ships inside the app, so a packaged build answers with
* what it was built with. A runtime `STORES_API` still wins over it — that is for trying
* something out, this is for shipping it.
*/
export class BuildConfiguration {
private cached: Readonly<Record<string, unknown>> | null = null
public readRegistryUrl (): string | null {
const section = readRecord(this.read(), 'warpEngine')
if (section === null) return null
const url = readString(section, 'registryUrl').trim()
return url.length > 0 ? url : null
}
private read (): Readonly<Record<string, unknown>> {
if (this.cached !== null) return this.cached
// build/infrastructure/config → the package root, packaged or not.
const candidates = [
path.join(__dirname, '..', '..', '..', 'package.json'),
path.join(__dirname, '..', '..', 'package.json')
]
for (const candidate of candidates) {
try {
const parsed = asRecord(JSON.parse(fs.readFileSync(candidate, 'utf8')))
if (parsed !== null) {
this.cached = parsed
return parsed
}
} catch {
// Try the next one; a missing package.json is only fatal if none is found.
}
}
this.cached = {}
return this.cached
}
}
+18 -2
View File
@@ -1,4 +1,4 @@
import type { Game, GameMode } from '../../domain/models/Game'
import type { Game, GameMode, UnavailableReason } from '../../domain/models/Game'
import {
readBoolean, readOptionalString, readString, type JsonRecord
} from '../json/JsonRecord'
@@ -26,11 +26,27 @@ export class EngineGameMapper {
installedVersion: readOptionalString(record, 'installed_version'),
menuEntryPath: readOptionalString(record, 'menu_entry'),
executablePath: readOptionalString(record, 'exe'),
hostedUrl: readOptionalString(record, 'url')
hostedUrl: readOptionalString(record, 'url'),
// Absent means installable: engines older than 1.2.0 list only what they can
// install, and treating their silence as "unavailable" would empty the window.
installable: readBoolean(record, 'installable', true),
unavailableReason: this.toReason(readOptionalString(record, 'unavailable_reason')),
unavailableDetail: readOptionalString(record, 'unavailable_detail')
}
}
private toMode (value: string): GameMode {
return value === 'web' ? 'web' : 'app'
}
/** The engine's snake_case codes, which are its wire format and not ours. */
private toReason (value: string | null): UnavailableReason | null {
const codes: Readonly<Record<string, UnavailableReason>> = {
platform_off: 'platformOff',
host_asset: 'hostAsset',
no_asset: 'noAsset',
vetoed: 'vetoed'
}
return value === null ? null : codes[value] ?? null
}
}
@@ -2,6 +2,7 @@ import { RegistryUnavailableError } from '../../domain/errors/RegistryUnavailabl
import type { RegistryStore } from '../../domain/models/RegistryStore'
import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository'
import { asRecord, readString, type JsonRecord } from '../json/JsonRecord'
import { BuildConfiguration } from '../config/BuildConfiguration'
import type { HttpTextClient } from '../http/HttpTextClient'
const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores'
@@ -9,8 +10,10 @@ 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.
* The one address this client knows, and it is decided in three places, most specific
* first: a runtime `STORES_API` (for trying something out), the `warpEngine.registryUrl`
* field a build was packaged with (for shipping a client for another site), and finally
* the address of ours.
*
* 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
@@ -20,11 +23,16 @@ const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores'
export class HttpStoreRegistryRepository implements StoreRegistryRepository {
public readonly sourceUrl: string
public constructor (private readonly httpClient: HttpTextClient, sourceUrl?: string) {
const configured = process.env['STORES_API']
this.sourceUrl = sourceUrl ?? (configured !== undefined && configured.length > 0
? configured
: DEFAULT_REGISTRY_URL)
public constructor (
private readonly httpClient: HttpTextClient,
sourceUrl?: string,
buildConfiguration: BuildConfiguration = new BuildConfiguration()
) {
const fromEnvironment = process.env['STORES_API']
this.sourceUrl = sourceUrl
?? (fromEnvironment !== undefined && fromEnvironment.length > 0 ? fromEnvironment : null)
?? buildConfiguration.readRegistryUrl()
?? DEFAULT_REGISTRY_URL
}
public async listStores (): Promise<readonly RegistryStore[]> {