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