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
+4 -1
View File
@@ -26,7 +26,10 @@ export class GameDtoMapper {
installed: game.installed,
updateAvailable: game.updateAvailable,
installedVersion: game.installedVersion,
launchable: this.isLaunchable(game)
launchable: this.isLaunchable(game),
installable: game.installable,
unavailableReason: game.unavailableReason,
unavailableDetail: game.unavailableDetail
}
}
+17
View File
@@ -1,6 +1,15 @@
/** How a title runs: unpacked on this machine, or served as a web build. */
export type GameMode = 'app' | 'web'
/**
* Why a title cannot be installed here, as the engine codes it.
*
* `platformOff` is the store not carrying that platform at all — a C64 cartridge on a
* desktop — and the other three are about this machine or this catalog: no asset kind
* for the os and architecture, no release carrying it, or the adapter refusing it.
*/
export type UnavailableReason = 'platformOff' | 'hostAsset' | 'noAsset' | 'vetoed'
/**
* A catalog entry, with what the store did about it on this machine.
*
@@ -24,4 +33,12 @@ export interface Game {
readonly menuEntryPath: string | null
readonly executablePath: string | null
readonly hostedUrl: string | null
/**
* False for a title this machine cannot install. It is still listed: a catalog that
* hides what your machine cannot run leaves you wondering which of the two is small.
*/
readonly installable: boolean
readonly unavailableReason: UnavailableReason | null
/** The engine's sentence for it, for a tooltip or the log. */
readonly unavailableDetail: string | null
}
@@ -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[]> {
+1 -1
View File
@@ -6,7 +6,7 @@ import { SelfTestRunner } from './diagnostics/SelfTestRunner'
const SELFTEST_FLAG = '--selftest'
const SELFTEST_USER_DATA_DIRECTORY = 'warpstore-gui-selftest'
const PRODUCT_NAME = 'WarpEngine Store'
const PRODUCT_NAME = 'WarpEngine Client'
/**
* The application's lifecycle.
+1 -1
View File
@@ -7,7 +7,7 @@ const WINDOW_OPTIONS: BrowserWindowConstructorOptions = {
minWidth: 760,
minHeight: 520,
backgroundColor: '#11151c',
title: 'WarpEngine Store'
title: 'WarpEngine Client'
}
/**
+5 -3
View File
@@ -6,7 +6,7 @@
runs: the app ships its own script and stylesheet. -->
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' https: data:; font-src 'self'; connect-src 'none'">
<title>WarpEngine Store</title>
<title>WarpEngine Client</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
@@ -16,8 +16,10 @@
</button>
<div class="bar-title">
<span class="logo" aria-hidden="true"></span>
<span id="app-name">WarpEngine Store</span>
<span class="store-id" id="store-id"></span>
<span id="app-name">WarpEngine Client</span>
<!-- Kept for the window check, which reads it to see which store is open; the
side menu is where a person reads that. -->
<span class="store-id" id="store-id" hidden></span>
</div>
<div class="bar-actions">
<span class="progress" id="progress" hidden></span>
+11 -5
View File
@@ -30,7 +30,9 @@ export function matchesFilter (game: GameDto, filter: CategoryFilter): boolean {
case 'platform':
return game.platform === filter.value
case 'mode':
return game.mode === filter.value
// A title this machine cannot install has no mode worth filtering on: the engine
// sends none, and counting it as native would put a C64 cartridge under "native".
return game.installable && game.mode === filter.value
case 'group':
return matchesGroup(game, filter.value)
}
@@ -43,7 +45,9 @@ function matchesGroup (game: GameDto, group: string): boolean {
case 'updates':
return game.updateAvailable
case 'available':
return !game.installed
return game.installable && !game.installed
case 'unsupported':
return !game.installable
default:
return true
}
@@ -68,7 +72,8 @@ export function buildCategorySections (
{ kind: 'group', value: 'all', label: messages.catAll, count: games.length },
{ kind: 'group', value: 'installed', label: messages.catInstalled, count: count((game: GameDto): boolean => game.installed) },
{ kind: 'group', value: 'updates', label: messages.catUpdates, count: count((game: GameDto): boolean => game.updateAvailable) },
{ kind: 'group', value: 'available', label: messages.catAvailable, count: count((game: GameDto): boolean => !game.installed) }
{ kind: 'group', value: 'available', label: messages.catAvailable, count: count((game: GameDto): boolean => game.installable && !game.installed) },
{ kind: 'group', value: 'unsupported', label: messages.catUnsupported, count: count((game: GameDto): boolean => !game.installable) }
]
sections.push({
title: null,
@@ -90,7 +95,8 @@ export function buildCategorySections (
})
}
const modes = [...new Set(games.map((game: GameDto): string => game.mode))]
const installable = games.filter((game: GameDto): boolean => game.installable)
const modes = [...new Set(installable.map((game: GameDto): string => game.mode))]
if (modes.length > 1) {
sections.push({
title: messages.catMode,
@@ -98,7 +104,7 @@ export function buildCategorySections (
kind: 'mode',
value: mode,
label: mode === 'web' ? messages.hosted : messages.native,
count: count((game: GameDto): boolean => game.mode === mode)
count: count((game: GameDto): boolean => game.installable && game.mode === mode)
}))
})
}
+22
View File
@@ -14,6 +14,22 @@
* { box-sizing: border-box; }
/* The scrollbars are part of the theme too: the platform's own are light, and a white
track down the side menu of a dark window looks like a mistake. */
* {
scrollbar-width: thin;
scrollbar-color: #33414f transparent;
}
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: #33414f;
border: 3px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover { background: #46586b; background-clip: content-box; }
/* An explicit `display` beats the browser's own [hidden] rule, and most of the
regions here have one — the gate's store picker showed as an empty stub because
of exactly that. This makes `hidden` mean hidden everywhere. */
@@ -248,6 +264,12 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
flex-direction: column;
}
.card.is-installed { border-color: #2f5a49; }
/* Listed, but not for this machine: dimmed rather than hidden, and it says why. */
.card.is-unavailable { opacity: .55; }
.card.is-unavailable:hover { opacity: .8; }
.card.is-unavailable .art { filter: grayscale(1); }
.badge-unavailable { color: var(--ink-dim); border-color: var(--line); border-style: dashed; }
.actions-note { font-size: 12px; color: var(--ink-dim); margin-top: auto; }
.art {
/* One height for every card, art or not, so the titles and the buttons line up
+25 -4
View File
@@ -20,6 +20,7 @@ export class GameCardView {
public createCard (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
const card = createElement('article', 'card')
if (game.installed) card.classList.add('is-installed')
if (!game.installable) card.classList.add('is-unavailable')
card.appendChild(this.createArt(game))
card.appendChild(this.createBody(game, messages, busy))
return card
@@ -54,10 +55,21 @@ export class GameCardView {
private createMeta (game: GameDto, messages: MessageBundle): HTMLElement {
const meta = createElement('div', 'meta')
const mode = createElement('span', `badge badge-${game.mode}`,
game.mode === 'web' ? messages.hosted : messages.native)
mode.title = game.mode === 'web' ? messages.hostedHint : messages.nativeHint
meta.appendChild(mode)
if (game.installable) {
const mode = createElement('span', `badge badge-${game.mode}`,
game.mode === 'web' ? messages.hosted : messages.native)
mode.title = game.mode === 'web' ? messages.hostedHint : messages.nativeHint
meta.appendChild(mode)
} else {
// Which of the two it is matters: a platform this store does not carry is a
// different disappointment from a game with no build for your machine.
const label = game.unavailableReason === 'platformOff'
? messages.unsupportedPlatform
: messages.unsupportedBuild
const badge = createElement('span', 'badge badge-unavailable', label)
badge.title = game.unavailableDetail ?? label
meta.appendChild(badge)
}
meta.appendChild(createElement('span', 'badge badge-plain', game.platform))
meta.appendChild(createElement('span', 'version',
game.installed && game.installedVersion !== null
@@ -68,6 +80,15 @@ export class GameCardView {
private createActions (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
const actions = createElement('div', 'actions')
// Nothing to offer, so nothing to press: a disabled Install would invite a click
// that can never work. The badge above says why.
if (!game.installable) {
actions.appendChild(createElement('span', 'actions-note',
game.unavailableDetail ?? messages.unsupportedPlatform))
return actions
}
const primary = createElement('button', 'btn btn-primary')
primary.disabled = busy
+3 -2
View File
@@ -18,8 +18,9 @@ export class TopBarView {
public render (state: AppState): void {
setText(this.appName, state.messages.appName)
// The badge is a bordered pill: empty, it renders as a stub next to the title.
setHidden(this.storeId, state.currentStore === null)
// The bar names the app, not the store: which store is open is what the switcher in
// the side menu says, and saying it twice made the title read like a breadcrumb. The
// element stays in the page, hidden, because the window check reads it.
setText(this.storeId, state.currentStore === null ? '' : state.currentStore.id)
this.navToggle.title = state.messages.menu
this.navToggle.setAttribute('aria-label', state.messages.menu)
+16 -3
View File
@@ -167,9 +167,22 @@ class SmokeTest {
}
private reportListing (games: readonly GameDto[]): void {
const native = games.filter((game: GameDto): boolean => game.mode === 'app').length
const hosted = games.length - native
this.reportOk('list', `${String(games.length)} titles (app:${String(native)}, web:${String(hosted)})`)
const installable = games.filter((game: GameDto): boolean => game.installable)
const native = installable.filter((game: GameDto): boolean => game.mode === 'app').length
const hosted = installable.length - native
this.reportOk('list', `${String(games.length)} titles ` +
`(app:${String(native)}, web:${String(hosted)}, unavailable:${String(games.length - installable.length)})`)
// The listing is supposed to carry what it cannot install, with a reason on each.
const unavailable = games.filter((game: GameDto): boolean => !game.installable)
const unexplained = unavailable.filter((game: GameDto): boolean => game.unavailableReason === null)
if (unexplained.length > 0) this.reportBad('unavailable', `${String(unexplained.length)} have no reason`)
else if (unavailable.length > 0) {
const first = unavailable[0]
if (first !== undefined) {
this.reportOk('unavailable', `${String(unavailable.length)}, e.g. ${first.name}: ${first.unavailableReason ?? ''}`)
}
}
const withoutTitle = games.filter((game: GameDto): boolean =>
game.name.length === 0 || game.title.length === 0 || game.platform.length === 0)
+7
View File
@@ -1,6 +1,9 @@
/** How a title runs: unpacked on this machine, or served as a web build. */
export type GameModeDto = 'app' | 'web'
/** Why a title cannot be installed on this machine. */
export type UnavailableReasonDto = 'platformOff' | 'hostAsset' | 'noAsset' | 'vetoed'
/**
* A catalog entry as the window needs it.
*
@@ -23,4 +26,8 @@ export interface GameDto {
readonly updateAvailable: boolean
readonly installedVersion: string | null
readonly launchable: boolean
/** False for a title this machine cannot install; it is listed all the same. */
readonly installable: boolean
readonly unavailableReason: UnavailableReasonDto | null
readonly unavailableDetail: string | null
}
+4 -1
View File
@@ -5,7 +5,7 @@
* translation is a compile error rather than a blank label at runtime.
*/
export const ENGLISH_MESSAGES = {
appName: 'WarpEngine Store',
appName: 'WarpEngine Client',
refresh: 'Refresh',
install: 'Install',
update: 'Update',
@@ -18,6 +18,8 @@ export const ENGLISH_MESSAGES = {
hostedHint: 'Opens in your browser — needs the network',
nativeHint: 'Installed on this machine — works offline',
updateAvailable: 'update available',
unsupportedPlatform: 'unsupported platform',
unsupportedBuild: 'no build for this machine',
log: 'Log',
menu: 'Menu',
stores: 'Stores',
@@ -29,6 +31,7 @@ export const ENGLISH_MESSAGES = {
catInstalled: 'Installed',
catUpdates: 'Updates',
catAvailable: 'Not installed',
catUnsupported: 'Not for this machine',
catPlatform: 'Platform',
catMode: 'Kind',
language: 'Language',
+4 -1
View File
@@ -5,7 +5,7 @@ import type { MessageBundle } from './MessageBundle'
* arrives from the store as it was published.
*/
export const HUNGARIAN_MESSAGES: MessageBundle = {
appName: 'WarpEngine Store',
appName: 'WarpEngine Client',
refresh: 'Frissítés',
install: 'Telepítés',
update: 'Frissítés',
@@ -18,6 +18,8 @@ export const HUNGARIAN_MESSAGES: MessageBundle = {
hostedHint: 'A böngészőben nyílik meg — internet kell hozzá',
nativeHint: 'Erre a gépre telepítve — internet nélkül is megy',
updateAvailable: 'frissítés elérhető',
unsupportedPlatform: 'nem támogatott platform',
unsupportedBuild: 'ehhez a géphez nincs build',
log: 'Napló',
menu: 'Menü',
stores: 'Store-ok',
@@ -29,6 +31,7 @@ export const HUNGARIAN_MESSAGES: MessageBundle = {
catInstalled: 'Telepítve',
catUpdates: 'Frissítés',
catAvailable: 'Nincs telepítve',
catUnsupported: 'Erre a gépre nem',
catPlatform: 'Platform',
catMode: 'Fajta',
language: 'Nyelv',