Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8511ccbef8 | ||
|
|
a364a5ce5f | ||
|
|
7026e0cc6a | ||
|
|
2f725fdd14 | ||
|
|
526c67b069 |
@@ -0,0 +1,73 @@
|
||||
# The pipeline lives in the repository rather than in the update server's
|
||||
# `/build/config` extension. That extension serves game-platform pipelines, which
|
||||
# build a cartridge and publish it into the site's catalog; this one builds a desktop
|
||||
# application and publishes it to a Gitea release. Different product, different target.
|
||||
#
|
||||
# What CI can and cannot do here: Linux and Windows packages are built in containers —
|
||||
# Windows through Wine — while the **macOS package stays a local build**, because
|
||||
# Apple's toolchain and its signing exist only on a Mac. A release therefore gets its
|
||||
# Linux and Windows assets from this pipeline and its macOS assets from `make release`.
|
||||
when:
|
||||
- event: [push, manual]
|
||||
branch: master
|
||||
- event: tag
|
||||
|
||||
variables:
|
||||
# The official electron-builder images: Node with the packaging tools, and the same
|
||||
# image plus Wine, which is what lets a Windows installer be built on Linux.
|
||||
- &node_image 'electronuserland/builder:22'
|
||||
- &wine_image 'electronuserland/builder:22-wine'
|
||||
|
||||
steps:
|
||||
- name: check
|
||||
image: *node_image
|
||||
commands:
|
||||
- node --version
|
||||
- npm ci
|
||||
- npm run typecheck
|
||||
- npm run lint
|
||||
# The window test wants a display and a store on the machine; that check belongs
|
||||
# where there is one. The bridge check is worth running here: it exercises the
|
||||
# registry and the message bundles.
|
||||
- |
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
npm run smoke
|
||||
else
|
||||
echo "no python3 in the image — the smoke test needs it, skipping"
|
||||
fi
|
||||
|
||||
# A quarter of a gigabyte of packages is not worth building on every push, so the
|
||||
# two builds run when a release is being cut — or when asked for by hand.
|
||||
- name: linux
|
||||
image: *node_image
|
||||
commands:
|
||||
- npm run dist:linux
|
||||
- scripts/ci-verify-packages.sh '*.AppImage' '*.deb'
|
||||
when:
|
||||
- event: [tag, manual]
|
||||
|
||||
- name: windows
|
||||
image: *wine_image
|
||||
commands:
|
||||
- npm run dist:win
|
||||
- scripts/ci-verify-packages.sh '*.exe'
|
||||
when:
|
||||
- event: [tag, manual]
|
||||
|
||||
# Only on a tag, and only what this pipeline built: the macOS assets are uploaded
|
||||
# from the Mac that can sign them.
|
||||
- name: release
|
||||
image: alpine
|
||||
environment:
|
||||
# Needed. Woodpecker does hand steps a forge credential — a manual build printed
|
||||
# one — but a build started by the tag webhook does not get it: the first tag build
|
||||
# died here with no credential at all. So the token is a repository secret, and the
|
||||
# script still falls back to the forge credential when it is there.
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
commands:
|
||||
- apk add --no-cache curl jq
|
||||
# No globs on the command line: the package names have spaces in them.
|
||||
- scripts/ci-upload.sh
|
||||
when:
|
||||
- event: tag
|
||||
@@ -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
|
||||
# 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"])')
|
||||
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
|
||||
|
||||
.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
|
||||
|
||||
help: ## List available targets
|
||||
@echo "WarpEngine Store GUI $(VERSION) — usage: make <target>"
|
||||
@echo "WarpEngine Client $(VERSION) — usage: make <target>"
|
||||
@echo
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \
|
||||
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
|
||||
|
||||
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)
|
||||
npm run dist:mac
|
||||
npm run dist:mac $(BUILDER_ARGS)
|
||||
|
||||
dist-win: node-check ## Package for Windows
|
||||
npm run dist:win
|
||||
npm run dist:win $(BUILDER_ARGS)
|
||||
|
||||
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
|
||||
@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 "tea "; tea --version 2>/dev/null | head -1 || echo "missing — devarea: make tea"
|
||||
@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,9 +1,10 @@
|
||||
# warp-engine-desktop-gui — a window for the desktop store
|
||||
# warp-engine-client — the WarpEngine Client app
|
||||
|
||||
A graphical client for
|
||||
[`warp-engine-desktop-store`](https://git.teletypegames.org/stores/warp-engine-desktop-store):
|
||||
the catalog as a grid of cards, one click to install a title into your own
|
||||
application menu, one to play it, one to remove it. Linux, macOS and Windows.
|
||||
The graphical client for a WarpEngine store — the app is called **WarpEngine
|
||||
Store** — driving
|
||||
[`warp-engine-desktop-store`](https://git.teletypegames.org/stores/warp-engine-desktop-store)
|
||||
underneath: the catalog as a grid of cards, one click to install a title into your
|
||||
own application menu, one to play it, one to remove it. Linux, macOS and Windows.
|
||||
|
||||
The CLI stays the product; this is its front door. Every action here runs
|
||||
`desktop_store.py`, so there is one catalog logic, one state file and one delete
|
||||
@@ -30,7 +31,7 @@ toolchain (TypeScript, ESLint, esbuild, electron-builder) installs with `make se
|
||||
## Install
|
||||
|
||||
Grab the package for your machine from the
|
||||
[releases](https://git.teletypegames.org/stores/warp-engine-desktop-gui/releases)
|
||||
[releases](https://git.teletypegames.org/stores/warp-engine-client/releases)
|
||||
and open it. On first run, if there is no store on the machine yet, the window
|
||||
offers to download one — that is the whole setup.
|
||||
|
||||
@@ -40,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:
|
||||
|
||||
```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
|
||||
@@ -63,36 +64,53 @@ 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": "Teletype Games",
|
||||
"catalogUrl": "https://teletypegames.org",
|
||||
"storeRepositoryUrl": "https://git.teletypegames.org/stores/ttg-desktop-store"
|
||||
"name": "Some Other Store",
|
||||
"catalogUrl": "https://games.example.org",
|
||||
"storeRepositoryUrl": "https://git.example.org/stores/other-desktop-store"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**A store needs no repository of its own.** A name and a catalog are enough: the
|
||||
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.
|
||||
|
||||
From a record the client works out the rest:
|
||||
|
||||
- **`storeRepositoryUrl`** → the store's `config.json`, read from
|
||||
`…/raw/branch/master/config.json`. That file is the authority on how the store
|
||||
behaves: which platforms, which statuses, where things land.
|
||||
- **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.
|
||||
- **the store id** — which names the store home and the folder games land in —
|
||||
comes from the repository name: `ttg-desktop-store` becomes `ttg`. A
|
||||
`config.json` that sets its own id keeps it.
|
||||
- **`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.
|
||||
|
||||
A repository **without** a `config.json` still works. The engine merges whatever
|
||||
it is handed onto its own defaults, so the client writes a three-field config and
|
||||
the store behaves like the default one pointed at that catalog.
|
||||
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
|
||||
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
|
||||
client, and `STORES_API` overrides it:
|
||||
The registry address is the single thing about a particular site left in the client,
|
||||
and it is decided in three places, most specific first:
|
||||
|
||||
```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
|
||||
panel — and not a release of this app.
|
||||
|
||||
@@ -107,8 +125,8 @@ Everything that is not a title lives in the **side menu** on the left, and the
|
||||
catalog into different folders show their folder instead of their id, because
|
||||
the id would not tell them apart. **Add a store…** brings up the registry
|
||||
picker, the same one the first run offers.
|
||||
- **Actions** holds **Install all**, which fetches everything the catalog offers
|
||||
for this machine, and **Refresh**, which re-reads the catalog.
|
||||
- **Actions** holds **Refresh**, which re-reads the catalog. Titles are installed
|
||||
one at a time from their own cards; there is no install-everything button.
|
||||
- **Categories** narrows the grid, one category at a time, with the count next to
|
||||
each: *Everything*, *Installed*, *Updates*, *Not installed*, then a row per
|
||||
**platform** (`godot`, `tic80`, `love`, …) and per **kind** (native or hosted).
|
||||
@@ -116,6 +134,9 @@ Everything that is not a title lives in the **side menu** on the left, and the
|
||||
titles is not listed, and a category that disappears under you falls back to
|
||||
*Everything* rather than leaving an empty grid. There is no genre in a
|
||||
WarpEngine catalog, so these are the categories there are.
|
||||
- **Log** opens the store's own output — its words, verbatim — together with the two
|
||||
folders everything lands in. Off screen until asked for: the window has no footer,
|
||||
because a permanent bar of absolute paths is not what a store is for.
|
||||
- **Language** follows the system and can be switched; **English and Hungarian**.
|
||||
|
||||
In the grid, a card's button is **Install**, **Update**, or **Play** / **Open**
|
||||
@@ -124,8 +145,13 @@ 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
|
||||
the network.
|
||||
|
||||
The **Log** drawer at the bottom carries the store's own output verbatim, and next
|
||||
to it are buttons that open the two folders everything lands in.
|
||||
**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
|
||||
title when the catalog has no image — so titles and buttons line up across a row.
|
||||
@@ -167,16 +193,77 @@ The npm scripts still work directly (`npm start`, `npm run dist:mac`) — the
|
||||
Makefile adds no logic of its own beyond the release step. Every script that runs the
|
||||
app builds first, so there is no way to test a stale bundle.
|
||||
|
||||
### Continuous integration
|
||||
|
||||
`.woodpecker.yaml` builds the **Linux and Windows** packages, and on a tag attaches
|
||||
them to the Gitea release. The pipeline is in this repository rather than served by the
|
||||
update server's `/build/config` extension: that extension serves game-platform
|
||||
pipelines, which build a cartridge and publish it into the site's catalog, and this
|
||||
builds an application and publishes to a release.
|
||||
|
||||
| Step | Image | What it does |
|
||||
|---|---|---|
|
||||
| `check` | `electronuserland/builder:22` | `npm ci`, type-check, lint, and the smoke test |
|
||||
| `linux` | `electronuserland/builder:22` | AppImage and deb |
|
||||
| `windows` | `electronuserland/builder:22-wine` | the NSIS installer and the portable exe, built through Wine |
|
||||
| `release` | `alpine` | on a tag only: **creates the release** and attaches what this pipeline built |
|
||||
|
||||
**macOS stays a local build.** Apple's toolchain and its signing only exist on a Mac.
|
||||
So the whole of a release is:
|
||||
|
||||
1. bump the version, commit, and push the tag: `git tag v1.4.0 && git push origin v1.4.0`;
|
||||
2. the pipeline builds Linux and Windows, **creates the release** with `RELEASE_NOTES.md`
|
||||
as its body, and attaches those four packages;
|
||||
3. on a Mac, `make release` builds the macOS package and pushes it onto the same release.
|
||||
|
||||
The window test is local as well: it needs a display and a store on the machine.
|
||||
|
||||
The `release` step needs a **`gitea_token`** repository secret — a Gitea token with
|
||||
write access to this repository:
|
||||
|
||||
```sh
|
||||
woodpecker-cli repo secret add --repository stores/warp-engine-client \
|
||||
--name gitea_token --value <token> --event tag
|
||||
```
|
||||
|
||||
Woodpecker does hand steps a forge credential of its own, and the script uses it when the
|
||||
secret is absent, but that is not something to rely on: a **manual** build has it and a
|
||||
build started by the **tag webhook** does not, which is how the first tag build failed —
|
||||
after building all four packages. Gitea takes either credential as `token …` or
|
||||
`Bearer …` depending on how it was issued, so the script probes which of the two `/user`
|
||||
accepts instead of assuming, and logs which one it used.
|
||||
|
||||
There are two publishers on purpose: `scripts/release.sh` drives `tea`, which is logged
|
||||
in on a workstation, and `scripts/ci-upload.sh` speaks the API with whatever credential
|
||||
CI has. Each is short enough to read in full; one script with two ways to authenticate
|
||||
would not be.
|
||||
|
||||
Both build steps end by checking what they produced: a package under 10 MB did not
|
||||
finish. That check exists because a half-finished Wine build leaves a stub *named* like
|
||||
the real installer — 162 KB of it — and `ls` is perfectly happy with that.
|
||||
|
||||
**The Windows step cannot be rehearsed on an Apple Silicon Mac.** Wine assumes 4 KB
|
||||
memory pages and this host has 16 KB ones, so an emulated amd64 container dies with
|
||||
`anon_mmap_fixed: Assertion failed`. It is a property of the machine, not of the
|
||||
pipeline; the x86_64 runner is where that step is proven. The Linux step was rehearsed
|
||||
locally in the same image and produced both packages.
|
||||
|
||||
The Windows installer is **not signed**: Windows will warn about an unknown publisher
|
||||
until there is a code-signing certificate. Linux packages carry no signature by
|
||||
convention.
|
||||
|
||||
### Publishing a release
|
||||
|
||||
```sh
|
||||
make release
|
||||
```
|
||||
|
||||
The tag comes from `package.json`, so `npm version patch` is the only place a
|
||||
version is set. The release is created if it is not there yet, and an attachment
|
||||
whose name is already on it is **replaced** rather than refused — so a rebuild and
|
||||
a second `make publish` lands rather than erroring.
|
||||
This is the **macOS half** of a release; the Linux and Windows packages come from the
|
||||
pipeline when the tag is pushed (see above). The tag comes from `package.json`, so
|
||||
`npm version patch` is the only place a version is set. The release is created if it is
|
||||
not there yet — either half can go first — and an attachment whose name is already on it
|
||||
is **replaced** rather than refused, so a rebuild and a second `make publish` lands
|
||||
rather than erroring.
|
||||
|
||||
Release notes come from `RELEASE_NOTES.md` when the file is present, otherwise the
|
||||
release gets a one-line note. The repository is read from `origin`, so a fork
|
||||
@@ -188,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
|
||||
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
|
||||
whitespace is what broke the first attempt at publishing 1.1.0.
|
||||
|
||||
@@ -258,6 +345,21 @@ there.
|
||||
|
||||
## Verified, and not
|
||||
|
||||
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
|
||||
produces the AppImage (128 MB) and the deb (100 MB). The Wine step could not be
|
||||
rehearsed here — see above — and the size check that guards it was tested against both
|
||||
outcomes: it rejects the 162 KB stub the failed Wine build left and accepts the two real
|
||||
Linux packages.
|
||||
|
||||
A store with no repository was installed end to end from a local registry serving
|
||||
one record with `storeRepositoryUrl: null`: the id came out as `teletypegames`, the
|
||||
engine and the shared core downloaded, the written config had the three sections,
|
||||
engine 1.1.0 accepted it, and it listed the same ten titles the configured store
|
||||
does — then a hosted title synced into a sandbox and its menu entry appeared. The
|
||||
setup gate was also photographed on a machine with no store at all.
|
||||
|
||||
The 1.3.0 refactor was measured rather than trusted: `make check` is clean — no type
|
||||
errors, no lint findings, both test suites green — the window was photographed before
|
||||
and after and the two are the same picture, and the packaged 1.3.0 bundle was run from
|
||||
|
||||
+35
-43
@@ -1,63 +1,55 @@
|
||||
# WarpEngine Store 1.3.0
|
||||
# WarpEngine Client 1.5.0
|
||||
|
||||
**TypeScript, in layers.** The client was one `main.js`, one `preload.js`, three files
|
||||
in `lib/` and one renderer script. It is now a typed application with the dependency
|
||||
rule pointing inward: `domain` (models, ports, errors) knows nothing about Electron,
|
||||
Node or Python; `application` orchestrates it through those ports; `infrastructure`
|
||||
holds the adapters — the Python CLI, HTTP, the filesystem, Electron itself — and the
|
||||
hosts (`main`, `preload`, `renderer`) sit on top. **[STRUCTURE.md](STRUCTURE.md)** is
|
||||
the map: every layer, every pattern in use, and the naming rules, written to be read
|
||||
before adding anything.
|
||||
**The app is called WarpEngine Client.** It was WarpEngine Store, which 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 packages, the bundle and the menu entry all
|
||||
say Client now; the repository already did. The store the window is driving is named in
|
||||
the side menu, so the bar no longer repeats it as a badge.
|
||||
|
||||
Nothing about the window changed. Same side menu, same categories, same switcher, same
|
||||
two languages — this release is the inside of the app.
|
||||
**Every title in the catalog is listed, including the ones this machine cannot install.**
|
||||
A C64 cartridge on a desktop, or a game with no build for your operating system, used to
|
||||
be silently absent — leaving you to wonder whether the catalog is small or your machine
|
||||
is unusual. Those cards are now there, dimmed, with an *unsupported platform* or *no
|
||||
build for this machine* badge and the engine's own sentence underneath, and with nothing
|
||||
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.
|
||||
|
||||
Two properties came out of the move, and both are worth having:
|
||||
This needs store engine **1.2.0** (desktop) on **warpstore 1.4.0**, which report what
|
||||
they had to leave out and why. An older engine still works — its listing is simply all
|
||||
installable, as it was before.
|
||||
|
||||
- **The catalog can be driven with no window and no Electron at all.** `make smoke`
|
||||
assembles the same services against the same ports in a plain Node process. It was a
|
||||
script that reimplemented the bridge before; now it is a second composition root.
|
||||
- **The window never receives a filesystem path.** A title crosses the bridge without
|
||||
one, and launching is asked for *by name* — the main process resolves what that means
|
||||
from the store's own state. Nothing in the renderer can be talked into opening a path.
|
||||
**A build can be pointed at another site's registry.**
|
||||
|
||||
**A strict linter, and types everywhere.** `strict` plus
|
||||
`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `noImplicitOverride`,
|
||||
`noPropertyAccessFromIndexSignature` and friends; typescript-eslint's
|
||||
`strictTypeChecked` and `stylisticTypeChecked` sets; explicit return types, parameter
|
||||
types and property types required even where inference would manage; exhaustive
|
||||
switches; no `any`, no `!`, no casts on foreign data — engine output and the registry
|
||||
go through readers that turn `unknown` into typed values. The naming patterns are
|
||||
enforced by `naming-convention` rather than trusted.
|
||||
```sh
|
||||
make dist STORES_API=https://games.example.org/api/stores
|
||||
```
|
||||
|
||||
Two things the types now catch that a person used to: a translation with a missing key
|
||||
does not compile, and a channel the preload does not implement does not compile.
|
||||
The address is written into the packaged app's own `package.json`, so a client built for
|
||||
somebody else's catalog needs no source change and nothing set on the user's machine. A
|
||||
runtime `STORES_API` still wins, which is for trying something out rather than shipping.
|
||||
|
||||
**New make targets:** `make build`, `make typecheck`, `make lint`, `make lint-fix` and
|
||||
`make check` — the gate, which runs the type-check, the linter and both test suites in
|
||||
that order, cheapest failure first. Every script that runs the app builds first, so a
|
||||
stale bundle cannot be tested.
|
||||
### Also
|
||||
|
||||
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.
|
||||
|
||||
### Opening it on macOS
|
||||
|
||||
Ad-hoc signed, **not notarised**, so macOS asks first:
|
||||
|
||||
```sh
|
||||
xattr -dr com.apple.quarantine "/Applications/WarpEngine Store.app"
|
||||
xattr -dr com.apple.quarantine "/Applications/WarpEngine Client.app"
|
||||
```
|
||||
|
||||
*Open Anyway* under **System Settings ▸ Privacy & Security** works as well.
|
||||
|
||||
### What is attached
|
||||
|
||||
**macOS arm64 only**, the machine this was built and verified on. Windows and Linux
|
||||
packages need a build on those platforms (`make dist-win` / `make dist-linux`).
|
||||
The macOS package, built and verified on a Mac, plus the Linux (AppImage, deb) and
|
||||
Windows (installer, portable) packages the pipeline builds when the tag is pushed.
|
||||
|
||||
### Verified
|
||||
|
||||
`make check` is clean: no type errors, no lint findings, the smoke test green against
|
||||
the real store and against a sandbox one, and the window test green with one store and
|
||||
with two — where it clicks the store that is not open and checks that the bar, the grid
|
||||
and the categories follow. The window was photographed before and after the refactor
|
||||
and the two are the same picture. The packaged app was run from the built bundle, not
|
||||
from a dev launch.
|
||||
`make check` is clean. The store on this machine lists 13 titles — ten installable, three
|
||||
C64 cartridges this store does not carry — and the window was photographed with all
|
||||
thirteen on screen, the three dimmed and labelled. The engine change was measured through
|
||||
the CLI as well, in both its human and its `--json` listing, and the other two store
|
||||
engines still get the two-value answer they ask for.
|
||||
|
||||
@@ -62,6 +62,7 @@ src/
|
||||
mappers/ engine JSON → domain
|
||||
http/ HttpTextClient, HttpStatusError
|
||||
json/ JsonRecord: reading data that came from elsewhere
|
||||
config/ BuildConfiguration: what was decided when this was packaged
|
||||
electron/ ApplicationEnvironment and GameLauncher adapters
|
||||
main/
|
||||
main.ts the entry point: one line of work
|
||||
@@ -161,6 +162,11 @@ after what it changes and notifies afterwards; `RendererApplication` re-renders
|
||||
view from the new state. Views never read each other and never hold state, so a
|
||||
listing can be thrown away and rebuilt.
|
||||
|
||||
Screens are state, not calls. The setup screen lives in the state as
|
||||
`gate: GatePresentation | null`, and that one field decides whether the gate or the
|
||||
grid is drawn. While it was two imperative calls the two disagreed: the gate went up
|
||||
and the empty-catalog line stayed on screen underneath it.
|
||||
|
||||
### Passive view
|
||||
|
||||
`renderer/views/*` — a view takes its DOM nodes and callbacks in the constructor and
|
||||
@@ -179,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
|
||||
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
|
||||
|
||||
Anything parsed from outside — engine stdout, the registry — goes through
|
||||
|
||||
Generated
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "warp-engine-desktop-gui",
|
||||
"name": "warp-engine-client",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "warp-engine-desktop-gui",
|
||||
"name": "warp-engine-client",
|
||||
"version": "1.2.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
|
||||
+9
-6
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "warp-engine-desktop-gui",
|
||||
"productName": "WarpEngine Store",
|
||||
"version": "1.3.0",
|
||||
"description": "Graphical client for a WarpEngine desktop store: install the catalog into your own application menu.",
|
||||
"name": "warp-engine-client",
|
||||
"productName": "WarpEngine Client",
|
||||
"version": "1.5.0",
|
||||
"description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.",
|
||||
"license": "MIT",
|
||||
"author": "Teletype Games <games@teletype.hu>",
|
||||
"homepage": "https://git.teletypegames.org/stores/warp-engine-desktop-gui",
|
||||
"homepage": "https://git.teletypegames.org/stores/warp-engine-client",
|
||||
"main": "build/main/main.js",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"build": {
|
||||
"appId": "org.teletypegames.warpstore.gui",
|
||||
"productName": "WarpEngine Store",
|
||||
"productName": "WarpEngine Client",
|
||||
"files": [
|
||||
"build/**/*",
|
||||
"package.json"
|
||||
@@ -64,5 +64,8 @@
|
||||
"allowScripts": {
|
||||
"electron@43.4.0": true,
|
||||
"esbuild@0.28.2": true
|
||||
},
|
||||
"warpEngine": {
|
||||
"registryUrl": "https://teletypegames.org/api/stores"
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+114
@@ -0,0 +1,114 @@
|
||||
#!/bin/sh
|
||||
# Attach built packages to the Gitea release for this tag.
|
||||
#
|
||||
# The local publisher (scripts/release.sh) drives `tea`, which is logged in
|
||||
# interactively on a workstation. CI has no such session: it has a token and curl. The
|
||||
# two are deliberately separate scripts rather than one with two ways to authenticate —
|
||||
# each is short enough to read in full.
|
||||
#
|
||||
# scripts/ci-upload.sh every package in dist/
|
||||
# scripts/ci-upload.sh dist/one.deb just these
|
||||
#
|
||||
# Authenticates with the `gitea_token` secret when there is one, and otherwise with the
|
||||
# credential Woodpecker gives every step for cloning — so a release needs no secret.
|
||||
#
|
||||
# Creates the release when the tag has none, with RELEASE_NOTES.md as its body. That is
|
||||
# the flow: a `vX.Y.Z` tag starts this pipeline, which publishes the release with the
|
||||
# Linux and Windows packages in it, and the macOS package is pushed on top afterwards by
|
||||
# `make release` from a Mac.
|
||||
set -eu
|
||||
|
||||
FORGE="${FORGE_API:-https://git.teletypegames.org/api/v1}"
|
||||
REPO="${REPO:-${CI_REPO:-}}"
|
||||
TAG="${TAG:-${CI_COMMIT_TAG:-}}"
|
||||
DIST="${DIST:-dist}"
|
||||
NOTES="${NOTES:-RELEASE_NOTES.md}"
|
||||
|
||||
say() { echo "[ci-upload] $*"; }
|
||||
die() { echo "[ci-upload] error: $*" >&2; exit 1; }
|
||||
|
||||
# Who to be. A `gitea_token` secret wins when there is one; otherwise the credential
|
||||
# Woodpecker already hands every step for cloning is used, which is an access token of
|
||||
# the repository's owner — so publishing needs no secret of its own. Gitea accepts a
|
||||
# personal access token as `token …` and an OAuth one as `Bearer …`, and which of the two
|
||||
# this is depends on how Woodpecker was set up, so the scheme is probed once rather than
|
||||
# assumed.
|
||||
TOKEN="${GITEA_TOKEN:-${CI_NETRC_PASSWORD:-}}"
|
||||
[ -n "$TOKEN" ] || die "no credential: set GITEA_TOKEN, or run this where Woodpecker provides CI_NETRC_PASSWORD"
|
||||
[ -n "$REPO" ] || die "cannot work out the repository — set REPO=owner/name"
|
||||
[ -n "$TAG" ] || die "cannot work out the tag — set TAG=v1.2.3"
|
||||
|
||||
AUTH=""
|
||||
for scheme in token Bearer; do
|
||||
if curl -fsS -H "Authorization: $scheme $TOKEN" "$FORGE/user" >/dev/null 2>&1; then
|
||||
AUTH="Authorization: $scheme $TOKEN"
|
||||
say "authenticated with the $scheme scheme"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ -n "$AUTH" ] || die "the credential was refused by $FORGE — it cannot read /user"
|
||||
|
||||
api() {
|
||||
method="$1"; path="$2"; shift 2
|
||||
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.
|
||||
LIST="$(mktemp)"
|
||||
trap 'rm -f "$LIST"' EXIT
|
||||
if [ "$#" -gt 0 ]; then
|
||||
for given in "$@"; do printf '%s\n' "$given"; done > "$LIST"
|
||||
else
|
||||
# What this pipeline builds. The macOS packages are attached from the Mac that can
|
||||
# sign them, so they are not listed here even when they happen to be present.
|
||||
find "$DIST" -maxdepth 1 -type f \
|
||||
\( -name '*.AppImage' -o -name '*.deb' -o -name '*.exe' \) 2>/dev/null | sort > "$LIST" || true
|
||||
fi
|
||||
[ -s "$LIST" ] || die "no Linux or Windows packages in $DIST"
|
||||
|
||||
say "$REPO $TAG"
|
||||
|
||||
# `curl -f` fails on the 404 a missing release answers, so the lookup is allowed to
|
||||
# fail and judged by what came back rather than by its exit status.
|
||||
release_id="$(curl -sS -H "$AUTH" "$FORGE/repos/$REPO/releases/tags/$TAG" | jq -r '.id // empty')"
|
||||
|
||||
if [ -z "$release_id" ]; then
|
||||
say "no release for $TAG yet — creating it"
|
||||
title="$(jq -r '(.productName // .name) + " " + (.version)' package.json)"
|
||||
notes=''
|
||||
[ -f "$NOTES" ] && notes="$(cat "$NOTES")"
|
||||
# The body goes through jq rather than string concatenation: release notes are
|
||||
# markdown with quotes and newlines in them.
|
||||
payload="$(jq -n --arg tag "$TAG" --arg title "$title" --arg body "$notes" \
|
||||
'{tag_name: $tag, name: $title, body: $body, draft: false, prerelease: false}')"
|
||||
release_id="$(api POST "/repos/$REPO/releases" \
|
||||
-H 'Content-Type: application/json' -d "$payload" | jq -r '.id // empty')"
|
||||
[ -n "$release_id" ] || die "the release for $TAG could not be created"
|
||||
else
|
||||
say "the release already exists"
|
||||
fi
|
||||
|
||||
while IFS= read -r asset; do
|
||||
[ -n "$asset" ] || continue
|
||||
[ -f "$asset" ] || die "no such file: $asset"
|
||||
name="$(basename "$asset")"
|
||||
encoded="$(printf '%s' "$name" | jq -sRr @uri)"
|
||||
|
||||
# Replace rather than refuse, so re-running a build lands.
|
||||
existing="$(api GET "/repos/$REPO/releases/$release_id/assets" |
|
||||
jq -r --arg name "$name" '.[] | select(.name == $name) | .id')"
|
||||
for id in $existing; do
|
||||
say "replacing $name"
|
||||
api DELETE "/repos/$REPO/releases/$release_id/assets/$id" >/dev/null
|
||||
done
|
||||
|
||||
say "uploading $name"
|
||||
api POST "/repos/$REPO/releases/$release_id/assets?name=$encoded" \
|
||||
-F "attachment=@$asset" >/dev/null
|
||||
done < "$LIST"
|
||||
|
||||
say "done:"
|
||||
api GET "/repos/$REPO/releases/$release_id" |
|
||||
jq -r '.assets[] | " \(.name) \(.size / 1000000 | floor) MB"'
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
# Fail on a package that is too small to be one.
|
||||
#
|
||||
# Written after a Wine build died halfway and left a 162 KB stub named like the real
|
||||
# installer: `ls` was happy, the step passed, and the release would have carried a file
|
||||
# that cannot be run. An Electron package is ~100 MB — anything under a tenth of that
|
||||
# did not finish.
|
||||
#
|
||||
# scripts/ci-verify-packages.sh '*.AppImage' '*.deb'
|
||||
set -eu
|
||||
|
||||
DIST="${DIST:-dist}"
|
||||
MIN_BYTES="${MIN_BYTES:-10000000}"
|
||||
|
||||
die() { echo "[verify] error: $*" >&2; exit 1; }
|
||||
|
||||
[ "$#" -gt 0 ] || die "no patterns given"
|
||||
|
||||
for pattern in "$@"; do
|
||||
found=0
|
||||
# One path per line: package names contain spaces.
|
||||
find "$DIST" -maxdepth 1 -type f -name "$pattern" | sort > /tmp/verify-list
|
||||
while IFS= read -r file; do
|
||||
[ -n "$file" ] || continue
|
||||
found=1
|
||||
size="$(wc -c < "$file" | tr -d ' ')"
|
||||
if [ "$size" -lt "$MIN_BYTES" ]; then
|
||||
die "$file is only $size bytes — the build did not finish"
|
||||
fi
|
||||
echo "[verify] $(basename "$file"): $size bytes"
|
||||
done < /tmp/verify-list
|
||||
[ "$found" -eq 1 ] || die "no $pattern in $DIST"
|
||||
done
|
||||
|
||||
rm -f /tmp/verify-list
|
||||
+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
|
||||
# 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
|
||||
# a single variable and looping over $list splits it on the space.
|
||||
LIST="$(mktemp)"
|
||||
|
||||
@@ -26,7 +26,10 @@ export class GameDtoMapper {
|
||||
installed: game.installed,
|
||||
updateAvailable: game.updateAvailable,
|
||||
installedVersion: game.installedVersion,
|
||||
launchable: this.isLaunchable(game)
|
||||
launchable: this.isLaunchable(game),
|
||||
installable: game.installable,
|
||||
unavailableReason: game.unavailableReason,
|
||||
unavailableDetail: game.unavailableDetail
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
/** How a title runs: unpacked on this machine, or served as a web build. */
|
||||
export type GameMode = 'app' | 'web'
|
||||
|
||||
/**
|
||||
* Why a title cannot be installed here, as the engine codes it.
|
||||
*
|
||||
* `platformOff` is the store not carrying that platform at all — a C64 cartridge on a
|
||||
* desktop — and the other three are about this machine or this catalog: no asset kind
|
||||
* for the os and architecture, no release carrying it, or the adapter refusing it.
|
||||
*/
|
||||
export type UnavailableReason = 'platformOff' | 'hostAsset' | 'noAsset' | 'vetoed'
|
||||
|
||||
/**
|
||||
* A catalog entry, with what the store did about it on this machine.
|
||||
*
|
||||
@@ -24,4 +33,12 @@ export interface Game {
|
||||
readonly menuEntryPath: string | null
|
||||
readonly executablePath: string | null
|
||||
readonly hostedUrl: string | null
|
||||
/**
|
||||
* False for a title this machine cannot install. It is still listed: a catalog that
|
||||
* hides what your machine cannot run leaves you wondering which of the two is small.
|
||||
*/
|
||||
readonly installable: boolean
|
||||
readonly unavailableReason: UnavailableReason | null
|
||||
/** The engine's sentence for it, for a tooltip or the log. */
|
||||
readonly unavailableDetail: string | null
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* A store the site's registry offers.
|
||||
*
|
||||
* Three fields, because that is what a record is: what it is called, which
|
||||
* catalog it serves, and where its configuration lives.
|
||||
* 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.
|
||||
*/
|
||||
export interface RegistryStore {
|
||||
readonly name: string
|
||||
readonly catalogUrl: string
|
||||
readonly storeRepositoryUrl: string
|
||||
readonly storeRepositoryUrl: string | null
|
||||
}
|
||||
|
||||
@@ -1,15 +1,39 @@
|
||||
import type { RegistryStore } from './RegistryStore'
|
||||
|
||||
/**
|
||||
* A store id from its repository name: `ttg-desktop-store` becomes `ttg`.
|
||||
* A store id, from whatever the registry gave us.
|
||||
*
|
||||
* The id names the store home and the folder games land in, so it has to be short
|
||||
* and filesystem-safe. The repository name is the best source available before
|
||||
* anything is downloaded; the store's own config.json overrides it once it is.
|
||||
* 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:
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* The store's own config.json overrides all of it whenever one exists.
|
||||
*/
|
||||
export function deriveStoreId (store: RegistryStore): string {
|
||||
const lastSegment = store.storeRepositoryUrl.replace(/\/+$/, '').split('/').pop() ?? ''
|
||||
const base = lastSegment.replace(/-(desktop-)?store$/, '') || store.name
|
||||
const slug = base.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
return slug || 'store'
|
||||
const fromRepository = store.storeRepositoryUrl === null
|
||||
? ''
|
||||
: (lastSegment(store.storeRepositoryUrl).replace(/-(desktop-)?store$/, ''))
|
||||
return toSlug(fromRepository) || toSlug(readHostLabel(store.catalogUrl)) || toSlug(store.name) || 'store'
|
||||
}
|
||||
|
||||
function lastSegment (url: string): string {
|
||||
return url.replace(/\/+$/, '').split('/').pop() ?? ''
|
||||
}
|
||||
|
||||
/** `https://www.teletypegames.org/x` → `teletypegames`. */
|
||||
function readHostLabel (catalogUrl: string): string {
|
||||
try {
|
||||
const host = new URL(catalogUrl).hostname.replace(/^www\./, '')
|
||||
return host.split('.')[0] ?? ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function toSlug (value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import https from 'node:https'
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 60_000
|
||||
const MAX_REDIRECTS = 5
|
||||
const USER_AGENT = 'warp-engine-desktop-gui'
|
||||
const USER_AGENT = 'warp-engine-client'
|
||||
|
||||
/** A response that arrived but said no. The status matters: 404 is not a failure everywhere. */
|
||||
export class HttpStatusError extends Error {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Game, GameMode } from '../../domain/models/Game'
|
||||
import type { Game, GameMode, UnavailableReason } from '../../domain/models/Game'
|
||||
import {
|
||||
readBoolean, readOptionalString, readString, type JsonRecord
|
||||
} from '../json/JsonRecord'
|
||||
@@ -26,11 +26,27 @@ export class EngineGameMapper {
|
||||
installedVersion: readOptionalString(record, 'installed_version'),
|
||||
menuEntryPath: readOptionalString(record, 'menu_entry'),
|
||||
executablePath: readOptionalString(record, 'exe'),
|
||||
hostedUrl: readOptionalString(record, 'url')
|
||||
hostedUrl: readOptionalString(record, 'url'),
|
||||
// Absent means installable: engines older than 1.2.0 list only what they can
|
||||
// install, and treating their silence as "unavailable" would empty the window.
|
||||
installable: readBoolean(record, 'installable', true),
|
||||
unavailableReason: this.toReason(readOptionalString(record, 'unavailable_reason')),
|
||||
unavailableDetail: readOptionalString(record, 'unavailable_detail')
|
||||
}
|
||||
}
|
||||
|
||||
private toMode (value: string): GameMode {
|
||||
return value === 'web' ? 'web' : 'app'
|
||||
}
|
||||
|
||||
/** The engine's snake_case codes, which are its wire format and not ours. */
|
||||
private toReason (value: string | null): UnavailableReason | null {
|
||||
const codes: Readonly<Record<string, UnavailableReason>> = {
|
||||
platform_off: 'platformOff',
|
||||
host_asset: 'hostAsset',
|
||||
no_asset: 'noAsset',
|
||||
vetoed: 'vetoed'
|
||||
}
|
||||
return value === null ? null : codes[value] ?? null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,30 +76,26 @@ export class HttpStoreEngineInstaller implements StoreEngineInstaller {
|
||||
/**
|
||||
* The store's configuration.
|
||||
*
|
||||
* Its repository is the authority on how the store behaves — which platforms,
|
||||
* which statuses, where things land. A repository without a config.json still
|
||||
* works: the engine merges whatever it is given onto its own defaults, so a
|
||||
* three-field config is a complete one. The registry wins on identity and on
|
||||
* which catalog to read.
|
||||
* 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)
|
||||
let config: Record<string, unknown>
|
||||
try {
|
||||
progress.onLog?.(`reading the store config from ${store.storeRepositoryUrl}`)
|
||||
const body = await this.httpClient.readText(this.configUrl(store.storeRepositoryUrl))
|
||||
config = { ...(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')
|
||||
config = {
|
||||
paths: { subfolder: storeId },
|
||||
catalog: { statuses: ['released', 'archived', 'demo'] }
|
||||
}
|
||||
}
|
||||
const config = await this.readPublishedConfig(store, storeId, progress)
|
||||
|
||||
const existing = asRecord(config['store']) ?? {}
|
||||
config['store'] = {
|
||||
@@ -111,6 +107,43 @@ export class HttpStoreEngineInstaller implements StoreEngineInstaller {
|
||||
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}`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { RegistryUnavailableError } from '../../domain/errors/RegistryUnavailabl
|
||||
import type { RegistryStore } from '../../domain/models/RegistryStore'
|
||||
import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository'
|
||||
import { asRecord, readString, type JsonRecord } from '../json/JsonRecord'
|
||||
import { BuildConfiguration } from '../config/BuildConfiguration'
|
||||
import type { HttpTextClient } from '../http/HttpTextClient'
|
||||
|
||||
const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores'
|
||||
@@ -9,18 +10,29 @@ const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores'
|
||||
/**
|
||||
* The registry: `GET /api/stores` on the site.
|
||||
*
|
||||
* The one address this client knows, and even that is overridable — `STORES_API`
|
||||
* points it at another site or at a local endpoint. Records missing any of the
|
||||
* three fields are dropped rather than half-used.
|
||||
* The one address this client knows, and it is decided in three places, most specific
|
||||
* first: a runtime `STORES_API` (for trying something out), the `warpEngine.registryUrl`
|
||||
* field a build was packaged with (for shipping a client for another site), and finally
|
||||
* the address of ours.
|
||||
*
|
||||
* A record needs a name and a catalog URL; those two make a store. The repository
|
||||
* is optional and arrives as null when absent — a store configured by nothing but
|
||||
* this record installs on the engine's defaults. Records missing either of the two
|
||||
* required fields are dropped rather than half-used.
|
||||
*/
|
||||
export class HttpStoreRegistryRepository implements StoreRegistryRepository {
|
||||
public readonly sourceUrl: string
|
||||
|
||||
public constructor (private readonly httpClient: HttpTextClient, sourceUrl?: string) {
|
||||
const configured = process.env['STORES_API']
|
||||
this.sourceUrl = sourceUrl ?? (configured !== undefined && configured.length > 0
|
||||
? configured
|
||||
: DEFAULT_REGISTRY_URL)
|
||||
public constructor (
|
||||
private readonly httpClient: HttpTextClient,
|
||||
sourceUrl?: string,
|
||||
buildConfiguration: BuildConfiguration = new BuildConfiguration()
|
||||
) {
|
||||
const fromEnvironment = process.env['STORES_API']
|
||||
this.sourceUrl = sourceUrl
|
||||
?? (fromEnvironment !== undefined && fromEnvironment.length > 0 ? fromEnvironment : null)
|
||||
?? buildConfiguration.readRegistryUrl()
|
||||
?? DEFAULT_REGISTRY_URL
|
||||
}
|
||||
|
||||
public async listStores (): Promise<readonly RegistryStore[]> {
|
||||
@@ -32,16 +44,19 @@ 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 => ({
|
||||
name: readString(row, 'name').trim(),
|
||||
.map((row: JsonRecord): RegistryStore => {
|
||||
// Both spellings, because a registry is someone else's API: ours answers
|
||||
// camelCase, and a hand-rolled one may not.
|
||||
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim(),
|
||||
storeRepositoryUrl: (
|
||||
const repository = (
|
||||
readString(row, 'storeRepositoryUrl') || readString(row, 'store_repository_url')
|
||||
).trim()
|
||||
}))
|
||||
return {
|
||||
name: readString(row, 'name').trim(),
|
||||
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim(),
|
||||
storeRepositoryUrl: repository.length > 0 ? repository : null
|
||||
}
|
||||
})
|
||||
.filter((store: RegistryStore): boolean =>
|
||||
store.name.length > 0 && store.catalogUrl.length > 0 && store.storeRepositoryUrl.length > 0)
|
||||
store.name.length > 0 && store.catalogUrl.length > 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { SelfTestRunner } from './diagnostics/SelfTestRunner'
|
||||
|
||||
const SELFTEST_FLAG = '--selftest'
|
||||
const SELFTEST_USER_DATA_DIRECTORY = 'warpstore-gui-selftest'
|
||||
const PRODUCT_NAME = 'WarpEngine Store'
|
||||
const PRODUCT_NAME = 'WarpEngine Client'
|
||||
|
||||
/**
|
||||
* The application's lifecycle.
|
||||
|
||||
@@ -7,7 +7,7 @@ const WINDOW_OPTIONS: BrowserWindowConstructorOptions = {
|
||||
minWidth: 760,
|
||||
minHeight: 520,
|
||||
backgroundColor: '#11151c',
|
||||
title: 'WarpEngine Store'
|
||||
title: 'WarpEngine Client'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -65,10 +65,13 @@ export class SelfTestRunner {
|
||||
|
||||
if (this.shotPath !== null) await this.captureShot(this.shotPath)
|
||||
|
||||
// A gate passes on having something to do, not on having a picker: the picker
|
||||
// only appears when the registry offers more than one store, and one store is
|
||||
// the ordinary case. Requiring choices here failed a perfectly good window.
|
||||
const rendered = report.locales.length > 1 && (
|
||||
(report.cards > 0 && !report.gateVisible && report.stores.length > 0 &&
|
||||
report.categories.length > 0 && report.activeCategory !== null) ||
|
||||
(report.gateVisible && report.gateChoices.length > 0 && report.gateAction.length > 0))
|
||||
(report.gateVisible && report.gateAction.length > 0))
|
||||
const switchedWell = switched === null || (
|
||||
switched.storeId.length > 0 && switched.storeId !== report.storeId &&
|
||||
switched.cards > 0 && switched.categories > 0)
|
||||
|
||||
@@ -27,14 +27,16 @@ 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'),
|
||||
storeRepositoryUrl: readString(record, 'storeRepositoryUrl'),
|
||||
// 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 || store.storeRepositoryUrl.length === 0) {
|
||||
throw new TypeError('a store record needs a name, a catalog URL and a repository URL')
|
||||
if (store.name.length === 0 || store.catalogUrl.length === 0) {
|
||||
throw new TypeError('a store record needs a name and a catalog URL')
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export class RendererApplication {
|
||||
})
|
||||
this.gate = new GateView((url: string): void => { void this.bridge.openUrl(url) })
|
||||
this.catalog = new CatalogController(this.bridge, this.store, this.log)
|
||||
this.stores = new StoreController(this.bridge, this.store, this.gate, this.log, this.catalog)
|
||||
this.stores = new StoreController(this.bridge, this.store, this.log, this.catalog)
|
||||
this.preferences = new PreferencesController(this.bridge, this.store)
|
||||
this.streams = new EngineStreamController(this.bridge, this.store, this.log)
|
||||
|
||||
@@ -56,7 +56,6 @@ export class RendererApplication {
|
||||
this.sideMenu = new SideMenuView({
|
||||
onSelectStore: (home: string): void => { void this.stores.selectStore(home) },
|
||||
onAddStore: (): void => { void this.stores.offerStores() },
|
||||
onSyncAll: (): void => { void this.catalog.syncGames([]) },
|
||||
onRefresh: (): void => { void this.catalog.refresh() },
|
||||
onSelectCategory: (filter: CategoryFilter): void => { this.store.applyFilter(filter) },
|
||||
onSelectLocale: (locale: string): void => { void this.preferences.selectLocale(locale) }
|
||||
@@ -83,7 +82,7 @@ export class RendererApplication {
|
||||
this.stores.showOutdatedEngineGate()
|
||||
return
|
||||
}
|
||||
this.gate.hide()
|
||||
this.store.applyGate(null)
|
||||
await this.catalog.refresh()
|
||||
}
|
||||
|
||||
@@ -100,7 +99,15 @@ export class RendererApplication {
|
||||
this.topBar.render(state)
|
||||
this.sideMenu.render(state)
|
||||
this.log.render(state)
|
||||
if (this.gate.visible) this.grid.hide()
|
||||
else this.grid.render(state)
|
||||
|
||||
// The gate and the grid are alternatives, decided by one field, so they cannot
|
||||
// both be on screen — which is what happened while this was two imperative calls.
|
||||
if (state.gate === null) {
|
||||
this.gate.hide()
|
||||
this.grid.render(state)
|
||||
} else {
|
||||
this.gate.show(state.gate, state.messages)
|
||||
this.grid.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { BridgeApi } from '../../shared/contracts/BridgeApi'
|
||||
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
|
||||
import type { AppStore } from '../state/AppStore'
|
||||
import type { GateView } from '../views/GateView'
|
||||
import type { LogDrawerView } from '../views/LogDrawerView'
|
||||
import type { CatalogController } from './CatalogController'
|
||||
|
||||
@@ -16,7 +15,6 @@ export class StoreController {
|
||||
public constructor (
|
||||
private readonly bridge: BridgeApi,
|
||||
private readonly store: AppStore,
|
||||
private readonly gate: GateView,
|
||||
private readonly log: LogDrawerView,
|
||||
private readonly catalog: CatalogController
|
||||
) {}
|
||||
@@ -31,7 +29,7 @@ export class StoreController {
|
||||
this.showOutdatedEngineGate()
|
||||
return
|
||||
}
|
||||
this.gate.hide()
|
||||
this.store.applyGate(null)
|
||||
await this.catalog.refresh()
|
||||
} catch (error: unknown) {
|
||||
this.log.appendLine(`${messages.switchFailed}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
@@ -48,23 +46,23 @@ export class StoreController {
|
||||
const result = await this.bridge.listRegistryStores()
|
||||
|
||||
if (result.error !== null) {
|
||||
this.gate.show({
|
||||
this.store.applyGate({
|
||||
title: messages.registryFailed,
|
||||
body: `${result.sourceUrl}\n\n${result.error}`,
|
||||
action: { label: messages.registryRetry, perform: (): void => { void this.offerStores() } }
|
||||
}, messages)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result.stores.length === 0) {
|
||||
this.gate.show({
|
||||
this.store.applyGate({
|
||||
title: messages.setupTitle,
|
||||
body: `${messages.registryEmpty}\n\n${result.sourceUrl}`
|
||||
}, messages)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.gate.show({
|
||||
this.store.applyGate({
|
||||
title: messages.setupTitle,
|
||||
body: `${messages.setupBody}\n\n${state.defaultStoreRoot}`,
|
||||
action: {
|
||||
@@ -74,26 +72,26 @@ export class StoreController {
|
||||
}
|
||||
},
|
||||
choices: result.stores
|
||||
}, messages)
|
||||
})
|
||||
}
|
||||
|
||||
public showOutdatedEngineGate (): void {
|
||||
const state = this.store.readState()
|
||||
const engineText = state.engine === null ? '' : state.engine.text
|
||||
this.gate.show({
|
||||
this.store.applyGate({
|
||||
title: state.messages.oldEngineTitle,
|
||||
body: `${state.messages.oldEngineBody}\n\n${engineText} → ${state.minimumEngineVersion}`,
|
||||
action: { label: state.messages.oldEngineAction, perform: (): void => { void this.offerStores() } }
|
||||
}, state.messages)
|
||||
})
|
||||
}
|
||||
|
||||
public showMissingPythonGate (): void {
|
||||
const messages = this.store.readState().messages
|
||||
this.gate.show({
|
||||
this.store.applyGate({
|
||||
title: messages.noPythonTitle,
|
||||
body: messages.noPythonBody,
|
||||
link: { label: messages.pythonLink, url: 'https://www.python.org/downloads/' }
|
||||
}, messages)
|
||||
})
|
||||
}
|
||||
|
||||
private async installStore (chosen: RegistryStoreDto): Promise<void> {
|
||||
@@ -102,7 +100,7 @@ export class StoreController {
|
||||
try {
|
||||
await this.bridge.installStore(chosen)
|
||||
this.store.applyAppState(await this.bridge.readState())
|
||||
this.gate.hide()
|
||||
this.store.applyGate(null)
|
||||
await this.catalog.refresh()
|
||||
await this.catalog.syncGames([])
|
||||
} catch (error: unknown) {
|
||||
|
||||
+10
-8
@@ -6,7 +6,7 @@
|
||||
runs: the app ships its own script and stylesheet. -->
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' https: data:; font-src 'self'; connect-src 'none'">
|
||||
<title>WarpEngine Store</title>
|
||||
<title>WarpEngine Client</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -16,8 +16,10 @@
|
||||
</button>
|
||||
<div class="bar-title">
|
||||
<span class="logo" aria-hidden="true">▚</span>
|
||||
<span id="app-name">WarpEngine Store</span>
|
||||
<span class="store-id" id="store-id"></span>
|
||||
<span id="app-name">WarpEngine Client</span>
|
||||
<!-- Kept for the window check, which reads it to see which store is open; the
|
||||
side menu is where a person reads that. -->
|
||||
<span class="store-id" id="store-id" hidden></span>
|
||||
</div>
|
||||
<div class="bar-actions">
|
||||
<span class="progress" id="progress" hidden></span>
|
||||
@@ -36,7 +38,6 @@
|
||||
|
||||
<section class="side-block">
|
||||
<h2 class="side-head" id="head-actions"></h2>
|
||||
<button id="sync-all" class="btn btn-primary btn-wide" disabled></button>
|
||||
<button id="refresh" class="btn btn-wide" disabled></button>
|
||||
</section>
|
||||
|
||||
@@ -46,6 +47,7 @@
|
||||
</section>
|
||||
|
||||
<section class="side-block side-foot">
|
||||
<button id="log-toggle" class="side-quiet" aria-expanded="false" aria-controls="log-panel"></button>
|
||||
<label class="side-lang">
|
||||
<span id="head-lang"></span>
|
||||
<select id="locale" class="select" aria-label="Language"></select>
|
||||
@@ -72,11 +74,11 @@
|
||||
|
||||
<section id="empty" class="empty" hidden></section>
|
||||
|
||||
<footer class="log">
|
||||
<button id="log-toggle" class="log-toggle" aria-expanded="false"></button>
|
||||
<div class="log-lines" id="log-lines" hidden></div>
|
||||
<!-- Only on screen while the log is open; the window has no permanent footer. -->
|
||||
<section class="log-panel" id="log-panel" hidden>
|
||||
<div class="log-lines" id="log-lines"></div>
|
||||
<div class="log-paths" id="log-paths"></div>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { StorePathsDto } from '../../shared/contracts/dto/StorePathsDto'
|
||||
import { ENGLISH_MESSAGES } from '../../shared/i18n/EnglishMessages'
|
||||
import type { Locale, MessageBundle } from '../../shared/i18n/MessageBundle'
|
||||
import { ALL_CATEGORIES, type CategoryFilter } from './CategoryFilter'
|
||||
import type { GatePresentation } from './GatePresentation'
|
||||
|
||||
/** How far a running sync has got, for the counter in the bar. */
|
||||
export interface SyncProgress {
|
||||
@@ -32,6 +33,8 @@ export interface AppState {
|
||||
readonly filter: CategoryFilter
|
||||
readonly busy: boolean
|
||||
readonly progress: SyncProgress | null
|
||||
/** Non-null while the setup screen is up, which is also what hides the grid. */
|
||||
readonly gate: GatePresentation | null
|
||||
}
|
||||
|
||||
const INITIAL_STATE: AppState = {
|
||||
@@ -50,7 +53,8 @@ const INITIAL_STATE: AppState = {
|
||||
paths: null,
|
||||
filter: ALL_CATEGORIES,
|
||||
busy: false,
|
||||
progress: null
|
||||
progress: null,
|
||||
gate: null
|
||||
}
|
||||
|
||||
export type AppStateListener = (state: AppState) => void
|
||||
@@ -112,6 +116,11 @@ export class AppStore {
|
||||
this.notify()
|
||||
}
|
||||
|
||||
public applyGate (gate: GatePresentation | null): void {
|
||||
this.state = { ...this.state, gate }
|
||||
this.notify()
|
||||
}
|
||||
|
||||
public applyFilter (filter: CategoryFilter): void {
|
||||
this.state = { ...this.state, filter }
|
||||
this.notify()
|
||||
|
||||
@@ -30,7 +30,9 @@ export function matchesFilter (game: GameDto, filter: CategoryFilter): boolean {
|
||||
case 'platform':
|
||||
return game.platform === filter.value
|
||||
case 'mode':
|
||||
return game.mode === filter.value
|
||||
// A title this machine cannot install has no mode worth filtering on: the engine
|
||||
// sends none, and counting it as native would put a C64 cartridge under "native".
|
||||
return game.installable && game.mode === filter.value
|
||||
case 'group':
|
||||
return matchesGroup(game, filter.value)
|
||||
}
|
||||
@@ -43,7 +45,9 @@ function matchesGroup (game: GameDto, group: string): boolean {
|
||||
case 'updates':
|
||||
return game.updateAvailable
|
||||
case 'available':
|
||||
return !game.installed
|
||||
return game.installable && !game.installed
|
||||
case 'unsupported':
|
||||
return !game.installable
|
||||
default:
|
||||
return true
|
||||
}
|
||||
@@ -68,7 +72,8 @@ export function buildCategorySections (
|
||||
{ kind: 'group', value: 'all', label: messages.catAll, count: games.length },
|
||||
{ kind: 'group', value: 'installed', label: messages.catInstalled, count: count((game: GameDto): boolean => game.installed) },
|
||||
{ kind: 'group', value: 'updates', label: messages.catUpdates, count: count((game: GameDto): boolean => game.updateAvailable) },
|
||||
{ kind: 'group', value: 'available', label: messages.catAvailable, count: count((game: GameDto): boolean => !game.installed) }
|
||||
{ kind: 'group', value: 'available', label: messages.catAvailable, count: count((game: GameDto): boolean => game.installable && !game.installed) },
|
||||
{ kind: 'group', value: 'unsupported', label: messages.catUnsupported, count: count((game: GameDto): boolean => !game.installable) }
|
||||
]
|
||||
sections.push({
|
||||
title: null,
|
||||
@@ -90,7 +95,8 @@ export function buildCategorySections (
|
||||
})
|
||||
}
|
||||
|
||||
const modes = [...new Set(games.map((game: GameDto): string => game.mode))]
|
||||
const installable = games.filter((game: GameDto): boolean => game.installable)
|
||||
const modes = [...new Set(installable.map((game: GameDto): string => game.mode))]
|
||||
if (modes.length > 1) {
|
||||
sections.push({
|
||||
title: messages.catMode,
|
||||
@@ -98,7 +104,7 @@ export function buildCategorySections (
|
||||
kind: 'mode',
|
||||
value: mode,
|
||||
label: mode === 'web' ? messages.hosted : messages.native,
|
||||
count: count((game: GameDto): boolean => game.mode === mode)
|
||||
count: count((game: GameDto): boolean => game.installable && game.mode === mode)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
|
||||
|
||||
/** A button on the gate, and what choosing it does. */
|
||||
export interface GateAction {
|
||||
readonly label: string
|
||||
readonly perform: (chosen: RegistryStoreDto | null) => void
|
||||
}
|
||||
|
||||
export interface GateLink {
|
||||
readonly label: string
|
||||
readonly url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What the gate is showing.
|
||||
*
|
||||
* Part of the state rather than a call on a view: whether the gate is up decides
|
||||
* whether the grid is drawn, and the two disagreed when this was imperative — the
|
||||
* gate went up and the empty-catalog line stayed underneath it.
|
||||
*/
|
||||
export interface GatePresentation {
|
||||
readonly title: string
|
||||
readonly body: string
|
||||
readonly action?: GateAction
|
||||
readonly link?: GateLink
|
||||
readonly choices?: readonly RegistryStoreDto[]
|
||||
}
|
||||
+43
-6
@@ -14,6 +14,27 @@
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
/* The scrollbars are part of the theme too: the platform's own are light, and a white
|
||||
track down the side menu of a dark window looks like a mistake. */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #33414f transparent;
|
||||
}
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #33414f;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: content-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: #46586b; background-clip: content-box; }
|
||||
|
||||
/* An explicit `display` beats the browser's own [hidden] rule, and most of the
|
||||
regions here have one — the gate's store picker showed as an empty stub because
|
||||
of exactly that. This makes `hidden` mean hidden everywhere. */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
@@ -60,7 +81,11 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
|
||||
.side-block { display: flex; flex-direction: column; gap: 6px; flex: none; }
|
||||
.side-cats { flex: 1; min-height: 0; }
|
||||
.side-foot { padding-top: 12px; border-top: 1px solid var(--line); }
|
||||
.side-foot {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
gap: 8px;
|
||||
}
|
||||
.side-head {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
@@ -239,6 +264,12 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
flex-direction: column;
|
||||
}
|
||||
.card.is-installed { border-color: #2f5a49; }
|
||||
/* Listed, but not for this machine: dimmed rather than hidden, and it says why. */
|
||||
.card.is-unavailable { opacity: .55; }
|
||||
.card.is-unavailable:hover { opacity: .8; }
|
||||
.card.is-unavailable .art { filter: grayscale(1); }
|
||||
.badge-unavailable { color: var(--ink-dim); border-color: var(--line); border-style: dashed; }
|
||||
.actions-note { font-size: 12px; color: var(--ink-dim); margin-top: auto; }
|
||||
|
||||
.art {
|
||||
/* One height for every card, art or not, so the titles and the buttons line up
|
||||
@@ -279,23 +310,29 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
}
|
||||
.actions { display: flex; gap: 8px; margin-top: auto; }
|
||||
|
||||
/* --- log ---------------------------------------------------------------- */
|
||||
.log {
|
||||
/* --- 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. */
|
||||
.log-panel {
|
||||
flex: none;
|
||||
background: var(--panel);
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 8px 18px 10px;
|
||||
}
|
||||
.log-toggle {
|
||||
.side-quiet {
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-dim);
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0 0 4px;
|
||||
border-radius: 6px;
|
||||
padding: 4px 6px;
|
||||
margin-left: -6px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.side-quiet:hover { color: var(--ink); background: var(--panel-2); }
|
||||
.side-quiet.is-active { color: var(--accent); }
|
||||
.log-lines {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -20,6 +20,7 @@ export class GameCardView {
|
||||
public createCard (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||
const card = createElement('article', 'card')
|
||||
if (game.installed) card.classList.add('is-installed')
|
||||
if (!game.installable) card.classList.add('is-unavailable')
|
||||
card.appendChild(this.createArt(game))
|
||||
card.appendChild(this.createBody(game, messages, busy))
|
||||
return card
|
||||
@@ -54,10 +55,21 @@ export class GameCardView {
|
||||
|
||||
private createMeta (game: GameDto, messages: MessageBundle): HTMLElement {
|
||||
const meta = createElement('div', 'meta')
|
||||
const mode = createElement('span', `badge badge-${game.mode}`,
|
||||
game.mode === 'web' ? messages.hosted : messages.native)
|
||||
mode.title = game.mode === 'web' ? messages.hostedHint : messages.nativeHint
|
||||
meta.appendChild(mode)
|
||||
if (game.installable) {
|
||||
const mode = createElement('span', `badge badge-${game.mode}`,
|
||||
game.mode === 'web' ? messages.hosted : messages.native)
|
||||
mode.title = game.mode === 'web' ? messages.hostedHint : messages.nativeHint
|
||||
meta.appendChild(mode)
|
||||
} else {
|
||||
// Which of the two it is matters: a platform this store does not carry is a
|
||||
// different disappointment from a game with no build for your machine.
|
||||
const label = game.unavailableReason === 'platformOff'
|
||||
? messages.unsupportedPlatform
|
||||
: messages.unsupportedBuild
|
||||
const badge = createElement('span', 'badge badge-unavailable', label)
|
||||
badge.title = game.unavailableDetail ?? label
|
||||
meta.appendChild(badge)
|
||||
}
|
||||
meta.appendChild(createElement('span', 'badge badge-plain', game.platform))
|
||||
meta.appendChild(createElement('span', 'version',
|
||||
game.installed && game.installedVersion !== null
|
||||
@@ -68,6 +80,15 @@ export class GameCardView {
|
||||
|
||||
private createActions (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||
const actions = createElement('div', 'actions')
|
||||
|
||||
// Nothing to offer, so nothing to press: a disabled Install would invite a click
|
||||
// that can never work. The badge above says why.
|
||||
if (!game.installable) {
|
||||
actions.appendChild(createElement('span', 'actions-note',
|
||||
game.unavailableDetail ?? messages.unsupportedPlatform))
|
||||
return actions
|
||||
}
|
||||
|
||||
const primary = createElement('button', 'btn btn-primary')
|
||||
primary.disabled = busy
|
||||
|
||||
|
||||
@@ -1,25 +1,7 @@
|
||||
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
|
||||
import { createElement, requireElement, setHidden, setText } from '../dom/Dom'
|
||||
import type { MessageBundle } from '../../shared/i18n/MessageBundle'
|
||||
|
||||
/** A button on the gate, and what choosing it does. */
|
||||
export interface GateAction {
|
||||
readonly label: string
|
||||
readonly perform: (chosen: RegistryStoreDto | null) => void
|
||||
}
|
||||
|
||||
export interface GateLink {
|
||||
readonly label: string
|
||||
readonly url: string
|
||||
}
|
||||
|
||||
export interface GatePresentation {
|
||||
readonly title: string
|
||||
readonly body: string
|
||||
readonly action?: GateAction
|
||||
readonly link?: GateLink
|
||||
readonly choices?: readonly RegistryStoreDto[]
|
||||
}
|
||||
import type { GateLink, GatePresentation } from '../state/GatePresentation'
|
||||
|
||||
/**
|
||||
* The screen shown instead of the grid when there is nothing to drive: no Python, no
|
||||
@@ -50,10 +32,6 @@ export class GateView {
|
||||
setHidden(this.section, true)
|
||||
}
|
||||
|
||||
public get visible (): boolean {
|
||||
return !this.section.hidden
|
||||
}
|
||||
|
||||
/** Only shown when the registry offers more than one store; with a single one there is nothing to decide. */
|
||||
private renderChoices (choices: readonly RegistryStoreDto[], messages: MessageBundle): void {
|
||||
setHidden(this.choice, choices.length < 2)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createElement, requireElement, setText } from '../dom/Dom'
|
||||
import { createElement, requireElement, setHidden, setText } from '../dom/Dom'
|
||||
import type { AppState } from '../state/AppStore'
|
||||
|
||||
const MAX_LOG_LINES = 400
|
||||
@@ -7,17 +7,23 @@ export interface LogDrawerViewCallbacks {
|
||||
readonly onOpenFolder: (directory: string) => void
|
||||
}
|
||||
|
||||
/** The store's own output, verbatim, and the folders everything lands in. */
|
||||
/**
|
||||
* The store's own output, verbatim, and the folders everything lands in.
|
||||
*
|
||||
* Off screen until asked for. The window used to carry a footer with a toggle and a
|
||||
* line of absolute paths at all times; both were clutter next to the one thing the
|
||||
* window is for, which is the titles. The switch lives in the side menu with the rest
|
||||
* of what is not a title, and the panel appears above the grid only while it is on.
|
||||
*/
|
||||
export class LogDrawerView {
|
||||
private readonly toggle = requireElement('log-toggle', HTMLButtonElement)
|
||||
private readonly panel = requireElement('log-panel', HTMLElement)
|
||||
private readonly lines = requireElement('log-lines', HTMLElement)
|
||||
private readonly pathsBox = requireElement('log-paths', HTMLElement)
|
||||
private open = false
|
||||
|
||||
public constructor (private readonly callbacks: LogDrawerViewCallbacks) {
|
||||
this.toggle.addEventListener('click', (): void => {
|
||||
this.lines.hidden = !this.lines.hidden
|
||||
this.toggle.setAttribute('aria-expanded', String(!this.lines.hidden))
|
||||
})
|
||||
this.toggle.addEventListener('click', (): void => { this.setOpen(!this.open) })
|
||||
}
|
||||
|
||||
public render (state: AppState): void {
|
||||
@@ -40,6 +46,19 @@ export class LogDrawerView {
|
||||
}
|
||||
}
|
||||
|
||||
private setOpen (open: boolean): void {
|
||||
this.open = open
|
||||
setHidden(this.panel, !open)
|
||||
this.toggle.setAttribute('aria-expanded', String(open))
|
||||
this.toggle.classList.toggle('is-active', open)
|
||||
if (open) this.lines.scrollTop = this.lines.scrollHeight
|
||||
}
|
||||
|
||||
/**
|
||||
* A line arriving while the panel is shut does not open it: the store logs on every
|
||||
* refresh, and a window that unfolded a panel by itself would be worse than one that
|
||||
* kept quiet. The lines are kept, so opening it later shows what happened.
|
||||
*/
|
||||
public appendLine (line: string): void {
|
||||
this.lines.appendChild(createElement('div', 'log-line', line))
|
||||
while (this.lines.childElementCount > MAX_LOG_LINES) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { AppState } from '../state/AppStore'
|
||||
export interface SideMenuViewCallbacks {
|
||||
readonly onSelectStore: (home: string) => void
|
||||
readonly onAddStore: () => void
|
||||
readonly onSyncAll: () => void
|
||||
readonly onRefresh: () => void
|
||||
readonly onSelectCategory: (filter: CategoryFilter) => void
|
||||
readonly onSelectLocale: (locale: string) => void
|
||||
@@ -27,14 +26,12 @@ export class SideMenuView {
|
||||
private readonly languageHead = requireElement('head-lang', HTMLElement)
|
||||
private readonly storeList = requireElement('store-list', HTMLElement)
|
||||
private readonly addStore = requireElement('add-store', HTMLButtonElement)
|
||||
private readonly syncAll = requireElement('sync-all', HTMLButtonElement)
|
||||
private readonly refresh = requireElement('refresh', HTMLButtonElement)
|
||||
private readonly categories = requireElement('cats', HTMLElement)
|
||||
private readonly locale = requireElement('locale', HTMLSelectElement)
|
||||
|
||||
public constructor (private readonly callbacks: SideMenuViewCallbacks) {
|
||||
this.addStore.addEventListener('click', callbacks.onAddStore)
|
||||
this.syncAll.addEventListener('click', callbacks.onSyncAll)
|
||||
this.refresh.addEventListener('click', callbacks.onRefresh)
|
||||
this.locale.addEventListener('change', (): void => { callbacks.onSelectLocale(this.locale.value) })
|
||||
}
|
||||
@@ -45,7 +42,6 @@ export class SideMenuView {
|
||||
setText(this.categoriesHead, state.messages.categories)
|
||||
setText(this.languageHead, state.messages.language)
|
||||
setText(this.addStore, state.messages.addStore)
|
||||
setText(this.syncAll, state.messages.syncAll)
|
||||
setText(this.refresh, state.messages.refresh)
|
||||
|
||||
this.renderStores(state)
|
||||
@@ -116,7 +112,6 @@ export class SideMenuView {
|
||||
*/
|
||||
private renderEnabled (state: AppState): void {
|
||||
const hasStore = state.currentStore !== null
|
||||
this.syncAll.disabled = state.busy || !hasStore
|
||||
this.refresh.disabled = state.busy || !hasStore
|
||||
this.addStore.disabled = state.busy
|
||||
for (const row of this.storeList.querySelectorAll('button')) row.disabled = state.busy
|
||||
|
||||
@@ -18,6 +18,9 @@ export class TopBarView {
|
||||
|
||||
public render (state: AppState): void {
|
||||
setText(this.appName, state.messages.appName)
|
||||
// The bar names the app, not the store: which store is open is what the switcher in
|
||||
// the side menu says, and saying it twice made the title read like a breadcrumb. The
|
||||
// element stays in the page, hidden, because the window check reads it.
|
||||
setText(this.storeId, state.currentStore === null ? '' : state.currentStore.id)
|
||||
this.navToggle.title = state.messages.menu
|
||||
this.navToggle.setAttribute('aria-label', state.messages.menu)
|
||||
|
||||
@@ -40,7 +40,7 @@ class SmokeTest {
|
||||
private readonly gameMapper = new GameDtoMapper()
|
||||
|
||||
public async run (): Promise<number> {
|
||||
console.log('warp-engine-desktop-gui smoke test')
|
||||
console.log('warp-engine-client smoke test')
|
||||
|
||||
if (!this.checkPython()) return 1
|
||||
this.checkMessages()
|
||||
@@ -89,10 +89,14 @@ class SmokeTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A store repository without a config.json still installs — the engine merges what
|
||||
* it is given onto its defaults — so an absent file is reported, not failed.
|
||||
* 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))
|
||||
@@ -163,9 +167,22 @@ class SmokeTest {
|
||||
}
|
||||
|
||||
private reportListing (games: readonly GameDto[]): void {
|
||||
const native = games.filter((game: GameDto): boolean => game.mode === 'app').length
|
||||
const hosted = games.length - native
|
||||
this.reportOk('list', `${String(games.length)} titles (app:${String(native)}, web:${String(hosted)})`)
|
||||
const installable = games.filter((game: GameDto): boolean => game.installable)
|
||||
const native = installable.filter((game: GameDto): boolean => game.mode === 'app').length
|
||||
const hosted = installable.length - native
|
||||
this.reportOk('list', `${String(games.length)} titles ` +
|
||||
`(app:${String(native)}, web:${String(hosted)}, unavailable:${String(games.length - installable.length)})`)
|
||||
|
||||
// The listing is supposed to carry what it cannot install, with a reason on each.
|
||||
const unavailable = games.filter((game: GameDto): boolean => !game.installable)
|
||||
const unexplained = unavailable.filter((game: GameDto): boolean => game.unavailableReason === null)
|
||||
if (unexplained.length > 0) this.reportBad('unavailable', `${String(unexplained.length)} have no reason`)
|
||||
else if (unavailable.length > 0) {
|
||||
const first = unavailable[0]
|
||||
if (first !== undefined) {
|
||||
this.reportOk('unavailable', `${String(unavailable.length)}, e.g. ${first.name}: ${first.unavailableReason ?? ''}`)
|
||||
}
|
||||
}
|
||||
|
||||
const withoutTitle = games.filter((game: GameDto): boolean =>
|
||||
game.name.length === 0 || game.title.length === 0 || game.platform.length === 0)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/** How a title runs: unpacked on this machine, or served as a web build. */
|
||||
export type GameModeDto = 'app' | 'web'
|
||||
|
||||
/** Why a title cannot be installed on this machine. */
|
||||
export type UnavailableReasonDto = 'platformOff' | 'hostAsset' | 'noAsset' | 'vetoed'
|
||||
|
||||
/**
|
||||
* A catalog entry as the window needs it.
|
||||
*
|
||||
@@ -23,4 +26,8 @@ export interface GameDto {
|
||||
readonly updateAvailable: boolean
|
||||
readonly installedVersion: string | null
|
||||
readonly launchable: boolean
|
||||
/** False for a title this machine cannot install; it is listed all the same. */
|
||||
readonly installable: boolean
|
||||
readonly unavailableReason: UnavailableReasonDto | null
|
||||
readonly unavailableDetail: string | null
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
export interface RegistryStoreDto {
|
||||
readonly name: string
|
||||
readonly catalogUrl: string
|
||||
readonly storeRepositoryUrl: string
|
||||
/** Derived from the repository name, so the picker can show what it will become. */
|
||||
/** 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. */
|
||||
readonly storeId: string
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
* translation is a compile error rather than a blank label at runtime.
|
||||
*/
|
||||
export const ENGLISH_MESSAGES = {
|
||||
appName: 'WarpEngine Store',
|
||||
syncAll: 'Install all',
|
||||
appName: 'WarpEngine Client',
|
||||
refresh: 'Refresh',
|
||||
install: 'Install',
|
||||
update: 'Update',
|
||||
@@ -19,6 +18,8 @@ export const ENGLISH_MESSAGES = {
|
||||
hostedHint: 'Opens in your browser — needs the network',
|
||||
nativeHint: 'Installed on this machine — works offline',
|
||||
updateAvailable: 'update available',
|
||||
unsupportedPlatform: 'unsupported platform',
|
||||
unsupportedBuild: 'no build for this machine',
|
||||
log: 'Log',
|
||||
menu: 'Menu',
|
||||
stores: 'Stores',
|
||||
@@ -30,6 +31,7 @@ export const ENGLISH_MESSAGES = {
|
||||
catInstalled: 'Installed',
|
||||
catUpdates: 'Updates',
|
||||
catAvailable: 'Not installed',
|
||||
catUnsupported: 'Not for this machine',
|
||||
catPlatform: 'Platform',
|
||||
catMode: 'Kind',
|
||||
language: 'Language',
|
||||
|
||||
@@ -5,8 +5,7 @@ import type { MessageBundle } from './MessageBundle'
|
||||
* arrives from the store as it was published.
|
||||
*/
|
||||
export const HUNGARIAN_MESSAGES: MessageBundle = {
|
||||
appName: 'WarpEngine Store',
|
||||
syncAll: 'Mind telepítése',
|
||||
appName: 'WarpEngine Client',
|
||||
refresh: 'Frissítés',
|
||||
install: 'Telepítés',
|
||||
update: 'Frissítés',
|
||||
@@ -19,6 +18,8 @@ export const HUNGARIAN_MESSAGES: MessageBundle = {
|
||||
hostedHint: 'A böngészőben nyílik meg — internet kell hozzá',
|
||||
nativeHint: 'Erre a gépre telepítve — internet nélkül is megy',
|
||||
updateAvailable: 'frissítés elérhető',
|
||||
unsupportedPlatform: 'nem támogatott platform',
|
||||
unsupportedBuild: 'ehhez a géphez nincs build',
|
||||
log: 'Napló',
|
||||
menu: 'Menü',
|
||||
stores: 'Store-ok',
|
||||
@@ -30,6 +31,7 @@ export const HUNGARIAN_MESSAGES: MessageBundle = {
|
||||
catInstalled: 'Telepítve',
|
||||
catUpdates: 'Frissítés',
|
||||
catAvailable: 'Nincs telepítve',
|
||||
catUnsupported: 'Erre a gépre nem',
|
||||
catPlatform: 'Platform',
|
||||
catMode: 'Fajta',
|
||||
language: 'Nyelv',
|
||||
|
||||
Reference in New Issue
Block a user