Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e35a72336a | ||
|
|
82590d3ec4 | ||
|
|
045c7bf5b7 |
@@ -62,35 +62,34 @@ and there is nothing to decide; several and the setup screen shows a picker.
|
||||
|
||||
```json
|
||||
[
|
||||
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org", "storeRepositoryUrl": null },
|
||||
{
|
||||
"name": "Some Other Store",
|
||||
"catalogUrl": "https://games.example.org",
|
||||
"storeRepositoryUrl": "https://git.example.org/stores/other-desktop-store"
|
||||
}
|
||||
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org" },
|
||||
{ "name": "Some Other Store", "catalogUrl": "https://games.example.org" }
|
||||
]
|
||||
```
|
||||
|
||||
**A store needs no repository of its own.** A name and a catalog are enough: the
|
||||
store engine's built-in defaults already cover the host-to-asset mapping, the
|
||||
install modes, the platforms and the behaviour, so what is actually missing from
|
||||
them is identity — a slug, a name and a catalog URL — and that is exactly what a
|
||||
registry record carries. With `storeRepositoryUrl` null the client writes a
|
||||
three-section config and the store installs.
|
||||
**A name and a catalog are the whole record.** The store engine's built-in defaults
|
||||
already cover the host-to-asset mapping, the install modes, the platforms and the
|
||||
behaviour, so what is actually missing from them is identity — and identity is all a
|
||||
registry says. Nothing a record carries decides where files go: how a store behaves is
|
||||
fixed per installed client, which knows its own machine, and a copy of that on a server
|
||||
would be a second authority over decisions this side has already made.
|
||||
|
||||
From a record the client works out the rest:
|
||||
|
||||
- **the store id** — which names the store home and the folder games land in —
|
||||
comes from the repository name when there is one (`ttg-desktop-store` becomes
|
||||
`ttg`), otherwise from the catalog host (`teletypegames.org` becomes
|
||||
`teletypegames`), otherwise from the display name. A `config.json` that sets its
|
||||
own id keeps it.
|
||||
- **`catalogUrl` and `name`** override the config's own `store.base_url` and
|
||||
`store.name`. The registry says which catalog this store is *for*, so it wins.
|
||||
- **`storeRepositoryUrl`**, when given → the store's `config.json`, read from
|
||||
`…/raw/branch/master/config.json`. That file stays the authority on how the store
|
||||
behaves: which platforms, which statuses, where things land. A repository
|
||||
**without** a `config.json` is treated as no repository at all.
|
||||
- **the store id** — which names the store home and the folder games land in — is a slug
|
||||
of the catalog host (`teletypegames.org` becomes `teletypegames`), or of the display
|
||||
name if that fails. 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.
|
||||
Reinstalling therefore never orphans what is already installed.
|
||||
- **the games folder** is that same slug inside the OS's usual place for programs, and it
|
||||
is the only subtree this store will ever delete from. That is the whole of how two
|
||||
stores on one machine stay out of each other's files: a subfolder, derived here.
|
||||
- **released, archived and demo** titles are listed, where the engine alone would show
|
||||
released and archived only — a catalog that publishes a demo means it to be played.
|
||||
|
||||
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
|
||||
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
|
||||
command succeeded immediately afterwards.
|
||||
|
||||
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
|
||||
whitespace is what broke the first attempt at publishing 1.1.0.
|
||||
Package names have no spaces in them — `WarpEngineClient-2.3.0-arm64.dmg` — because a
|
||||
space in a release asset is a space in every `curl`, script and shell command that ever
|
||||
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.
|
||||
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
|
||||
|
||||
**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
|
||||
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
|
||||
|
||||
+43
-57
@@ -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
|
||||
more.** Reading the catalog, choosing which release fits your computer, downloading and
|
||||
unpacking it, writing the menu entry and remembering what went where all happen inside
|
||||
the application now. There is no Python to find, no child process, and no JSON protocol
|
||||
between the two halves — which is why this is a major version rather than a feature.
|
||||
**A card says which version you have, and offers the newer one.** Where the catalog has
|
||||
moved on, an installed title's version line reads `0.1 → 0.3` instead of just the number
|
||||
on your disk, so the card answers both questions a person came to it with: what is
|
||||
installed, and is there anything better.
|
||||
|
||||
What that changes for a person: on Windows and on a fresh Mac the app simply works.
|
||||
Before it looked for `python3`, `python` and `py -3`, and where none answered it drew a
|
||||
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.
|
||||
Which version is installed was already recorded — that is what `state.json` has always
|
||||
been for. What was missing was somewhere to act on it.
|
||||
|
||||
**Your existing library is kept.** `config.json` and `state.json` on disk are unchanged —
|
||||
the same field names, the same `<scope>:<name>` keys, the same file modes — so a machine
|
||||
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 actions moved into a three-dot menu.** Installed cards now lead with **Play** (or
|
||||
**Open** for a hosted title) and put the rest behind the ⋮ button beside it:
|
||||
|
||||
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 |
|
||||
| nothing at all | reads it as the oldest supported version — an engine before 0.4.0 sent no header |
|
||||
| something older | the same, and says so in the log |
|
||||
| something newer | tries the newest dialect anyway, warning that titles may be missed |
|
||||
| **Upgrade** | fetches whatever the catalog now has, replacing the old payload and menu entry. Greyed out when there is nothing newer |
|
||||
| **Uninstall** | as before — the payload, the icon and the menu entry, and nothing else |
|
||||
|
||||
Adding a version to that list fails the build until somebody says what it reads like, in
|
||||
the type checker and in the linter both. A new engine version cannot arrive unnoticed.
|
||||
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".
|
||||
|
||||
**Refresh and the language picker are icons.** They sit together at the foot of the side
|
||||
menu, and the *Actions* heading that used to head a section of one button is gone. Both
|
||||
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
|
||||
showing.
|
||||
Playing stays the headline action even when an upgrade is waiting. The version on the disk
|
||||
still runs, and wanting to play it is not the same as wanting to wait for a download.
|
||||
|
||||
**Package names have no spaces.** `WarpEngineClient-2.3.0-arm64.dmg` rather than
|
||||
`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
|
||||
|
||||
No runtime dependencies, still: the zip reader the installer needs is about 150 lines over
|
||||
`node:zlib` rather than a package. It restores the executable bit from each entry's
|
||||
external attributes, which is what makes an unpacked game able to start at all, and it
|
||||
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.
|
||||
The catalog refresh control's tooltip now says *Refresh the catalog*, because in Hungarian
|
||||
it and the new Upgrade both wanted the word "Frissítés" and only one of them reloads a
|
||||
list.
|
||||
|
||||
### 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.
|
||||
|
||||
The new engine was measured against the old one rather than trusted. On the same catalog
|
||||
and the same config, the Python engine and this one produce **the same 13-title listing
|
||||
with zero field differences** and the same resolved paths. Installing three titles — a
|
||||
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 on a sandbox store rather than reasoned about. Two titles were
|
||||
installed, then one of their state records was rewritten to claim an older build — which is
|
||||
exactly what the engine compares — and the window was asked what it would offer:
|
||||
|
||||
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
|
||||
re-sync writes nothing. Remove, prune, prune-suppression on a named sync, and the v1→v2
|
||||
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.
|
||||
```
|
||||
BombExpert [newer] Upgrade:on Uninstall:on
|
||||
Rabbit Roller [current] Upgrade:off Uninstall:on
|
||||
```
|
||||
|
||||
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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "warp-engine-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.",
|
||||
"license": "MIT",
|
||||
"author": "Teletype Games <games@teletype.hu>",
|
||||
@@ -44,7 +44,11 @@
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
]
|
||||
],
|
||||
"artifactName": "WarpEngineClient-${version}-${arch}-mac.${ext}"
|
||||
},
|
||||
"dmg": {
|
||||
"artifactName": "WarpEngineClient-${version}-${arch}.${ext}"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
@@ -52,6 +56,12 @@
|
||||
"portable"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"artifactName": "WarpEngineClient-Setup-${version}-${arch}.${ext}"
|
||||
},
|
||||
"portable": {
|
||||
"artifactName": "WarpEngineClient-Portable-${version}-${arch}.${ext}"
|
||||
},
|
||||
"linux": {
|
||||
"category": "Game",
|
||||
"target": [
|
||||
@@ -59,6 +69,9 @@
|
||||
"deb"
|
||||
]
|
||||
},
|
||||
"appImage": {
|
||||
"artifactName": "WarpEngineClient-${version}-${arch}.${ext}"
|
||||
},
|
||||
"afterPack": "scripts/after-pack.js"
|
||||
},
|
||||
"allowScripts": {
|
||||
|
||||
@@ -53,9 +53,10 @@ api() {
|
||||
curl -fsS -X "$method" -H "$AUTH" "$FORGE$path" "$@"
|
||||
}
|
||||
|
||||
# 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
|
||||
# variable looped over with $list splits on the space and uploads nothing.
|
||||
# The list lives one path per line in a file and is read with `while IFS= read -r`. The
|
||||
# built package names have no spaces in them any more, but a path given on the command
|
||||
# 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)"
|
||||
trap 'rm -f "$LIST"' EXIT
|
||||
if [ "$#" -gt 0 ]; then
|
||||
|
||||
+4
-3
@@ -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
|
||||
# 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
|
||||
# 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.
|
||||
# - the spaces. The built names have none since 2.3.0 — `WarpEngineClient-2.3.0-arm64.dmg`
|
||||
# — but a path given as an argument still can, so the list stays one path per line in
|
||||
# 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)"
|
||||
trap 'rm -f "$LIST"' EXIT
|
||||
if [ "$#" -gt 0 ]; then
|
||||
|
||||
@@ -7,7 +7,6 @@ export class RegistryStoreDtoMapper {
|
||||
return {
|
||||
name: store.name,
|
||||
catalogUrl: store.catalogUrl,
|
||||
storeRepositoryUrl: store.storeRepositoryUrl,
|
||||
storeId: deriveStoreId(store)
|
||||
}
|
||||
}
|
||||
@@ -18,10 +17,6 @@ export class RegistryStoreDtoMapper {
|
||||
|
||||
/** The window hands a record straight back when asking for an install. */
|
||||
public toModel (dto: RegistryStoreDto): RegistryStore {
|
||||
return {
|
||||
name: dto.name,
|
||||
catalogUrl: dto.catalogUrl,
|
||||
storeRepositoryUrl: dto.storeRepositoryUrl
|
||||
}
|
||||
return { name: dto.name, catalogUrl: dto.catalogUrl }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,14 @@ export class StoreProvisioningService {
|
||||
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 (
|
||||
store: RegistryStore,
|
||||
progress?: EngineProgressListener
|
||||
@@ -38,4 +46,5 @@ export class StoreProvisioningService {
|
||||
const installed = await this.installer.installEngine(home, store, progress)
|
||||
return this.selection.adoptStore(installed)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* there is one it stays the authority on how that store behaves — which platforms
|
||||
* it offers, where things land — and when there is not, the engine's own defaults
|
||||
* cover all of it and this record covers the identity. That is the whole reason a
|
||||
* store needs no repository of its own.
|
||||
* That is the whole record, and it is enough. How a store behaves is not the registry's
|
||||
* business — this client carries its own store engine, whose defaults cover the
|
||||
* host-to-asset mapping, the install modes, the platforms and the behaviour — so what
|
||||
* was actually missing from those defaults is identity, and identity is all this is.
|
||||
*
|
||||
* 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 {
|
||||
readonly name: string
|
||||
readonly catalogUrl: string
|
||||
readonly storeRepositoryUrl: string | null
|
||||
}
|
||||
|
||||
@@ -3,28 +3,22 @@ import type { RegistryStore } from './RegistryStore'
|
||||
/**
|
||||
* 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
|
||||
* it has to be short and filesystem-safe. Three sources, in order of how much they
|
||||
* were meant to be a name:
|
||||
* The id names the store home, the folder games land in and the launcher files, so it
|
||||
* has to be short and filesystem-safe. Two sources, in order of how much they were
|
||||
* meant to be a name:
|
||||
*
|
||||
* 1. the repository name — `ttg-desktop-store` becomes `ttg`;
|
||||
* 2. the catalog host — `https://teletypegames.org` becomes `teletypegames`;
|
||||
* 3. the display name, slugged, as a last resort.
|
||||
* 1. the catalog host — `https://teletypegames.org` becomes `teletypegames`;
|
||||
* 2. 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 {
|
||||
const fromRepository = store.storeRepositoryUrl === null
|
||||
? ''
|
||||
: (lastSegment(store.storeRepositoryUrl).replace(/-(desktop-)?store$/, ''))
|
||||
return toSlug(fromRepository) || toSlug(readHostLabel(store.catalogUrl)) || toSlug(store.name) || 'store'
|
||||
return toSlug(readHostLabel(store.catalogUrl)) || toSlug(store.name) || 'store'
|
||||
}
|
||||
|
||||
function lastSegment (url: string): string {
|
||||
return url.replace(/\/+$/, '').split('/').pop() ?? ''
|
||||
}
|
||||
|
||||
/** `https://www.teletypegames.org/x` → `teletypegames`. */
|
||||
/** `https://www.teletypegames.org/x` -> `teletypegames`. */
|
||||
function readHostLabel (catalogUrl: string): string {
|
||||
try {
|
||||
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
|
||||
* the address of ours.
|
||||
*
|
||||
* A record needs a name and a catalog URL; those two make a store. The repository
|
||||
* is optional and arrives as null when absent — a store configured by nothing but
|
||||
* this record installs on the engine's defaults. Records missing either of the two
|
||||
* required fields are dropped rather than half-used.
|
||||
* A name and a catalog URL make a store, and are all a record carries. Anything else it
|
||||
* happens to say is ignored: how a store behaves is this client's own business, decided
|
||||
* by the engine it ships with. Records missing either field are dropped rather than
|
||||
* half-used.
|
||||
*/
|
||||
export class HttpStoreRegistryRepository implements StoreRegistryRepository {
|
||||
public readonly sourceUrl: string
|
||||
@@ -44,18 +44,12 @@ export class HttpStoreRegistryRepository implements StoreRegistryRepository {
|
||||
return parsed
|
||||
.map((row: unknown): JsonRecord | null => asRecord(row))
|
||||
.filter((row: JsonRecord | null): row is JsonRecord => row !== null)
|
||||
.map((row: JsonRecord): RegistryStore => {
|
||||
// Both spellings, because a registry is someone else's API: ours answers
|
||||
// camelCase, and a hand-rolled one may not.
|
||||
const repository = (
|
||||
readString(row, 'storeRepositoryUrl') || readString(row, 'store_repository_url')
|
||||
).trim()
|
||||
return {
|
||||
.map((row: JsonRecord): RegistryStore => ({
|
||||
name: readString(row, 'name').trim(),
|
||||
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim(),
|
||||
storeRepositoryUrl: repository.length > 0 ? repository : null
|
||||
}
|
||||
})
|
||||
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim()
|
||||
}))
|
||||
.filter((store: RegistryStore): boolean =>
|
||||
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 { deriveStoreId } from '../../domain/models/StoreIdentity'
|
||||
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 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. */
|
||||
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.
|
||||
*
|
||||
* Since the engine moved into this application there is nothing to download but the
|
||||
* store's own configuration, so an install is one HTTP call and one file. The store
|
||||
* home stays where it was and keeps its name, because the state and the catalog cache
|
||||
* beside that config are what make an existing library recognisable.
|
||||
* Nothing is downloaded and nothing is asked of a server. The engine ships in this
|
||||
* application and its defaults already cover the host-to-asset mapping, the install
|
||||
* modes, the platforms and the behaviour; what a registry record adds is identity — a
|
||||
* 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 {
|
||||
private readonly forgeBase: string
|
||||
|
||||
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 (
|
||||
public installEngine (
|
||||
home: string,
|
||||
store: RegistryStore,
|
||||
progress: EngineProgressListener = {}
|
||||
): Promise<InstalledStore> {
|
||||
fs.mkdirSync(home, { recursive: true })
|
||||
|
||||
const config = await this.readStoreConfig(store, progress)
|
||||
const storeId = deriveStoreId(store)
|
||||
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)
|
||||
|
||||
progress.onLog?.(`${store.name} is set up in ${home}`)
|
||||
const configStore = asRecord(config['store'])
|
||||
return {
|
||||
id: configStore === null ? deriveStoreId(store) : readString(configStore, 'id', deriveStoreId(store)),
|
||||
return Promise.resolve({
|
||||
id: storeId,
|
||||
name: store.name,
|
||||
home,
|
||||
configPath,
|
||||
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.
|
||||
*
|
||||
* A store home provisioned by 1.5.0 or by the shell installer holds two Python
|
||||
* files that nothing reads any more. They are harmless, but a directory that still
|
||||
* looks like it holds the engine invites someone to run it against a state file
|
||||
* this application is also writing.
|
||||
* A store home provisioned by 1.5.0 or by a shell installer holds two Python files
|
||||
* that nothing reads any more. They are harmless, but a directory that still looks
|
||||
* like it holds the engine invites someone to run it against a state file this
|
||||
* application is also writing.
|
||||
*/
|
||||
private removeRetiredEngine (home: string, progress: EngineProgressListener): void {
|
||||
for (const fileName of RETIRED_ENGINE_FILES) {
|
||||
@@ -73,79 +81,4 @@ export class NativeStoreEngineInstaller implements StoreEngineInstaller {
|
||||
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}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export class ServiceContainer {
|
||||
const stores = new FileSystemInstalledStoreRepository()
|
||||
const catalogGateway = new NativeStoreCatalogGateway()
|
||||
const registry = new HttpStoreRegistryRepository(httpClient)
|
||||
const installer = new NativeStoreEngineInstaller(httpClient)
|
||||
const installer = new NativeStoreEngineInstaller()
|
||||
const preferencesRepository = new JsonFilePreferencesRepository(environment)
|
||||
|
||||
const preferences = new PreferencesService(preferencesRepository, environment)
|
||||
|
||||
@@ -28,6 +28,8 @@ interface SelfTestReport {
|
||||
readonly locales: readonly string[]
|
||||
/** `<accessible name>:<glyph count>` per icon-only control in the footer. */
|
||||
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. */
|
||||
@@ -74,7 +76,11 @@ export class SelfTestRunner {
|
||||
// picker are the only way to reach those two actions now that neither has a label.
|
||||
const iconsNamed = report.iconControls.length === 2 &&
|
||||
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.categories.length > 0 && report.activeCategory !== null) ||
|
||||
(report.gateVisible && report.gateAction.length > 0))
|
||||
@@ -107,6 +113,16 @@ export class SelfTestRunner {
|
||||
locales: [...document.getElementById('locale').options].map((option) => option.value),
|
||||
// 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.
|
||||
// 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')]
|
||||
.map((control) => {
|
||||
const named = control.getAttribute('aria-label') || control.getAttribute('title') ||
|
||||
@@ -132,7 +148,8 @@ export class SelfTestRunner {
|
||||
paths: readString(record, 'paths'),
|
||||
logLines: readNumber(record, 'logLines'),
|
||||
locales: readStringArray(record, 'locales'),
|
||||
iconControls: readStringArray(record, 'iconControls')
|
||||
iconControls: readStringArray(record, 'iconControls'),
|
||||
cardMenus: readStringArray(record, 'cardMenus')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,12 +27,9 @@ export function requireStringArray (value: unknown, name: string): readonly stri
|
||||
export function requireRegistryStore (value: unknown): RegistryStoreDto {
|
||||
const record = asRecord(value)
|
||||
if (record === null) throw new TypeError('a store record is required')
|
||||
const repository = readString(record, 'storeRepositoryUrl')
|
||||
const store: RegistryStoreDto = {
|
||||
name: readString(record, 'name'),
|
||||
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')
|
||||
}
|
||||
if (store.name.length === 0 || store.catalogUrl.length === 0) {
|
||||
|
||||
@@ -47,6 +47,9 @@ export class RendererApplication {
|
||||
|
||||
this.grid = new CatalogGridView(new GameCardView({
|
||||
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) },
|
||||
onRemove: (name: string): void => { void this.catalog.removeGame(name) }
|
||||
}))
|
||||
|
||||
@@ -357,6 +357,56 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
}
|
||||
.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 ----------------------------------------------------------------
|
||||
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. */
|
||||
|
||||
@@ -9,7 +9,26 @@ export class CatalogGridView {
|
||||
private readonly grid = requireElement('grid', 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 {
|
||||
const shown = state.games.filter((game: GameDto): boolean => matchesFilter(game, state.filter))
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createElement } from '../dom/Dom'
|
||||
|
||||
export interface GameCardViewCallbacks {
|
||||
readonly onInstall: (name: string) => void
|
||||
readonly onUpgrade: (name: string) => void
|
||||
readonly onLaunch: (name: string) => void
|
||||
readonly onRemove: (name: string) => void
|
||||
}
|
||||
@@ -11,8 +12,11 @@ export interface GameCardViewCallbacks {
|
||||
/**
|
||||
* One card.
|
||||
*
|
||||
* A card is a function of a title and the strings: it holds no state of its own, so
|
||||
* the grid can throw the lot away and rebuild after every listing.
|
||||
* A card is a function of a title and the strings: it holds no state of its own, so the
|
||||
* 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 {
|
||||
public constructor (private readonly callbacks: GameCardViewCallbacks) {}
|
||||
@@ -71,13 +75,30 @@ export class GameCardView {
|
||||
meta.appendChild(badge)
|
||||
}
|
||||
meta.appendChild(createElement('span', 'badge badge-plain', game.platform))
|
||||
meta.appendChild(createElement('span', 'version',
|
||||
game.installed && game.installedVersion !== null
|
||||
? `${game.installedVersion} · ${messages.installed}`
|
||||
: game.version))
|
||||
meta.appendChild(this.createVersion(game, messages))
|
||||
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 {
|
||||
const actions = createElement('div', 'actions')
|
||||
|
||||
@@ -89,25 +110,88 @@ export class GameCardView {
|
||||
return actions
|
||||
}
|
||||
|
||||
const primary = createElement('button', 'btn btn-primary')
|
||||
primary.disabled = busy
|
||||
actions.appendChild(this.createPrimary(game, messages, 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.disabled = busy || !game.launchable
|
||||
primary.addEventListener('click', (): void => { this.callbacks.onLaunch(game.name) })
|
||||
} else {
|
||||
primary.textContent = game.updateAvailable ? messages.update : messages.install
|
||||
return primary
|
||||
}
|
||||
primary.textContent = messages.install
|
||||
primary.disabled = busy
|
||||
primary.addEventListener('click', (): void => { this.callbacks.onInstall(game.name) })
|
||||
return primary
|
||||
}
|
||||
actions.appendChild(primary)
|
||||
|
||||
if (game.installed) {
|
||||
const remove = createElement('button', 'btn btn-ghost', messages.remove)
|
||||
remove.disabled = busy
|
||||
remove.addEventListener('click', (): void => { this.callbacks.onRemove(game.name) })
|
||||
actions.appendChild(remove)
|
||||
/**
|
||||
* The three-dot menu: upgrade, and uninstall.
|
||||
*
|
||||
* A `<details>` rather than a scripted popover, so the open state is the DOM's and the
|
||||
* 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
|
||||
}
|
||||
return actions
|
||||
|
||||
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 svg
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from 'node:path'
|
||||
import { GameDtoMapper } from '../application/mappers/GameDtoMapper'
|
||||
import type { CatalogListing } from '../domain/models/CatalogListing'
|
||||
import type { InstalledStore } from '../domain/models/InstalledStore'
|
||||
import type { RegistryStore } from '../domain/models/RegistryStore'
|
||||
import { DESKTOP_STORE_ENGINE } from '../domain/models/StoreEngine'
|
||||
import { deriveStoreId } from '../domain/models/StoreIdentity'
|
||||
import { NativeStoreCatalogGateway } from '../infrastructure/engine/NativeStoreCatalogGateway'
|
||||
@@ -66,34 +65,17 @@ class SmokeTest {
|
||||
return
|
||||
}
|
||||
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) {
|
||||
this.reportOk(` ${store.name}`, `${store.catalogUrl} · ${deriveStoreId(store)}`)
|
||||
await this.checkStoreConfig(store)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
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 {
|
||||
const sandbox = process.env['SMOKE_HOME']
|
||||
if (sandbox !== undefined && sandbox.length > 0) {
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
export interface RegistryStoreDto {
|
||||
readonly name: string
|
||||
readonly catalogUrl: string
|
||||
/** Null when the store has no repository of its own; the engine's defaults are then used. */
|
||||
readonly storeRepositoryUrl: string | null
|
||||
/** Derived from the repository, the catalog host or the name — what the store will be called on disk. */
|
||||
/** Derived from the catalog host, or the name — what the store will be called on disk. */
|
||||
readonly storeId: string
|
||||
}
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
*/
|
||||
export const ENGLISH_MESSAGES = {
|
||||
appName: 'WarpEngine Client',
|
||||
refresh: 'Refresh',
|
||||
refresh: 'Refresh the catalog',
|
||||
install: 'Install',
|
||||
update: 'Update',
|
||||
upgrade: 'Upgrade',
|
||||
play: 'Play',
|
||||
open: 'Open',
|
||||
remove: 'Remove',
|
||||
uninstall: 'Uninstall',
|
||||
moreActions: 'More actions',
|
||||
installed: 'installed',
|
||||
native: 'native',
|
||||
hosted: 'hosted',
|
||||
|
||||
@@ -6,12 +6,13 @@ import type { MessageBundle } from './MessageBundle'
|
||||
*/
|
||||
export const HUNGARIAN_MESSAGES: MessageBundle = {
|
||||
appName: 'WarpEngine Client',
|
||||
refresh: 'Frissítés',
|
||||
refresh: 'Katalógus frissítése',
|
||||
install: 'Telepítés',
|
||||
update: 'Frissítés',
|
||||
upgrade: 'Frissítés',
|
||||
play: 'Indítás',
|
||||
open: 'Megnyitás',
|
||||
remove: 'Eltávolítás',
|
||||
uninstall: 'Eltávolítás',
|
||||
moreActions: 'További műveletek',
|
||||
installed: 'telepítve',
|
||||
native: 'natív',
|
||||
hosted: 'hosztolt',
|
||||
|
||||
Reference in New Issue
Block a user