3 Commits
Author SHA1 Message Date
mr.zeroandClaude Opus 5 e35a72336a Upgrade from the card, behind a three-dot menu
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
An installed title's version line now reads `0.1 → 0.3` where the catalog has moved on,
so a card answers both questions somebody brings to it: what is installed, and is there
anything better. Which version is installed was already recorded — `state.json` has
always carried it — what was missing was anywhere to act on it.

The card leads with Play (or Open, for a hosted title) and puts the rest behind a ⋮
button: Upgrade, which fetches whatever the catalog now has, and Uninstall. Upgrade stays
visible while disabled rather than appearing and disappearing — a menu whose items come
and go makes a person hunt for the one they used last time, and greyed out already says
"not now". Playing stays the headline even with an upgrade waiting: the build on the disk
still runs, and wanting to play it is not the same as wanting to wait for a download.

The menu is a `<details>`, so its open state is the DOM's and the keyboard needs no
teaching. Closing it on an outside click is the grid's job, not a card's: cards are
rebuilt on every render, so a listener per card would be a listener per render.

Package names lose their spaces — `WarpEngineClient-2.3.0-arm64.dmg` — because a space in
a release asset is a space in every curl, script and shell command that touches it. Set
per target rather than globally: nsis and portable would otherwise resolve to the same
.exe name and overwrite each other. `productName` is untouched, so the app is still
called WarpEngine Client where a person sees it — in the Dock and in /Applications.

Tested on a sandbox store by rewriting one state record to claim an older build, which is
what the engine actually compares: the window then offered `Upgrade:on` for that title and
`Upgrade:off` for the current one, and pressing it took the record from 0.1 to 0.2 with
the old payload removed first. The self-test asserts that pairing on every installed card,
because a closed menu photographs identically whether or not its items are right.

In Hungarian the catalog refresh and the new Upgrade both wanted "Frissítés"; the refresh
is an icon with a tooltip, and a tooltip can afford to say *Katalógus frissítése*.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 08:17:43 +02:00
mr.zeroandClaude Opus 5 82590d3ec4 A registry record is a name and a catalog
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
`config` — added this morning in 2.1.0 — is gone, and `storeRepositoryUrl` with it, along
with the two store repositories they pointed at.

2.1.0 had the registry say how each store behaves. Wrong shape: how a store behaves is
fixed per installed client, and this application is the only thing that can see the
machine it runs on. A copy of that on a server was a second authority over decisions this
side had already made correctly — including which directories the store may delete from —
and two authorities are a way to disagree.

Keeping two stores on one machine apart needs none of it. It is a subfolder, derived here:
the store id is a slug of the catalog host, the home is `<id>-desktop`, the games folder is
`<id>`, and that folder is the only subtree the store will ever delete from. Derived from
the *catalog* on purpose — the catalog is what a store is, so two records naming the same
one are the same store and land in the same place, which makes installing twice idempotent
instead of a way to orphan what is already there.

Existing installations keep their identity: a store home is recognised by its own
`config.json`, so one installed as `ttg` stays `ttg` in `ttg-desktop` with its games where
they are. Only a new install derives its id.

`StoreProvisioningService` no longer re-reads the registry before installing. That existed
to keep the renderer from supplying a config, and with no config in the record there is
nothing left to protect: a name and a catalog have no paths in them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 07:27:52 +02:00
mr.zeroandClaude Opus 5 045c7bf5b7 Read a store's config from the registry record
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
`GET /api/stores` records now carry a `config` field — a store's `config.json` moved
into the record that already said what the store is — and the client applies it
directly. Installing a store no longer depends on a second repository existing and
staying reachable, and a store can be configured from the site's admin alone.

The order is registry config, then a repository's `config.json`, then the engine's
defaults. The middle one is why nothing has to move at once: a registry whose stores
have not been migrated is read exactly as before.

The window cannot supply a config. It is handed stores to show and hands one back to
install, but only as an identity: `RegistryStoreDtoMapper.toModel` drops the config and
`StoreProvisioningService` reads the record again from the registry first. A config
decides where files are written and, through `paths.subfolder`, which subtree the store
may later delete from — not a decision the renderer gets to make, for the same reason a
`GameDto` carries no paths. Tested by installing from a record carrying
`subfolder: "ATTACKER"` and `install_root: "/tmp/pwned"` and finding neither on disk.

A store that has left the registry, or a registry that cannot be re-read, still
installs: it falls back to the engine's defaults rather than refusing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 06:43:39 +02:00
22 changed files with 389 additions and 294 deletions
+42 -26
View File
@@ -62,35 +62,34 @@ and there is nothing to decide; several and the setup screen shows a picker.
```json ```json
[ [
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org", "storeRepositoryUrl": null }, { "name": "Teletype Games", "catalogUrl": "https://teletypegames.org" },
{ { "name": "Some Other Store", "catalogUrl": "https://games.example.org" }
"name": "Some Other Store",
"catalogUrl": "https://games.example.org",
"storeRepositoryUrl": "https://git.example.org/stores/other-desktop-store"
}
] ]
``` ```
**A store needs no repository of its own.** A name and a catalog are enough: the **A name and a catalog are the whole record.** The store engine's built-in defaults
store engine's built-in defaults already cover the host-to-asset mapping, the already cover the host-to-asset mapping, the install modes, the platforms and the
install modes, the platforms and the behaviour, so what is actually missing from behaviour, so what is actually missing from them is identity — and identity is all a
them is identity — a slug, a name and a catalog URL — and that is exactly what a registry says. Nothing a record carries decides where files go: how a store behaves is
registry record carries. With `storeRepositoryUrl` null the client writes a fixed per installed client, which knows its own machine, and a copy of that on a server
three-section config and the store installs. would be a second authority over decisions this side has already made.
From a record the client works out the rest: From a record the client works out the rest:
- **the store id** — which names the store home and the folder games land in — - **the store id** — which names the store home and the folder games land in — is a slug
comes from the repository name when there is one (`ttg-desktop-store` becomes of the catalog host (`teletypegames.org` becomes `teletypegames`), or of the display
`ttg`), otherwise from the catalog host (`teletypegames.org` becomes name if that fails. Derived from the *catalog* on purpose: the catalog is what a store
`teletypegames`), otherwise from the display name. A `config.json` that sets its is, so two records naming the same one are the same store and land in the same place.
own id keeps it. Reinstalling therefore never orphans what is already installed.
- **`catalogUrl` and `name`** override the config's own `store.base_url` and - **the games folder** is that same slug inside the OS's usual place for programs, and it
`store.name`. The registry says which catalog this store is *for*, so it wins. is the only subtree this store will ever delete from. That is the whole of how two
- **`storeRepositoryUrl`**, when given → the store's `config.json`, read from stores on one machine stay out of each other's files: a subfolder, derived here.
`…/raw/branch/master/config.json`. That file stays the authority on how the store - **released, archived and demo** titles are listed, where the engine alone would show
behaves: which platforms, which statuses, where things land. A repository released and archived only — a catalog that publishes a demo means it to be played.
**without** a `config.json` is treated as no repository at all.
Because a record has no paths in it and no config, there is nothing for the window to
tamper with: `RegistryStoreDtoMapper.toModel` can take its choice at face value, and the
config that lands on disk is written by the installer from the engine's own defaults.
What the defaults produce, for a record with no repository: the games land in a 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
@@ -273,9 +272,14 @@ 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 Client-1.5.0-arm64.dmg`so the list of Package names have no spaces in them`WarpEngineClient-2.3.0-arm64.dmg`because a
files is passed one path per line rather than as one string; splitting it on space in a release asset is a space in every `curl`, script and shell command that ever
whitespace is what broke the first attempt at publishing 1.1.0. touches it. The app itself is still called **WarpEngine Client**: that name is what
appears in the Dock and in `/Applications`, and only the file names were the problem.
The list of files is still passed one path per line rather than as one string, since a
path given on the command line can contain a space even when a built one cannot;
splitting it on whitespace is what broke the first attempt at publishing 1.1.0.
It needs `tea` installed and logged in — the devarea repo has `make tea` for that. It needs `tea` installed and logged in — the devarea repo has `make tea` for that.
Overridable: `TAG`, `REPO`, `TEA_LOGIN`, `NOTES`, `DIST`. Overridable: `TAG`, `REPO`, `TEA_LOGIN`, `NOTES`, `DIST`.
@@ -376,6 +380,18 @@ across them. One class serving three versions is the honest way to say that.
## Verified, and not ## Verified, and not
**2.2.0** — the registry record was cut back to a name and a catalog, so the whole
install path was measured again against a local registry serving exactly that. The slug
came out `teletypegames` from the catalog host, the home `teletypegames-desktop`, the
games subfolder `teletypegames`, and installing the same record twice landed in the same
home. A record carrying `config` and `storeRepositoryUrl` — the fields a stale client or a
tampering renderer might still send — changed nothing, because neither exists in the model
any more. The site side was migrated and its specs re-run; the frontend was built, which
first required removing a dead `engines` list that had been failing `vue-tsc` on master.
Older entries below describe what was verified for the version they name, and some of
them predate the store engine moving into this application.
The pipeline's commands were run in the same containers it uses, before the pipeline was The pipeline's commands were run in the same containers it uses, before the pipeline was
committed: `electronuserland/builder:22` installs, type-checks, lints, passes the smoke committed: `electronuserland/builder:22` installs, type-checks, lints, passes the smoke
test (registry reached, store skipped as it should be on a machine that has none) and test (registry reached, store skipped as it should be on a machine that has none) and
+43 -57
View File
@@ -1,57 +1,39 @@
# WarpEngine Client 2.0.0 # WarpEngine Client 2.3.0
**The store engine is part of the app. Nothing has to be installed on the machine any **A card says which version you have, and offers the newer one.** Where the catalog has
more.** Reading the catalog, choosing which release fits your computer, downloading and moved on, an installed title's version line reads `0.1 → 0.3` instead of just the number
unpacking it, writing the menu entry and remembering what went where all happen inside on your disk, so the card answers both questions a person came to it with: what is
the application now. There is no Python to find, no child process, and no JSON protocol installed, and is there anything better.
between the two halves — which is why this is a major version rather than a feature.
What that changes for a person: on Windows and on a fresh Mac the app simply works. Which version is installed was already recorded — that is what `state.json` has always
Before it looked for `python3`, `python` and `py -3`, and where none answered it drew a been for. What was missing was somewhere to act on it.
screen with a link to python.org instead of a catalog. That screen is gone, along with
the one that offered to refresh a store engine too old to drive.
**Your existing library is kept.** `config.json` and `state.json` on disk are unchanged — **The actions moved into a three-dot menu.** Installed cards now lead with **Play** (or
the same field names, the same `<scope>:<name>` keys, the same file modes — so a machine **Open** for a hosted title) and put the rest behind the ⋮ button beside it:
whose games were installed by the shell store keeps them. Opening this version against
such a store lists them as installed, offers no needless update, and a sync reports
*already up to date*. A `version: 1` state file is still migrated on first read.
The two Python files an earlier install left in the store folder are removed the next | | |
time that store is set up. Nothing reads them, and a folder that still looks like it
holds the engine invites someone to run it against a state file this app is also writing.
**The client knows which WarpEngine served a catalog.** Every WarpEngine API response
carries a `WarpEngine-Version` header, and the client now reads it, names the version in
the log, and picks the catalog dialect for it. `SUPPORTED_WARP_ENGINE_VERSIONS` lists what
this build was written against — 0.2, 0.3 and 0.4 — and the four cases are all handled:
| The header says | What the client does |
|---|---| |---|---|
| a supported version | reads the catalog with that version's dialect | | **Upgrade** | fetches whatever the catalog now has, replacing the old payload and menu entry. Greyed out when there is nothing newer |
| nothing at all | reads it as the oldest supported version — an engine before 0.4.0 sent no header | | **Uninstall** | as before — the payload, the icon and the menu entry, and nothing else |
| something older | the same, and says so in the log |
| something newer | tries the newest dialect anyway, warning that titles may be missed |
Adding a version to that list fails the build until somebody says what it reads like, in Upgrade stays visible while disabled rather than appearing and disappearing: a menu whose
the type checker and in the linter both. A new engine version cannot arrive unnoticed. items come and go makes a person hunt for the one they used last time, and greyed out
already says "not now".
**Refresh and the language picker are icons.** They sit together at the foot of the side Playing stays the headline action even when an upgrade is waiting. The version on the disk
menu, and the *Actions* heading that used to head a section of one button is gone. Both still runs, and wanting to play it is not the same as wanting to wait for a download.
carry their name as a tooltip and to a screen reader, and the language picker is still a
real `<select>` underneath — the native dropdown, keyboard and all, with only the glyph **Package names have no spaces.** `WarpEngineClient-2.3.0-arm64.dmg` rather than
showing. `WarpEngine Client-2.3.0-arm64.dmg`, and the same for the AppImage and both Windows
builds — a space in a release asset is a space in every `curl`, script and shell command
that ever touches it. The app is still called **WarpEngine Client**, which is what appears
in the Dock and in `/Applications`; only the file names changed.
### Also ### Also
No runtime dependencies, still: the zip reader the installer needs is about 150 lines over The catalog refresh control's tooltip now says *Refresh the catalog*, because in Hungarian
`node:zlib` rather than a package. It restores the executable bit from each entry's it and the new Upgrade both wanted the word "Frissítés" and only one of them reloads a
external attributes, which is what makes an unpacked game able to start at all, and it list.
refuses a zip64 archive, an unknown compression method or an entry that would be written
outside its destination rather than guessing.
The repository itself is free of Python too — the Makefile, the CI check and the release
script read `package.json` and the forge's JSON with Node now.
### Opening it on macOS ### Opening it on macOS
@@ -70,17 +52,21 @@ Windows (installer, portable) packages the pipeline builds when the tag is pushe
`make check` is clean: typecheck, lint, the headless smoke test and the window self-test. `make check` is clean: typecheck, lint, the headless smoke test and the window self-test.
The new engine was measured against the old one rather than trusted. On the same catalog The upgrade path was tested on a sandbox store rather than reasoned about. Two titles were
and the same config, the Python engine and this one produce **the same 13-title listing installed, then one of their state records was rewritten to claim an older build — which is
with zero field differences** and the same resolved paths. Installing three titles — a exactly what the engine compares — and the window was asked what it would offer:
bare TIC-80 binary wrapped in a bundle, a Godot `.app` symlinked, and a hosted web entry —
gives **byte-identical payloads, identical file modes and an identical `Info.plist`**; the
only difference in the two trees is the sandbox path inside the generated launcher script.
`state.json` matches record for record.
The upgrade path was tested directly: pointed at a store home installed by the Python ```
engine, this one reports all three titles installed with no update available, and a BombExpert [newer] Upgrade:on Uninstall:on
re-sync writes nothing. Remove, prune, prune-suppression on a named sync, and the v1→v2 Rabbit Roller [current] Upgrade:off Uninstall:on
state migration were each exercised. The zip reader was checked against Python's ```
`extractall` on an archive holding stored, deflated, directory and symlink entries —
identical bytes and identical modes — and its zip-slip and not-a-zip guards both fire. Pressing Upgrade runs the same call the window makes, and the record went from `0.1` to
`0.2` with the old payload removed first. The self-test now asserts that pairing on every
installed card, because a closed menu photographs identically whether or not its items are
right.
The new package names were read off a real build (`WarpEngineClient-2.3.0-arm64.dmg`,
`…-arm64-mac.zip`) and checked against the release script's own file filter, which finds
both. The Windows names are `-Setup-` and `-Portable-` so the two `.exe` targets cannot
resolve to one name and overwrite each other.
+15 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "warp-engine-client", "name": "warp-engine-client",
"productName": "WarpEngine Client", "productName": "WarpEngine Client",
"version": "2.0.0", "version": "2.3.0",
"description": "Graphical client for WarpEngine stores: install a 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>",
@@ -44,7 +44,11 @@
"target": [ "target": [
"dmg", "dmg",
"zip" "zip"
] ],
"artifactName": "WarpEngineClient-${version}-${arch}-mac.${ext}"
},
"dmg": {
"artifactName": "WarpEngineClient-${version}-${arch}.${ext}"
}, },
"win": { "win": {
"target": [ "target": [
@@ -52,6 +56,12 @@
"portable" "portable"
] ]
}, },
"nsis": {
"artifactName": "WarpEngineClient-Setup-${version}-${arch}.${ext}"
},
"portable": {
"artifactName": "WarpEngineClient-Portable-${version}-${arch}.${ext}"
},
"linux": { "linux": {
"category": "Game", "category": "Game",
"target": [ "target": [
@@ -59,6 +69,9 @@
"deb" "deb"
] ]
}, },
"appImage": {
"artifactName": "WarpEngineClient-${version}-${arch}.${ext}"
},
"afterPack": "scripts/after-pack.js" "afterPack": "scripts/after-pack.js"
}, },
"allowScripts": { "allowScripts": {
+4 -3
View File
@@ -53,9 +53,10 @@ api() {
curl -fsS -X "$method" -H "$AUTH" "$FORGE$path" "$@" curl -fsS -X "$method" -H "$AUTH" "$FORGE$path" "$@"
} }
# Package names contain spaces — "WarpEngine Client Setup 1.5.0.exe" does — so the list # The list lives one path per line in a file and is read with `while IFS= read -r`. The
# lives one path per line in a file and is read with `while IFS= read -r`. A single # built package names have no spaces in them any more, but a path given on the command
# variable looped over with $list splits on the space and uploads nothing. # line still can — and a single variable looped over with $list splits on the space and
# uploads nothing, which is a silent way to publish a release with no assets.
LIST="$(mktemp)" LIST="$(mktemp)"
trap 'rm -f "$LIST"' EXIT trap 'rm -f "$LIST"' EXIT
if [ "$#" -gt 0 ]; then if [ "$#" -gt 0 ]; then
+4 -3
View File
@@ -43,9 +43,10 @@ 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 Client-1.5.0-arm64.dmg" has one, so the list lives one # - the spaces. The built names have none since 2.3.0 — `WarpEngineClient-2.3.0-arm64.dmg`
# path per line in a file and is read with `while IFS= read -r`. Holding it in # — but a path given as an argument still can, so the list stays one path per line in
# a single variable and looping over $list splits it on the space. # a file, read with `while IFS= read -r`. Holding it in a single variable and looping
# over $list splits it on the space, and publishes nothing.
LIST="$(mktemp)" LIST="$(mktemp)"
trap 'rm -f "$LIST"' EXIT trap 'rm -f "$LIST"' EXIT
if [ "$#" -gt 0 ]; then if [ "$#" -gt 0 ]; then
@@ -7,7 +7,6 @@ export class RegistryStoreDtoMapper {
return { return {
name: store.name, name: store.name,
catalogUrl: store.catalogUrl, catalogUrl: store.catalogUrl,
storeRepositoryUrl: store.storeRepositoryUrl,
storeId: deriveStoreId(store) storeId: deriveStoreId(store)
} }
} }
@@ -18,10 +17,6 @@ export class RegistryStoreDtoMapper {
/** The window hands a record straight back when asking for an install. */ /** The window hands a record straight back when asking for an install. */
public toModel (dto: RegistryStoreDto): RegistryStore { public toModel (dto: RegistryStoreDto): RegistryStore {
return { return { name: dto.name, catalogUrl: dto.catalogUrl }
name: dto.name,
catalogUrl: dto.catalogUrl,
storeRepositoryUrl: dto.storeRepositoryUrl
}
} }
} }
@@ -30,6 +30,14 @@ export class StoreProvisioningService {
return this.registry.listStores() return this.registry.listStores()
} }
/**
* Install the chosen store.
*
* The window's choice is taken at face value, which is safe because a record is only a
* name and a catalog: there is no path in it and nothing that decides what may be
* deleted. The store's own configuration is written by the installer from the engine's
* defaults, so the renderer cannot influence where anything lands.
*/
public async installStore ( public async installStore (
store: RegistryStore, store: RegistryStore,
progress?: EngineProgressListener progress?: EngineProgressListener
@@ -38,4 +46,5 @@ export class StoreProvisioningService {
const installed = await this.installer.installEngine(home, store, progress) const installed = await this.installer.installEngine(home, store, progress)
return this.selection.adoptStore(installed) return this.selection.adoptStore(installed)
} }
} }
+8 -7
View File
@@ -1,14 +1,15 @@
/** /**
* A store the site's registry offers. * A store the site's registry offers: a name and a catalog.
* *
* A name and a catalog are what make a store; the repository is optional. When * That is the whole record, and it is enough. How a store behaves is not the registry's
* there is one it stays the authority on how that store behaves — which platforms * business — this client carries its own store engine, whose defaults cover the
* it offers, where things land — and when there is not, the engine's own defaults * host-to-asset mapping, the install modes, the platforms and the behaviour — so what
* cover all of it and this record covers the identity. That is the whole reason a * was actually missing from those defaults is identity, and identity is all this is.
* store needs no repository of its own. *
* Keeping two stores on one machine out of each other's files is a subfolder, derived
* here from the store's own slug rather than told to us by a server.
*/ */
export interface RegistryStore { export interface RegistryStore {
readonly name: string readonly name: string
readonly catalogUrl: string readonly catalogUrl: string
readonly storeRepositoryUrl: string | null
} }
+10 -16
View File
@@ -3,28 +3,22 @@ import type { RegistryStore } from './RegistryStore'
/** /**
* A store id, from whatever the registry gave us. * A store id, from whatever the registry gave us.
* *
* The id names the store home, the folder games land in and the launcher files, so * The id names the store home, the folder games land in and the launcher files, so it
* it has to be short and filesystem-safe. Three sources, in order of how much they * has to be short and filesystem-safe. Two sources, in order of how much they were
* were meant to be a name: * meant to be a name:
* *
* 1. the repository name — `ttg-desktop-store` becomes `ttg`; * 1. the catalog host — `https://teletypegames.org` becomes `teletypegames`;
* 2. the catalog host — `https://teletypegames.org` becomes `teletypegames`; * 2. the display name, slugged, as a last resort.
* 3. the display name, slugged, as a last resort.
* *
* The store's own config.json overrides all of it whenever one exists. * Derived rather than carried, and derived from the catalog: the catalog is what a store
* *is*, so two records naming the same catalog are the same store and land in the same
* place, which is what keeps a reinstall from orphaning what is already there.
*/ */
export function deriveStoreId (store: RegistryStore): string { export function deriveStoreId (store: RegistryStore): string {
const fromRepository = store.storeRepositoryUrl === null return toSlug(readHostLabel(store.catalogUrl)) || toSlug(store.name) || 'store'
? ''
: (lastSegment(store.storeRepositoryUrl).replace(/-(desktop-)?store$/, ''))
return toSlug(fromRepository) || toSlug(readHostLabel(store.catalogUrl)) || toSlug(store.name) || 'store'
} }
function lastSegment (url: string): string { /** `https://www.teletypegames.org/x` -> `teletypegames`. */
return url.replace(/\/+$/, '').split('/').pop() ?? ''
}
/** `https://www.teletypegames.org/x` → `teletypegames`. */
function readHostLabel (catalogUrl: string): string { function readHostLabel (catalogUrl: string): string {
try { try {
const host = new URL(catalogUrl).hostname.replace(/^www\./, '') const host = new URL(catalogUrl).hostname.replace(/^www\./, '')
@@ -15,10 +15,10 @@ const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores'
* field a build was packaged with (for shipping a client for another site), and finally * field a build was packaged with (for shipping a client for another site), and finally
* the address of ours. * the address of ours.
* *
* A record needs a name and a catalog URL; those two make a store. The repository * A name and a catalog URL make a store, and are all a record carries. Anything else it
* is optional and arrives as null when absent — a store configured by nothing but * happens to say is ignored: how a store behaves is this client's own business, decided
* this record installs on the engine's defaults. Records missing either of the two * by the engine it ships with. Records missing either field are dropped rather than
* required fields are dropped rather than half-used. * half-used.
*/ */
export class HttpStoreRegistryRepository implements StoreRegistryRepository { export class HttpStoreRegistryRepository implements StoreRegistryRepository {
public readonly sourceUrl: string public readonly sourceUrl: string
@@ -44,18 +44,12 @@ export class HttpStoreRegistryRepository implements StoreRegistryRepository {
return parsed return parsed
.map((row: unknown): JsonRecord | null => asRecord(row)) .map((row: unknown): JsonRecord | null => asRecord(row))
.filter((row: JsonRecord | null): row is JsonRecord => row !== null) .filter((row: JsonRecord | null): row is JsonRecord => row !== null)
.map((row: JsonRecord): RegistryStore => { // Both spellings, because a registry is someone else's API: ours answers
// Both spellings, because a registry is someone else's API: ours answers // camelCase, and a hand-rolled one may not.
// camelCase, and a hand-rolled one may not. .map((row: JsonRecord): RegistryStore => ({
const repository = ( name: readString(row, 'name').trim(),
readString(row, 'storeRepositoryUrl') || readString(row, 'store_repository_url') catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim()
).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 => .filter((store: RegistryStore): boolean =>
store.name.length > 0 && store.catalogUrl.length > 0) store.name.length > 0 && store.catalogUrl.length > 0)
} }
@@ -6,12 +6,8 @@ import type { RegistryStore } from '../../domain/models/RegistryStore'
import { DESKTOP_STORE_ENGINE } from '../../domain/models/StoreEngine' import { DESKTOP_STORE_ENGINE } from '../../domain/models/StoreEngine'
import { deriveStoreId } from '../../domain/models/StoreIdentity' import { deriveStoreId } from '../../domain/models/StoreIdentity'
import type { StoreEngineInstaller } from '../../domain/ports/StoreEngineInstaller' import type { StoreEngineInstaller } from '../../domain/ports/StoreEngineInstaller'
import { asRecord, readString } from '../json/JsonRecord'
import { HttpStatusError, type HttpTextClient } from '../http/HttpTextClient'
const CONFIG_FILE_NAME = 'config.json' const CONFIG_FILE_NAME = 'config.json'
const DEFAULT_FORGE_BASE = 'https://git.teletypegames.org'
const DEFAULT_BRANCH = 'master'
/** What an install used to leave in a store home, back when the engine was a script. */ /** What an install used to leave in a store home, back when the engine was a script. */
const RETIRED_ENGINE_FILES: readonly string[] = ['desktop_store.py', 'warpstore.py'] const RETIRED_ENGINE_FILES: readonly string[] = ['desktop_store.py', 'warpstore.py']
@@ -19,51 +15,63 @@ const RETIRED_ENGINE_FILES: readonly string[] = ['desktop_store.py', 'warpstore.
/** /**
* Setting up a store where there is none. * Setting up a store where there is none.
* *
* Since the engine moved into this application there is nothing to download but the * Nothing is downloaded and nothing is asked of a server. The engine ships in this
* store's own configuration, so an install is one HTTP call and one file. The store * application and its defaults already cover the host-to-asset mapping, the install
* home stays where it was and keeps its name, because the state and the catalog cache * modes, the platforms and the behaviour; what a registry record adds is identity — a
* beside that config are what make an existing library recognisable. * name, a catalog and a slug — and that is what gets written.
*
* The config is written to disk rather than kept in memory because it is the store's
* own record of itself: `StoreConfigurationReader` reads it on every operation, an
* existing store home is recognised by it, and a person can look at it.
*/ */
export class NativeStoreEngineInstaller implements StoreEngineInstaller { export class NativeStoreEngineInstaller implements StoreEngineInstaller {
private readonly forgeBase: string public installEngine (
public constructor (private readonly httpClient: HttpTextClient, forgeBase?: string) {
const configured = process.env['FORGE_BASE']
this.forgeBase = forgeBase ?? (configured !== undefined && configured.length > 0
? configured
: DEFAULT_FORGE_BASE)
}
public async installEngine (
home: string, home: string,
store: RegistryStore, store: RegistryStore,
progress: EngineProgressListener = {} progress: EngineProgressListener = {}
): Promise<InstalledStore> { ): Promise<InstalledStore> {
fs.mkdirSync(home, { recursive: true }) fs.mkdirSync(home, { recursive: true })
const config = await this.readStoreConfig(store, progress) const storeId = deriveStoreId(store)
const configPath = path.join(home, CONFIG_FILE_NAME) const configPath = path.join(home, CONFIG_FILE_NAME)
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) fs.writeFileSync(configPath, `${JSON.stringify(this.buildConfig(store, storeId), null, 2)}\n`)
this.removeRetiredEngine(home, progress) this.removeRetiredEngine(home, progress)
progress.onLog?.(`${store.name} is set up in ${home}`) progress.onLog?.(`${store.name} is set up in ${home}`)
const configStore = asRecord(config['store']) return Promise.resolve({
return { id: storeId,
id: configStore === null ? deriveStoreId(store) : readString(configStore, 'id', deriveStoreId(store)),
name: store.name, name: store.name,
home, home,
configPath, configPath,
engine: DESKTOP_STORE_ENGINE.id engine: DESKTOP_STORE_ENGINE.id
})
}
/**
* The store's configuration: its identity, and the two things worth stating.
*
* Everything absent from this falls to the engine's defaults, which is most of it. The
* subfolder is named after the store so two stores on one machine cannot reach into
* each other's files — it is the prune boundary, so it has to 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 buildConfig (store: RegistryStore, storeId: string): Record<string, unknown> {
return {
store: { id: storeId, name: store.name, base_url: store.catalogUrl },
paths: { subfolder: storeId },
catalog: { statuses: ['released', 'archived', 'demo'] }
} }
} }
/** /**
* Clear out the scripts an older client downloaded here. * Clear out the scripts an older client downloaded here.
* *
* A store home provisioned by 1.5.0 or by the shell installer holds two Python * A store home provisioned by 1.5.0 or by a shell installer holds two Python files
* files that nothing reads any more. They are harmless, but a directory that still * that nothing reads any more. They are harmless, but a directory that still looks
* looks like it holds the engine invites someone to run it against a state file * like it holds the engine invites someone to run it against a state file this
* this application is also writing. * application is also writing.
*/ */
private removeRetiredEngine (home: string, progress: EngineProgressListener): void { private removeRetiredEngine (home: string, progress: EngineProgressListener): void {
for (const fileName of RETIRED_ENGINE_FILES) { for (const fileName of RETIRED_ENGINE_FILES) {
@@ -73,79 +81,4 @@ export class NativeStoreEngineInstaller implements StoreEngineInstaller {
progress.onLog?.(`removed the retired ${fileName}`) progress.onLog?.(`removed the retired ${fileName}`)
} }
} }
/**
* The store's configuration.
*
* 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)
const config = await this.readPublishedConfig(store, storeId, progress)
const existing = asRecord(config['store']) ?? {}
config['store'] = {
...existing,
id: readString(existing, 'id', storeId),
name: store.name,
base_url: store.catalogUrl
}
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}`
}
} }
+1 -1
View File
@@ -48,7 +48,7 @@ export class ServiceContainer {
const stores = new FileSystemInstalledStoreRepository() const stores = new FileSystemInstalledStoreRepository()
const catalogGateway = new NativeStoreCatalogGateway() const catalogGateway = new NativeStoreCatalogGateway()
const registry = new HttpStoreRegistryRepository(httpClient) const registry = new HttpStoreRegistryRepository(httpClient)
const installer = new NativeStoreEngineInstaller(httpClient) const installer = new NativeStoreEngineInstaller()
const preferencesRepository = new JsonFilePreferencesRepository(environment) const preferencesRepository = new JsonFilePreferencesRepository(environment)
const preferences = new PreferencesService(preferencesRepository, environment) const preferences = new PreferencesService(preferencesRepository, environment)
+19 -2
View File
@@ -28,6 +28,8 @@ interface SelfTestReport {
readonly locales: readonly string[] readonly locales: readonly string[]
/** `<accessible name>:<glyph count>` per icon-only control in the footer. */ /** `<accessible name>:<glyph count>` per icon-only control in the footer. */
readonly iconControls: readonly string[] readonly iconControls: readonly string[]
/** `<title> [current|newer] Upgrade:on|off Uninstall:on|off` per installed card. */
readonly cardMenus: readonly string[]
} }
/** What changed after clicking a store that was not open. */ /** What changed after clicking a store that was not open. */
@@ -74,7 +76,11 @@ export class SelfTestRunner {
// picker are the only way to reach those two actions now that neither has a label. // picker are the only way to reach those two actions now that neither has a label.
const iconsNamed = report.iconControls.length === 2 && const iconsNamed = report.iconControls.length === 2 &&
report.iconControls.every((control: string): boolean => /^.+:1$/.test(control)) report.iconControls.every((control: string): boolean => /^.+:1$/.test(control))
const rendered = report.locales.length > 1 && iconsNamed && ( // Every installed card offers both actions, and Upgrade is enabled exactly when the
// version line says there is something newer. Uninstall is always available.
const menusAgree = report.cardMenus.every((entry: string): boolean =>
/\[newer\] \S+:on \S+:on$/.test(entry) || /\[current\] \S+:off \S+:on$/.test(entry))
const rendered = report.locales.length > 1 && iconsNamed && menusAgree && (
(report.cards > 0 && !report.gateVisible && report.stores.length > 0 && (report.cards > 0 && !report.gateVisible && report.stores.length > 0 &&
report.categories.length > 0 && report.activeCategory !== null) || report.categories.length > 0 && report.activeCategory !== null) ||
(report.gateVisible && report.gateAction.length > 0)) (report.gateVisible && report.gateAction.length > 0))
@@ -107,6 +113,16 @@ export class SelfTestRunner {
locales: [...document.getElementById('locale').options].map((option) => option.value), locales: [...document.getElementById('locale').options].map((option) => option.value),
// The two icon-only controls: a glyph with no accessible name is a button nobody // The two icon-only controls: a glyph with no accessible name is a button nobody
// can identify, and the failure is silent because the icon still draws. // can identify, and the failure is silent because the icon still draws.
// One entry per installed card: its title, whether the version line shows an
// upgrade, and the menu's two items with their disabled state. This is the only
// way to see that Upgrade is offered exactly when there is something newer —
// a screenshot shows a closed menu.
cardMenus: [...document.querySelectorAll('.card.is-installed')].map((card) => {
const items = [...card.querySelectorAll('.menu-item')]
.map((item) => item.textContent + (item.disabled ? ':off' : ':on'))
const arrow = card.querySelector('.version.has-update') === null ? 'current' : 'newer'
return (card.querySelector('h2') || {}).textContent + ' [' + arrow + '] ' + items.join(' ')
}),
iconControls: [...document.querySelectorAll('.side-tools .icon-btn')] iconControls: [...document.querySelectorAll('.side-tools .icon-btn')]
.map((control) => { .map((control) => {
const named = control.getAttribute('aria-label') || control.getAttribute('title') || const named = control.getAttribute('aria-label') || control.getAttribute('title') ||
@@ -132,7 +148,8 @@ export class SelfTestRunner {
paths: readString(record, 'paths'), paths: readString(record, 'paths'),
logLines: readNumber(record, 'logLines'), logLines: readNumber(record, 'logLines'),
locales: readStringArray(record, 'locales'), locales: readStringArray(record, 'locales'),
iconControls: readStringArray(record, 'iconControls') iconControls: readStringArray(record, 'iconControls'),
cardMenus: readStringArray(record, 'cardMenus')
} }
} }
-3
View File
@@ -27,12 +27,9 @@ export function requireStringArray (value: unknown, name: string): readonly stri
export function requireRegistryStore (value: unknown): RegistryStoreDto { export function requireRegistryStore (value: unknown): RegistryStoreDto {
const record = asRecord(value) const record = asRecord(value)
if (record === null) throw new TypeError('a store record is required') if (record === null) throw new TypeError('a store record is required')
const repository = readString(record, 'storeRepositoryUrl')
const store: RegistryStoreDto = { const store: RegistryStoreDto = {
name: readString(record, 'name'), name: readString(record, 'name'),
catalogUrl: readString(record, 'catalogUrl'), catalogUrl: readString(record, 'catalogUrl'),
// Optional: a store with no repository installs on the engine's defaults.
storeRepositoryUrl: repository.length > 0 ? repository : null,
storeId: readString(record, 'storeId') storeId: readString(record, 'storeId')
} }
if (store.name.length === 0 || store.catalogUrl.length === 0) { if (store.name.length === 0 || store.catalogUrl.length === 0) {
+3
View File
@@ -47,6 +47,9 @@ export class RendererApplication {
this.grid = new CatalogGridView(new GameCardView({ this.grid = new CatalogGridView(new GameCardView({
onInstall: (name: string): void => { void this.catalog.syncGames([name]) }, onInstall: (name: string): void => { void this.catalog.syncGames([name]) },
// The same call as an install: a sync of one name fetches whatever the catalog
// now has for it, and the engine replaces the old payload and menu entry.
onUpgrade: (name: string): void => { void this.catalog.syncGames([name]) },
onLaunch: (name: string): void => { void this.catalog.launchGame(name) }, onLaunch: (name: string): void => { void this.catalog.launchGame(name) },
onRemove: (name: string): void => { void this.catalog.removeGame(name) } onRemove: (name: string): void => { void this.catalog.removeGame(name) }
})) }))
+50
View File
@@ -357,6 +357,56 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
} }
.actions { display: flex; gap: 8px; margin-top: auto; } .actions { display: flex; gap: 8px; margin-top: auto; }
/* --- the card's actions menu -------------------------------------------- */
/*
* A <details> holding the two actions that are not the card's headline. The card is the
* positioning context, and the panel is pinned to the button's right edge so it opens
* inward rather than off the side of the grid.
*/
.menu { position: relative; margin-left: auto; }
.menu-toggle { list-style: none; }
/* Safari and Chrome each draw their own marker on a summary; both have to go. */
.menu-toggle::-webkit-details-marker { display: none; }
.menu-toggle::marker { content: ''; }
.menu[open] .menu-toggle { color: var(--ink); background: var(--panel-2); border-color: var(--line); }
.menu-items {
position: absolute;
right: 0;
bottom: calc(100% + 6px);
z-index: 20;
min-width: 148px;
display: flex;
flex-direction: column;
padding: 4px;
gap: 2px;
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 10px;
box-shadow: 0 10px 28px rgb(0 0 0 / .45);
}
.menu-item {
font: inherit;
font-size: 13px;
text-align: left;
padding: 7px 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--ink);
cursor: pointer;
white-space: nowrap;
}
.menu-item:hover:not(:disabled) { background: #2b3746; }
.menu-item:disabled { color: var(--ink-dim); opacity: .5; cursor: default; }
.icon-dots { fill: currentColor; stroke: none; }
/* An installed version with a newer one behind it: the arrow carries the news, so the
colour only has to make it findable in a grid. */
.version.has-update { color: var(--accent); font-weight: 600; }
/* --- log ---------------------------------------------------------------- /* --- log ----------------------------------------------------------------
No permanent footer: the panel is in the flow only while it is open, and the No permanent footer: the panel is in the flow only while it is open, and the
switch for it sits in the side menu with everything else that is not a title. */ switch for it sits in the side menu with everything else that is not a title. */
+20 -1
View File
@@ -9,7 +9,26 @@ export class CatalogGridView {
private readonly grid = requireElement('grid', HTMLElement) private readonly grid = requireElement('grid', HTMLElement)
private readonly empty = requireElement('empty', HTMLElement) private readonly empty = requireElement('empty', HTMLElement)
public constructor (private readonly cards: GameCardView) {} public constructor (private readonly cards: GameCardView) {
this.closeMenusOnOutsideClick()
}
/**
* One listener for every card's actions menu.
*
* A `<details>` does not close when the pointer goes elsewhere, and a card cannot own
* this: cards are rebuilt on every render, so a listener per card would be a listener
* per render. The grid is created once, which makes it the right place for it.
*/
private closeMenusOnOutsideClick (): void {
document.addEventListener('click', (event: MouseEvent): void => {
const target = event.target
const clicked = target instanceof Node ? target : null
for (const menu of this.grid.querySelectorAll('details.menu[open]')) {
if (clicked === null || !menu.contains(clicked)) menu.removeAttribute('open')
}
})
}
public render (state: AppState): void { public render (state: AppState): void {
const shown = state.games.filter((game: GameDto): boolean => matchesFilter(game, state.filter)) const shown = state.games.filter((game: GameDto): boolean => matchesFilter(game, state.filter))
+103 -19
View File
@@ -4,6 +4,7 @@ import { createElement } from '../dom/Dom'
export interface GameCardViewCallbacks { export interface GameCardViewCallbacks {
readonly onInstall: (name: string) => void readonly onInstall: (name: string) => void
readonly onUpgrade: (name: string) => void
readonly onLaunch: (name: string) => void readonly onLaunch: (name: string) => void
readonly onRemove: (name: string) => void readonly onRemove: (name: string) => void
} }
@@ -11,8 +12,11 @@ export interface GameCardViewCallbacks {
/** /**
* One card. * One card.
* *
* A card is a function of a title and the strings: it holds no state of its own, so * A card is a function of a title and the strings: it holds no state of its own, so the
* the grid can throw the lot away and rebuild after every listing. * grid can throw the lot away and rebuild after every listing. The one exception is the
* actions menu, whose open/closed state lives in a `<details>` element — and being
* thrown away is exactly what should happen to an open menu when the catalog changes
* under it.
*/ */
export class GameCardView { export class GameCardView {
public constructor (private readonly callbacks: GameCardViewCallbacks) {} public constructor (private readonly callbacks: GameCardViewCallbacks) {}
@@ -71,13 +75,30 @@ export class GameCardView {
meta.appendChild(badge) 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(this.createVersion(game, messages))
game.installed && game.installedVersion !== null
? `${game.installedVersion} · ${messages.installed}`
: game.version))
return meta return meta
} }
/**
* The version, and what the catalog has that this machine does not.
*
* Both numbers, when they differ: which version is installed is the thing a person
* came to the card to find out, and "there is a newer one" is only meaningful next to
* it. Where they agree, the second number would be noise.
*/
private createVersion (game: GameDto, messages: MessageBundle): HTMLElement {
if (!game.installed || game.installedVersion === null) {
return createElement('span', 'version', game.version)
}
if (!game.updateAvailable) {
return createElement('span', 'version', `${game.installedVersion} · ${messages.installed}`)
}
const version = createElement('span', 'version has-update',
`${game.installedVersion}${game.version}`)
version.title = messages.updateAvailable
return version
}
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')
@@ -89,25 +110,88 @@ export class GameCardView {
return actions return actions
} }
const primary = createElement('button', 'btn btn-primary') actions.appendChild(this.createPrimary(game, messages, busy))
primary.disabled = busy // Only an installed title has anything in the menu: nothing to upgrade and nothing
// to uninstall until there is something on the disk.
if (game.installed) actions.appendChild(this.createMenu(game, messages, busy))
return actions
}
if (game.installed && !game.updateAvailable) { /**
* The one action a card leads with.
*
* For an installed title that is playing it — including when an upgrade is waiting,
* because the version on the disk still runs and wanting to play it is not the same
* as wanting to wait for a download.
*/
private createPrimary (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
const primary = createElement('button', 'btn btn-primary')
if (game.installed) {
primary.textContent = game.mode === 'web' ? messages.open : messages.play primary.textContent = game.mode === 'web' ? messages.open : messages.play
primary.disabled = busy || !game.launchable primary.disabled = busy || !game.launchable
primary.addEventListener('click', (): void => { this.callbacks.onLaunch(game.name) }) primary.addEventListener('click', (): void => { this.callbacks.onLaunch(game.name) })
} else { return primary
primary.textContent = game.updateAvailable ? messages.update : messages.install
primary.addEventListener('click', (): void => { this.callbacks.onInstall(game.name) })
} }
actions.appendChild(primary) primary.textContent = messages.install
primary.disabled = busy
primary.addEventListener('click', (): void => { this.callbacks.onInstall(game.name) })
return primary
}
if (game.installed) { /**
const remove = createElement('button', 'btn btn-ghost', messages.remove) * The three-dot menu: upgrade, and uninstall.
remove.disabled = busy *
remove.addEventListener('click', (): void => { this.callbacks.onRemove(game.name) }) * A `<details>` rather than a scripted popover, so the open state is the DOM's and the
actions.appendChild(remove) * keyboard works without being taught to. Upgrade is present but disabled when there
* is nothing newer — a menu whose items appear and disappear makes a person hunt for
* the one they used last time, and "greyed out" already says "not now".
*/
private createMenu (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
const menu = createElement('details', 'menu')
const toggle = createElement('summary', 'icon-btn menu-toggle')
toggle.title = messages.moreActions
toggle.setAttribute('aria-label', messages.moreActions)
toggle.appendChild(this.createDotsIcon())
menu.appendChild(toggle)
const items = createElement('div', 'menu-items')
items.appendChild(this.createMenuItem(messages.upgrade, busy || !game.updateAvailable,
(): void => { this.callbacks.onUpgrade(game.name) }))
items.appendChild(this.createMenuItem(messages.uninstall, busy,
(): void => { this.callbacks.onRemove(game.name) }))
menu.appendChild(items)
return menu
}
private createMenuItem (
label: string,
disabled: boolean,
perform: () => void
): HTMLButtonElement {
const item = createElement('button', 'menu-item', label)
item.disabled = disabled
item.addEventListener('click', (): void => {
// Close before acting: the click starts work that re-renders the grid, and a menu
// left open would vanish mid-gesture rather than answer the press.
item.closest('details')?.removeAttribute('open')
perform()
})
return item
}
private createDotsIcon (): SVGSVGElement {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('class', 'icon icon-dots')
svg.setAttribute('viewBox', '0 0 16 16')
svg.setAttribute('aria-hidden', 'true')
svg.setAttribute('focusable', 'false')
for (const y of [3.5, 8, 12.5]) {
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
dot.setAttribute('cx', '8')
dot.setAttribute('cy', String(y))
dot.setAttribute('r', '1.35')
svg.appendChild(dot)
} }
return actions return svg
} }
} }
+3 -21
View File
@@ -3,7 +3,6 @@ import path from 'node:path'
import { GameDtoMapper } from '../application/mappers/GameDtoMapper' import { GameDtoMapper } from '../application/mappers/GameDtoMapper'
import type { CatalogListing } from '../domain/models/CatalogListing' import type { CatalogListing } from '../domain/models/CatalogListing'
import type { InstalledStore } from '../domain/models/InstalledStore' import type { InstalledStore } from '../domain/models/InstalledStore'
import type { RegistryStore } from '../domain/models/RegistryStore'
import { DESKTOP_STORE_ENGINE } from '../domain/models/StoreEngine' import { DESKTOP_STORE_ENGINE } from '../domain/models/StoreEngine'
import { deriveStoreId } from '../domain/models/StoreIdentity' import { deriveStoreId } from '../domain/models/StoreIdentity'
import { NativeStoreCatalogGateway } from '../infrastructure/engine/NativeStoreCatalogGateway' import { NativeStoreCatalogGateway } from '../infrastructure/engine/NativeStoreCatalogGateway'
@@ -66,34 +65,17 @@ class SmokeTest {
return return
} }
this.reportOk('registry', `${String(stores.length)} store(s) from ${this.registry.sourceUrl}`) this.reportOk('registry', `${String(stores.length)} store(s) from ${this.registry.sourceUrl}`)
// The slug is worth printing: it names the store home and the games subfolder, and
// it is derived here rather than told to us, so a wrong catalog URL shows up as a
// wrong folder name before anything is installed.
for (const store of stores) { for (const store of stores) {
this.reportOk(` ${store.name}`, `${store.catalogUrl} · ${deriveStoreId(store)}`) this.reportOk(` ${store.name}`, `${store.catalogUrl} · ${deriveStoreId(store)}`)
await this.checkStoreConfig(store)
} }
} catch (error: unknown) { } catch (error: unknown) {
this.reportBad('registry', `${this.registry.sourceUrl}: ${this.describe(error)}`) this.reportBad('registry', `${this.registry.sourceUrl}: ${this.describe(error)}`)
} }
} }
/**
* A store needs no repository, and a repository needs no config.json: either way
* the engine's defaults carry it. So both absences are reported, not failed.
*/
private async checkStoreConfig (store: RegistryStore): Promise<void> {
if (store.storeRepositoryUrl === null) {
this.reportOk(' config', 'no repository — the engine defaults would be used')
return
}
const url = `${store.storeRepositoryUrl.replace(/\/+$/, '')}/raw/branch/master/config.json`
try {
const config: unknown = JSON.parse(await this.httpClient.readText(url))
const sections = typeof config === 'object' && config !== null ? Object.keys(config).length : 0
this.reportOk(' config.json', `${String(sections)} sections`)
} catch (error: unknown) {
this.reportOk(' config.json', `absent (${this.describe(error)}) — defaults would be used`)
}
}
private findStore (): InstalledStore | null { private findStore (): InstalledStore | null {
const sandbox = process.env['SMOKE_HOME'] const sandbox = process.env['SMOKE_HOME']
if (sandbox !== undefined && sandbox.length > 0) { if (sandbox !== undefined && sandbox.length > 0) {
+1 -3
View File
@@ -2,8 +2,6 @@
export interface RegistryStoreDto { export interface RegistryStoreDto {
readonly name: string readonly name: string
readonly catalogUrl: string readonly catalogUrl: string
/** Null when the store has no repository of its own; the engine's defaults are then used. */ /** Derived from the catalog host, or the name — what the store will be called on disk. */
readonly storeRepositoryUrl: string | null
/** Derived from the repository, the catalog host or the name — what the store will be called on disk. */
readonly storeId: string readonly storeId: string
} }
+4 -3
View File
@@ -6,12 +6,13 @@
*/ */
export const ENGLISH_MESSAGES = { export const ENGLISH_MESSAGES = {
appName: 'WarpEngine Client', appName: 'WarpEngine Client',
refresh: 'Refresh', refresh: 'Refresh the catalog',
install: 'Install', install: 'Install',
update: 'Update', upgrade: 'Upgrade',
play: 'Play', play: 'Play',
open: 'Open', open: 'Open',
remove: 'Remove', uninstall: 'Uninstall',
moreActions: 'More actions',
installed: 'installed', installed: 'installed',
native: 'native', native: 'native',
hosted: 'hosted', hosted: 'hosted',
+4 -3
View File
@@ -6,12 +6,13 @@ import type { MessageBundle } from './MessageBundle'
*/ */
export const HUNGARIAN_MESSAGES: MessageBundle = { export const HUNGARIAN_MESSAGES: MessageBundle = {
appName: 'WarpEngine Client', appName: 'WarpEngine Client',
refresh: 'Frissítés', refresh: 'Katalógus frissítése',
install: 'Telepítés', install: 'Telepítés',
update: 'Frissítés', upgrade: 'Frissítés',
play: 'Indítás', play: 'Indítás',
open: 'Megnyitás', open: 'Megnyitás',
remove: 'Eltávolítás', uninstall: 'Eltávolítás',
moreActions: 'További műveletek',
installed: 'telepítve', installed: 'telepítve',
native: 'natív', native: 'natív',
hosted: 'hosztolt', hosted: 'hosztolt',