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
+18 -6
View File
@@ -1,4 +1,4 @@
# WarpEngine Store GUI — the front door to the npm scripts. # WarpEngine Client — the front door to the npm scripts.
# #
# Everything here is a thin wrapper: the app is an Electron project, so npm still # Everything here is a thin wrapper: the app is an Electron project, so npm still
# does the work. The Makefile exists so the useful sequences have names, and so # does the work. The Makefile exists so the useful sequences have names, and so
@@ -22,13 +22,23 @@ NODE_MIN := 22
VERSION := $(shell python3 -c 'import json; print(json.load(open("package.json"))["version"])') VERSION := $(shell python3 -c 'import json; print(json.load(open("package.json"))["version"])')
TAG ?= v$(VERSION) TAG ?= v$(VERSION)
# Which site's store registry a packaged build reads. Empty means the default in
# package.json (ours); set it to build a client for somebody else's catalog:
#
# make dist STORES_API=https://games.example.org/api/stores
#
# It is baked into the package's own package.json, so the built app carries it. A runtime
# STORES_API still overrides it, which is for trying something out rather than shipping.
STORES_API ?=
BUILDER_ARGS := $(if $(STORES_API),-- --config.extraMetadata.warpEngine.registryUrl=$(STORES_API),)
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.PHONY: help setup node-check build typecheck lint lint-fix check start smoke uitest test \ .PHONY: help setup node-check build typecheck lint lint-fix check start smoke uitest test \
dist dist-mac dist-win dist-linux release publish clean distclean version dist dist-mac dist-win dist-linux release publish clean distclean version
help: ## List available targets help: ## List available targets
@echo "WarpEngine Store GUI $(VERSION) — usage: make <target>" @echo "WarpEngine Client $(VERSION) — usage: make <target>"
@echo @echo
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \ @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}' awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
@@ -74,16 +84,16 @@ uitest: ## Load the window once and report what rendered
test: smoke uitest ## Both checks test: smoke uitest ## Both checks
dist: node-check ## Package for this machine dist: node-check ## Package for this machine
npm run dist npm run dist $(BUILDER_ARGS)
dist-mac: node-check ## Package for macOS (ad-hoc signed, see the README) dist-mac: node-check ## Package for macOS (ad-hoc signed, see the README)
npm run dist:mac npm run dist:mac $(BUILDER_ARGS)
dist-win: node-check ## Package for Windows dist-win: node-check ## Package for Windows
npm run dist:win npm run dist:win $(BUILDER_ARGS)
dist-linux: node-check ## Package for Linux dist-linux: node-check ## Package for Linux
npm run dist:linux npm run dist:linux $(BUILDER_ARGS)
publish: ## Upload the packages already in dist/ to the Gitea release publish: ## Upload the packages already in dist/ to the Gitea release
@TAG=$(TAG) $(SCRIPTS)/release.sh @TAG=$(TAG) $(SCRIPTS)/release.sh
@@ -107,3 +117,5 @@ version: ## Show the versions involved
@printf "eslint "; npx eslint --version 2>/dev/null || echo "missing" @printf "eslint "; npx eslint --version 2>/dev/null || echo "missing"
@printf "tea "; tea --version 2>/dev/null | head -1 || echo "missing — devarea: make tea" @printf "tea "; tea --version 2>/dev/null | head -1 || echo "missing — devarea: make tea"
@printf "python3 "; python3 --version 2>/dev/null || echo "missing" @printf "python3 "; python3 --version 2>/dev/null || echo "missing"
@printf "registry "; python3 -c 'import json; print(json.load(open("package.json"))["warpEngine"]["registryUrl"])'
@if [ -n "$(STORES_API)" ]; then printf " build override: %s\n" "$(STORES_API)"; fi
+20 -6
View File
@@ -1,4 +1,4 @@
# warp-engine-client — the WarpEngine Store app # warp-engine-client — the WarpEngine Client app
The graphical client for a WarpEngine store — the app is called **WarpEngine The graphical client for a WarpEngine store — the app is called **WarpEngine
Store** — driving Store** — driving
@@ -41,7 +41,7 @@ The build is ad-hoc signed but **not notarised**, so macOS asks before running a
copy that came from a browser. The reliable way through: copy that came from a browser. The reliable way through:
```sh ```sh
xattr -dr com.apple.quarantine "/Applications/WarpEngine Store.app" xattr -dr com.apple.quarantine "/Applications/WarpEngine Client.app"
``` ```
If macOS offers *Open Anyway* under **System Settings ▸ Privacy & Security** after If macOS offers *Open Anyway* under **System Settings ▸ Privacy & Security** after
@@ -98,13 +98,19 @@ What the defaults produce, for a record with no repository: the games land in a
folder named after the store id, and released, archived **and demo** titles are folder named after the store id, and released, archived **and demo** titles are
listed — a catalog that publishes a demo means it to be played. listed — a catalog that publishes a demo means it to be played.
The registry address is the single thing about a particular site left in the The registry address is the single thing about a particular site left in the client,
client, and `STORES_API` overrides it: and it is decided in three places, most specific first:
```sh ```sh
STORES_API=http://127.0.0.1:8731/stores npm start STORES_API=http://127.0.0.1:8731/stores npm start # runtime: for trying something out
make dist STORES_API=https://games.example.org/api/stores # build: for shipping it
``` ```
The build variant is baked into the packaged app's own `package.json`
(`warpEngine.registryUrl`, written by `electron-builder --config.extraMetadata`), so a
client built for somebody else's catalog needs no source change and no environment on the
user's machine. With neither set, the address is ours.
Adding a store is therefore a database row on the site — see its ActiveAdmin Adding a store is therefore a database row on the site — see its ActiveAdmin
panel — and not a release of this app. panel — and not a release of this app.
@@ -139,6 +145,14 @@ once it is there. **Remove** takes a title back out. Each card says whether it i
build the catalog serves rather than packages, so its entry opens a page and needs build the catalog serves rather than packages, so its entry opens a page and needs
the network. the network.
**Everything in the catalog is listed, including what this machine cannot install.**
Those cards are dimmed, carry an *unsupported platform* or *no build for this machine*
badge with the engine's own explanation under it, and have nothing to press. A store
that hides them leaves you wondering whether the catalog is small or your machine is
unusual; this way it says which. They have a category of their own — *Not for this
machine* — and they are left out of the native/hosted counts, because a title with no
build has no mode to be counted under.
Every card carries a band of box art the same height — the first letter of the Every card carries a band of box art the same height — the first letter of the
title when the catalog has no image — so titles and buttons line up across a row. title when the catalog has no image — so titles and buttons line up across a row.
Until this was photographed, the grid was quietly broken: the rows split the Until this was photographed, the grid was quietly broken: the rows split the
@@ -261,7 +275,7 @@ on its own: publishing 1.2.0 got *"invalid username, password or token"* on the
second package while the first had just gone up with the same token, and the same second package while the first had just gone up with the same token, and the same
command succeeded immediately afterwards. command succeeded immediately afterwards.
Package names contain a space — `WarpEngine Store-1.2.0-arm64.dmg` — so the list of Package names contain a space — `WarpEngine Client-1.5.0-arm64.dmg` — so the list of
files is passed one path per line rather than as one string; splitting it on files is passed one path per line rather than as one string; splitting it on
whitespace is what broke the first attempt at publishing 1.1.0. whitespace is what broke the first attempt at publishing 1.1.0.
+34 -69
View File
@@ -1,90 +1,55 @@
# WarpEngine Store 1.4.0 # WarpEngine Client 1.5.0
**A store no longer needs a repository of its own.** Until now every store in the **The app is called WarpEngine Client.** It was WarpEngine Store, which named the thing
registry pointed at a repository holding its `config.json`, and the client read that it opens rather than the thing you run — and the store is a catalog on a site, not a
file to know what to install. It turns out almost nothing in there was necessary: the window on your machine. The window title, the packages, the bundle and the menu entry all
store engine's built-in defaults already cover the host-to-asset mapping, the install say Client now; the repository already did. The store the window is driving is named in
modes, the platforms and the behaviour. What defaults cannot know is *identity* — a the side menu, so the bar no longer repeats it as a badge.
slug, a name and a catalog URL — and that is exactly what a registry record carries.
So `storeRepositoryUrl` is now optional. A record with a name and a catalog URL is a **Every title in the catalog is listed, including the ones this machine cannot install.**
complete store: the client derives the slug from the catalog host A C64 cartridge on a desktop, or a game with no build for your operating system, used to
(`teletypegames.org``teletypegames`), writes a small config and installs. Given a be silently absent — leaving you to wonder whether the catalog is small or your machine
repository it still reads it, and that file remains the authority on how the store is unusual. Those cards are now there, dimmed, with an *unsupported platform* or *no
behaves — which platforms it offers, which statuses it shows, where things land. A build for this machine* badge and the engine's own sentence underneath, and with nothing
repository without a `config.json` is treated as no repository at all. to press. They have a category of their own, *Not for this machine*, and they stay out of
the native/hosted counts: a title with no build has no mode to be counted under.
With the defaults, games land in a folder named after the store id and released, This needs store engine **1.2.0** (desktop) on **warpstore 1.4.0**, which report what
archived **and demo** titles are listed: a catalog that publishes a demo means it to they had to leave out and why. An older engine still works — its listing is simply all
be played. installable, as it was before.
The site's registry endpoint changed to match — `storeRepositoryUrl` answers `null` **A build can be pointed at another site's registry.**
when there is none — and adding a store is now genuinely one database row with two
fields filled in.
**The "Install all" button is gone.** Titles are installed one at a time from their ```sh
own cards. make dist STORES_API=https://games.example.org/api/stores
```
**No footer.** The window carried a bar at the bottom at all times — a toggle and a The address is written into the packaged app's own `package.json`, so a client built for
line of absolute paths — for something most sessions never need. The log is still somebody else's catalog needs no source change and nothing set on the user's machine. A
there, with the two folder buttons in it, but it lives behind a quiet switch at the runtime `STORES_API` still wins, which is for trying something out rather than shipping.
bottom of the side menu and takes no room until it is opened. The grid gets the height
back.
**The repository is now `warp-engine-client`.** The app has always been called ### Also
WarpEngine Store; `warp-engine-desktop-gui` described the role rather than the
product, and the host-specific engines keep their own shape
(`warp-engine-desktop-store`, `-retroarch-store`, `-batocera-store`). Gitea keeps a
redirect from the old path, and the releases and tags moved with the repository, so
existing links and clones still resolve.
### Three things a screenshot found The scrollbars are the window's own now: the platform's light track down the side menu of
a dark window looked like a mistake.
Photographing the setup screen — which no automated count had ever looked at — turned
up three faults that every check had passed:
- the store badge in the bar rendered as an empty pill when no store was open;
- the gate's store picker showed as an empty dropdown stub, because an explicit
`display` in the stylesheet beats the browser's own `[hidden]` rule;
- the gate went up while the *"No installable titles in the catalog"* line stayed on
screen underneath it.
The last one was a design fault, not a typo: whether the gate is up was an imperative
call on a view rather than state, so the gate and the grid could disagree. The setup
screen is now a field in the state store, and that one field decides which of the two
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 a perfectly good
window failed it.
### Linux and Windows packages now come from CI
A `vX.Y.Z` tag now starts the pipeline, which builds the AppImage, the deb, the NSIS
installer and the portable exe — Windows through Wine — **creates this release** and
attaches all four. macOS stays a local build, because Apple's toolchain and its signing
exist only on a Mac, so `make release` from a Mac pushes that package onto the same
release afterwards. Publishing uses a `gitea_token` repository secret in Woodpecker.
The Windows installer is not signed: Windows will warn about an unknown publisher until
there is a certificate.
### Opening it on macOS ### Opening it on macOS
Ad-hoc signed, **not notarised**, so macOS asks first: Ad-hoc signed, **not notarised**, so macOS asks first:
```sh ```sh
xattr -dr com.apple.quarantine "/Applications/WarpEngine Store.app" xattr -dr com.apple.quarantine "/Applications/WarpEngine Client.app"
``` ```
### What is attached ### What is attached
The macOS arm64 package, built and verified here, plus whatever the pipeline attaches The macOS package, built and verified on a Mac, plus the Linux (AppImage, deb) and
for Linux (AppImage, deb) and Windows (installer, portable). Windows (installer, portable) packages the pipeline builds when the tag is pushed.
### Verified ### Verified
A repository-less store was installed end to end against a local registry serving one `make check` is clean. The store on this machine lists 13 titles — ten installable, three
record with `storeRepositoryUrl: null`: the id came out as `teletypegames`, the engine C64 cartridges this store does not carry — and the window was photographed with all
and the shared core downloaded, the written config had three sections, engine 1.1.0 thirteen on screen, the three dimmed and labelled. The engine change was measured through
accepted it, it listed the same ten titles the configured store does, and a hosted the CLI as well, in both its human and its `--json` listing, and the other two store
title synced into a sandbox with its menu entry written. `make check` is clean, and engines still get the two-value answer they ask for.
the setup gate was photographed on a machine with no store at all.
+9
View File
@@ -62,6 +62,7 @@ src/
mappers/ engine JSON → domain mappers/ engine JSON → domain
http/ HttpTextClient, HttpStatusError http/ HttpTextClient, HttpStatusError
json/ JsonRecord: reading data that came from elsewhere json/ JsonRecord: reading data that came from elsewhere
config/ BuildConfiguration: what was decided when this was packaged
electron/ ApplicationEnvironment and GameLauncher adapters electron/ ApplicationEnvironment and GameLauncher adapters
main/ main/
main.ts the entry point: one line of work main.ts the entry point: one line of work
@@ -184,6 +185,14 @@ never calls the bridge.
type, so a typo is a compile error and adding an entry is the whole change. This is type, so a typo is a compile error and adding an entry is the whole change. This is
the extension point for a second engine. the extension point for a second engine.
### Build-time configuration
`infrastructure/config/BuildConfiguration.ts` reads the packaged `package.json`, which is
where a build records the registry it was made for (`warpEngine.registryUrl`, set by
`make dist STORES_API=…`). Precedence is runtime environment, then build, then the
built-in default — most specific first, and each one is a different audience: someone
trying it out, someone shipping a client for another site, us.
### Untrusted-data readers ### Untrusted-data readers
Anything parsed from outside — engine stdout, the registry — goes through Anything parsed from outside — engine stdout, the registry — goes through
+7 -4
View File
@@ -1,8 +1,8 @@
{ {
"name": "warp-engine-client", "name": "warp-engine-client",
"productName": "WarpEngine Store", "productName": "WarpEngine Client",
"version": "1.4.0", "version": "1.5.0",
"description": "Graphical client for a WarpEngine desktop store: install the catalog into your own application menu.", "description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.",
"license": "MIT", "license": "MIT",
"author": "Teletype Games <games@teletype.hu>", "author": "Teletype Games <games@teletype.hu>",
"homepage": "https://git.teletypegames.org/stores/warp-engine-client", "homepage": "https://git.teletypegames.org/stores/warp-engine-client",
@@ -34,7 +34,7 @@
}, },
"build": { "build": {
"appId": "org.teletypegames.warpstore.gui", "appId": "org.teletypegames.warpstore.gui",
"productName": "WarpEngine Store", "productName": "WarpEngine Client",
"files": [ "files": [
"build/**/*", "build/**/*",
"package.json" "package.json"
@@ -64,5 +64,8 @@
"allowScripts": { "allowScripts": {
"electron@43.4.0": true, "electron@43.4.0": true,
"esbuild@0.28.2": true "esbuild@0.28.2": true
},
"warpEngine": {
"registryUrl": "https://teletypegames.org/api/stores"
} }
} }
+1 -1
View File
@@ -53,7 +53,7 @@ api() {
curl -fsS -X "$method" -H "$AUTH" "$FORGE$path" "$@" curl -fsS -X "$method" -H "$AUTH" "$FORGE$path" "$@"
} }
# Package names contain spaces — "WarpEngine Store Setup 1.4.0.exe" does — so the list # Package names contain spaces — "WarpEngine Client Setup 1.5.0.exe" does — so the list
# lives one path per line in a file and is read with `while IFS= read -r`. A single # lives one path per line in a file and is read with `while IFS= read -r`. A single
# variable looped over with $list splits on the space and uploads nothing. # variable looped over with $list splits on the space and uploads nothing.
LIST="$(mktemp)" LIST="$(mktemp)"
+1 -1
View File
@@ -52,7 +52,7 @@ REPO="${REPO:-$(git remote get-url origin 2>/dev/null |
# #
# - the version filter, because dist/ keeps whatever earlier builds left there # - the version filter, because dist/ keeps whatever earlier builds left there
# and a release would quietly get the previous version's files attached; # and a release would quietly get the previous version's files attached;
# - the spaces. "WarpEngine Store-1.1.0-arm64.dmg" has one, so the list lives one # - the spaces. "WarpEngine Client-1.5.0-arm64.dmg" has one, so the list lives one
# path per line in a file and is read with `while IFS= read -r`. Holding it in # path per line in a file and is read with `while IFS= read -r`. Holding it in
# a single variable and looping over $list splits it on the space. # a single variable and looping over $list splits it on the space.
LIST="$(mktemp)" LIST="$(mktemp)"
+4 -1
View File
@@ -26,7 +26,10 @@ export class GameDtoMapper {
installed: game.installed, installed: game.installed,
updateAvailable: game.updateAvailable, updateAvailable: game.updateAvailable,
installedVersion: game.installedVersion, 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. */ /** How a title runs: unpacked on this machine, or served as a web build. */
export type GameMode = 'app' | 'web' 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. * 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 menuEntryPath: string | null
readonly executablePath: string | null readonly executablePath: string | null
readonly hostedUrl: 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 { import {
readBoolean, readOptionalString, readString, type JsonRecord readBoolean, readOptionalString, readString, type JsonRecord
} from '../json/JsonRecord' } from '../json/JsonRecord'
@@ -26,11 +26,27 @@ export class EngineGameMapper {
installedVersion: readOptionalString(record, 'installed_version'), installedVersion: readOptionalString(record, 'installed_version'),
menuEntryPath: readOptionalString(record, 'menu_entry'), menuEntryPath: readOptionalString(record, 'menu_entry'),
executablePath: readOptionalString(record, 'exe'), 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 { private toMode (value: string): GameMode {
return value === 'web' ? 'web' : 'app' 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 { RegistryStore } from '../../domain/models/RegistryStore'
import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository' import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository'
import { asRecord, readString, type JsonRecord } from '../json/JsonRecord' import { asRecord, readString, type JsonRecord } from '../json/JsonRecord'
import { BuildConfiguration } from '../config/BuildConfiguration'
import type { HttpTextClient } from '../http/HttpTextClient' import type { HttpTextClient } from '../http/HttpTextClient'
const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores' 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 registry: `GET /api/stores` on the site.
* *
* The one address this client knows, and even that is overridable — `STORES_API` * The one address this client knows, and it is decided in three places, most specific
* points it at another site or at a local endpoint. * 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 * 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 * 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 { export class HttpStoreRegistryRepository implements StoreRegistryRepository {
public readonly sourceUrl: string public readonly sourceUrl: string
public constructor (private readonly httpClient: HttpTextClient, sourceUrl?: string) { public constructor (
const configured = process.env['STORES_API'] private readonly httpClient: HttpTextClient,
this.sourceUrl = sourceUrl ?? (configured !== undefined && configured.length > 0 sourceUrl?: string,
? configured buildConfiguration: BuildConfiguration = new BuildConfiguration()
: DEFAULT_REGISTRY_URL) ) {
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[]> { 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_FLAG = '--selftest'
const SELFTEST_USER_DATA_DIRECTORY = 'warpstore-gui-selftest' const SELFTEST_USER_DATA_DIRECTORY = 'warpstore-gui-selftest'
const PRODUCT_NAME = 'WarpEngine Store' const PRODUCT_NAME = 'WarpEngine Client'
/** /**
* The application's lifecycle. * The application's lifecycle.
+1 -1
View File
@@ -7,7 +7,7 @@ const WINDOW_OPTIONS: BrowserWindowConstructorOptions = {
minWidth: 760, minWidth: 760,
minHeight: 520, minHeight: 520,
backgroundColor: '#11151c', 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. --> runs: the app ships its own script and stylesheet. -->
<meta http-equiv="Content-Security-Policy" <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'"> 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"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
@@ -16,8 +16,10 @@
</button> </button>
<div class="bar-title"> <div class="bar-title">
<span class="logo" aria-hidden="true"></span> <span class="logo" aria-hidden="true"></span>
<span id="app-name">WarpEngine Store</span> <span id="app-name">WarpEngine Client</span>
<span class="store-id" id="store-id"></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>
<div class="bar-actions"> <div class="bar-actions">
<span class="progress" id="progress" hidden></span> <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': case 'platform':
return game.platform === filter.value return game.platform === filter.value
case 'mode': 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': case 'group':
return matchesGroup(game, filter.value) return matchesGroup(game, filter.value)
} }
@@ -43,7 +45,9 @@ function matchesGroup (game: GameDto, group: string): boolean {
case 'updates': case 'updates':
return game.updateAvailable return game.updateAvailable
case 'available': case 'available':
return !game.installed return game.installable && !game.installed
case 'unsupported':
return !game.installable
default: default:
return true return true
} }
@@ -68,7 +72,8 @@ export function buildCategorySections (
{ kind: 'group', value: 'all', label: messages.catAll, count: games.length }, { 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: '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: '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({ sections.push({
title: null, 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) { if (modes.length > 1) {
sections.push({ sections.push({
title: messages.catMode, title: messages.catMode,
@@ -98,7 +104,7 @@ export function buildCategorySections (
kind: 'mode', kind: 'mode',
value: mode, value: mode,
label: mode === 'web' ? messages.hosted : messages.native, 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; } * { 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 /* 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 regions here have one — the gate's store picker showed as an empty stub because
of exactly that. This makes `hidden` mean hidden everywhere. */ 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; flex-direction: column;
} }
.card.is-installed { border-color: #2f5a49; } .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 { .art {
/* One height for every card, art or not, so the titles and the buttons line up /* One height for every card, art or not, so the titles and the buttons line up
+21
View File
@@ -20,6 +20,7 @@ export class GameCardView {
public createCard (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement { public createCard (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
const card = createElement('article', 'card') const card = createElement('article', 'card')
if (game.installed) card.classList.add('is-installed') if (game.installed) card.classList.add('is-installed')
if (!game.installable) card.classList.add('is-unavailable')
card.appendChild(this.createArt(game)) card.appendChild(this.createArt(game))
card.appendChild(this.createBody(game, messages, busy)) card.appendChild(this.createBody(game, messages, busy))
return card return card
@@ -54,10 +55,21 @@ export class GameCardView {
private createMeta (game: GameDto, messages: MessageBundle): HTMLElement { private createMeta (game: GameDto, messages: MessageBundle): HTMLElement {
const meta = createElement('div', 'meta') const meta = createElement('div', 'meta')
if (game.installable) {
const mode = createElement('span', `badge badge-${game.mode}`, const mode = createElement('span', `badge badge-${game.mode}`,
game.mode === 'web' ? messages.hosted : messages.native) game.mode === 'web' ? messages.hosted : messages.native)
mode.title = game.mode === 'web' ? messages.hostedHint : messages.nativeHint mode.title = game.mode === 'web' ? messages.hostedHint : messages.nativeHint
meta.appendChild(mode) 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', 'badge badge-plain', game.platform))
meta.appendChild(createElement('span', 'version', meta.appendChild(createElement('span', 'version',
game.installed && game.installedVersion !== null game.installed && game.installedVersion !== null
@@ -68,6 +80,15 @@ export class GameCardView {
private createActions (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement { private createActions (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
const actions = createElement('div', 'actions') 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') const primary = createElement('button', 'btn btn-primary')
primary.disabled = busy primary.disabled = busy
+3 -2
View File
@@ -18,8 +18,9 @@ export class TopBarView {
public render (state: AppState): void { public render (state: AppState): void {
setText(this.appName, state.messages.appName) setText(this.appName, state.messages.appName)
// The badge is a bordered pill: empty, it renders as a stub next to the title. // The bar names the app, not the store: which store is open is what the switcher in
setHidden(this.storeId, state.currentStore === null) // 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) setText(this.storeId, state.currentStore === null ? '' : state.currentStore.id)
this.navToggle.title = state.messages.menu this.navToggle.title = state.messages.menu
this.navToggle.setAttribute('aria-label', 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 { private reportListing (games: readonly GameDto[]): void {
const native = games.filter((game: GameDto): boolean => game.mode === 'app').length const installable = games.filter((game: GameDto): boolean => game.installable)
const hosted = games.length - native const native = installable.filter((game: GameDto): boolean => game.mode === 'app').length
this.reportOk('list', `${String(games.length)} titles (app:${String(native)}, web:${String(hosted)})`) 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 => const withoutTitle = games.filter((game: GameDto): boolean =>
game.name.length === 0 || game.title.length === 0 || game.platform.length === 0) 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. */ /** How a title runs: unpacked on this machine, or served as a web build. */
export type GameModeDto = 'app' | 'web' 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. * A catalog entry as the window needs it.
* *
@@ -23,4 +26,8 @@ export interface GameDto {
readonly updateAvailable: boolean readonly updateAvailable: boolean
readonly installedVersion: string | null readonly installedVersion: string | null
readonly launchable: boolean 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. * translation is a compile error rather than a blank label at runtime.
*/ */
export const ENGLISH_MESSAGES = { export const ENGLISH_MESSAGES = {
appName: 'WarpEngine Store', appName: 'WarpEngine Client',
refresh: 'Refresh', refresh: 'Refresh',
install: 'Install', install: 'Install',
update: 'Update', update: 'Update',
@@ -18,6 +18,8 @@ export const ENGLISH_MESSAGES = {
hostedHint: 'Opens in your browser — needs the network', hostedHint: 'Opens in your browser — needs the network',
nativeHint: 'Installed on this machine — works offline', nativeHint: 'Installed on this machine — works offline',
updateAvailable: 'update available', updateAvailable: 'update available',
unsupportedPlatform: 'unsupported platform',
unsupportedBuild: 'no build for this machine',
log: 'Log', log: 'Log',
menu: 'Menu', menu: 'Menu',
stores: 'Stores', stores: 'Stores',
@@ -29,6 +31,7 @@ export const ENGLISH_MESSAGES = {
catInstalled: 'Installed', catInstalled: 'Installed',
catUpdates: 'Updates', catUpdates: 'Updates',
catAvailable: 'Not installed', catAvailable: 'Not installed',
catUnsupported: 'Not for this machine',
catPlatform: 'Platform', catPlatform: 'Platform',
catMode: 'Kind', catMode: 'Kind',
language: 'Language', language: 'Language',
+4 -1
View File
@@ -5,7 +5,7 @@ import type { MessageBundle } from './MessageBundle'
* arrives from the store as it was published. * arrives from the store as it was published.
*/ */
export const HUNGARIAN_MESSAGES: MessageBundle = { export const HUNGARIAN_MESSAGES: MessageBundle = {
appName: 'WarpEngine Store', appName: 'WarpEngine Client',
refresh: 'Frissítés', refresh: 'Frissítés',
install: 'Telepítés', install: 'Telepítés',
update: 'Frissí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á', 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', nativeHint: 'Erre a gépre telepítve — internet nélkül is megy',
updateAvailable: 'frissítés elérhető', updateAvailable: 'frissítés elérhető',
unsupportedPlatform: 'nem támogatott platform',
unsupportedBuild: 'ehhez a géphez nincs build',
log: 'Napló', log: 'Napló',
menu: 'Menü', menu: 'Menü',
stores: 'Store-ok', stores: 'Store-ok',
@@ -29,6 +31,7 @@ export const HUNGARIAN_MESSAGES: MessageBundle = {
catInstalled: 'Telepítve', catInstalled: 'Telepítve',
catUpdates: 'Frissítés', catUpdates: 'Frissítés',
catAvailable: 'Nincs telepítve', catAvailable: 'Nincs telepítve',
catUnsupported: 'Erre a gépre nem',
catPlatform: 'Platform', catPlatform: 'Platform',
catMode: 'Fajta', catMode: 'Fajta',
language: 'Nyelv', language: 'Nyelv',