Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d42355189 | ||
|
|
26c7aa9be1 | ||
|
|
e35a72336a | ||
|
|
82590d3ec4 | ||
|
|
045c7bf5b7 | ||
|
|
06f3f2a3b1 | ||
|
|
8511ccbef8 |
+3
-8
@@ -27,14 +27,9 @@ steps:
|
|||||||
- npm run typecheck
|
- npm run typecheck
|
||||||
- npm run lint
|
- npm run lint
|
||||||
# The window test wants a display and a store on the machine; that check belongs
|
# 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
|
# where there is one. The bridge check runs here unconditionally — it needs nothing
|
||||||
# registry and the message bundles.
|
# but Node, now that the store engine is part of the application.
|
||||||
- |
|
- npm run smoke
|
||||||
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
|
# 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.
|
# two builds run when a release is being cut — or when asked for by hand.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# WarpEngine Store GUI — the front door to the npm scripts.
|
# WarpEngine Client — the front door to the npm scripts.
|
||||||
#
|
#
|
||||||
# Everything here is a thin wrapper: the app is an Electron project, so npm still
|
# Everything here is a thin wrapper: the app is an Electron project, so npm still
|
||||||
# does the work. The Makefile exists so the useful sequences have names, and so
|
# does the work. The Makefile exists so the useful sequences have names, and so
|
||||||
@@ -18,17 +18,29 @@ SHELL := /bin/sh
|
|||||||
SCRIPTS := scripts
|
SCRIPTS := scripts
|
||||||
NODE_MIN := 22
|
NODE_MIN := 22
|
||||||
|
|
||||||
# The version is package.json's, so the release tag never drifts from the app.
|
# The version is package.json's, so the release tag never drifts from the app. Read with
|
||||||
VERSION := $(shell python3 -c 'import json; print(json.load(open("package.json"))["version"])')
|
# node, which this project already requires — nothing here needs an interpreter the app
|
||||||
|
# itself no longer depends on.
|
||||||
|
VERSION := $(shell node -p 'require("./package.json").version')
|
||||||
TAG ?= v$(VERSION)
|
TAG ?= v$(VERSION)
|
||||||
|
|
||||||
|
# Which site's store registry a packaged build reads. Empty means the default in
|
||||||
|
# package.json (ours); set it to build a client for somebody else's catalog:
|
||||||
|
#
|
||||||
|
# make dist STORES_API=https://games.example.org/api/stores
|
||||||
|
#
|
||||||
|
# It is baked into the package's own package.json, so the built app carries it. A runtime
|
||||||
|
# STORES_API still overrides it, which is for trying something out rather than shipping.
|
||||||
|
STORES_API ?=
|
||||||
|
BUILDER_ARGS := $(if $(STORES_API),-- --config.extraMetadata.warpEngine.registryUrl=$(STORES_API),)
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
.PHONY: help setup node-check build typecheck lint lint-fix check start smoke uitest test \
|
.PHONY: help setup node-check build typecheck lint lint-fix check start smoke uitest test \
|
||||||
dist dist-mac dist-win dist-linux release publish clean distclean version
|
dist dist-mac dist-win dist-linux release publish clean distclean version
|
||||||
|
|
||||||
help: ## List available targets
|
help: ## List available targets
|
||||||
@echo "WarpEngine Store GUI $(VERSION) — usage: make <target>"
|
@echo "WarpEngine Client $(VERSION) — usage: make <target>"
|
||||||
@echo
|
@echo
|
||||||
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \
|
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \
|
||||||
awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
|
awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
|
||||||
@@ -74,16 +86,16 @@ uitest: ## Load the window once and report what rendered
|
|||||||
test: smoke uitest ## Both checks
|
test: smoke uitest ## Both checks
|
||||||
|
|
||||||
dist: node-check ## Package for this machine
|
dist: node-check ## Package for this machine
|
||||||
npm run dist
|
npm run dist $(BUILDER_ARGS)
|
||||||
|
|
||||||
dist-mac: node-check ## Package for macOS (ad-hoc signed, see the README)
|
dist-mac: node-check ## Package for macOS (ad-hoc signed, see the README)
|
||||||
npm run dist:mac
|
npm run dist:mac $(BUILDER_ARGS)
|
||||||
|
|
||||||
dist-win: node-check ## Package for Windows
|
dist-win: node-check ## Package for Windows
|
||||||
npm run dist:win
|
npm run dist:win $(BUILDER_ARGS)
|
||||||
|
|
||||||
dist-linux: node-check ## Package for Linux
|
dist-linux: node-check ## Package for Linux
|
||||||
npm run dist:linux
|
npm run dist:linux $(BUILDER_ARGS)
|
||||||
|
|
||||||
publish: ## Upload the packages already in dist/ to the Gitea release
|
publish: ## Upload the packages already in dist/ to the Gitea release
|
||||||
@TAG=$(TAG) $(SCRIPTS)/release.sh
|
@TAG=$(TAG) $(SCRIPTS)/release.sh
|
||||||
@@ -106,4 +118,5 @@ version: ## Show the versions involved
|
|||||||
@printf "typescript "; npx tsc --version 2>/dev/null || echo "missing"
|
@printf "typescript "; npx tsc --version 2>/dev/null || echo "missing"
|
||||||
@printf "eslint "; npx eslint --version 2>/dev/null || echo "missing"
|
@printf "eslint "; npx eslint --version 2>/dev/null || echo "missing"
|
||||||
@printf "tea "; tea --version 2>/dev/null | head -1 || echo "missing — devarea: make tea"
|
@printf "tea "; tea --version 2>/dev/null | head -1 || echo "missing — devarea: make tea"
|
||||||
@printf "python3 "; python3 --version 2>/dev/null || echo "missing"
|
@printf "registry "; node -p 'require("./package.json").warpEngine.registryUrl'
|
||||||
|
@if [ -n "$(STORES_API)" ]; then printf " build override: %s\n" "$(STORES_API)"; fi
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
# warp-engine-client — the WarpEngine Store app
|
# warp-engine-client — the WarpEngine Client app
|
||||||
|
|
||||||
The graphical client for a WarpEngine store — the app is called **WarpEngine
|
The client for a WarpEngine store: the catalog as a grid of cards, one click to install
|
||||||
Store** — driving
|
a title into your own application menu, one to play it, one to remove it. Linux, macOS
|
||||||
[`warp-engine-desktop-store`](https://git.teletypegames.org/stores/warp-engine-desktop-store)
|
and Windows.
|
||||||
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
|
**The store engine is part of this application.** Reading the catalog, choosing which
|
||||||
`desktop_store.py`, so there is one catalog logic, one state file and one delete
|
release fits this machine, unpacking it, writing the menu entry and remembering what
|
||||||
guard — the window never touches the filesystem itself.
|
went where all happen in process — there is no interpreter to find and no child process
|
||||||
|
to parse. What lands on disk has not changed: `config.json` and `state.json` keep the
|
||||||
|
shape the shell engine wrote, so a machine whose library was installed by the CLI keeps
|
||||||
|
it, and the engine's own defaults still decide everything a store does not configure.
|
||||||
|
|
||||||
It is also **the Windows install path**. The store's own installer is
|
That also makes this the only install path that needs nothing of the machine. The shell
|
||||||
`curl … | sh`, which Windows does not have; this app downloads the store engine
|
store's installer was `curl … | sh`, which Windows does not have.
|
||||||
itself, into the same folder the shell installer would use.
|
|
||||||
|
|
||||||
Which store it installs is not baked in: the client asks a registry — `GET
|
Which store it installs is not baked in: the client asks a registry — `GET
|
||||||
/api/stores` on the site — and each record says what the store is called, which
|
/api/stores` on the site — and each record says what the store is called, which
|
||||||
@@ -20,10 +20,8 @@ catalog it serves and where its configuration lives.
|
|||||||
|
|
||||||
## What it needs
|
## What it needs
|
||||||
|
|
||||||
- **Python 3** on the machine, because the store is a Python program. The app
|
- **Nothing.** No interpreter, no package manager, no admin rights: the app carries its
|
||||||
looks for `python3`, `python` and `py -3`, and says so plainly if none answer.
|
own runtime and the store installs under your own user account.
|
||||||
- Nothing else at runtime. No Node, no package manager, no admin rights: the
|
|
||||||
store installs under your own user account.
|
|
||||||
|
|
||||||
To *develop* it you also need **Node 22 or newer** — see below — and nothing else: the
|
To *develop* it you also need **Node 22 or newer** — see below — and nothing else: the
|
||||||
toolchain (TypeScript, ESLint, esbuild, electron-builder) installs with `make setup`.
|
toolchain (TypeScript, ESLint, esbuild, electron-builder) installs with `make setup`.
|
||||||
@@ -41,15 +39,15 @@ The build is ad-hoc signed but **not notarised**, so macOS asks before running a
|
|||||||
copy that came from a browser. The reliable way through:
|
copy that came from a browser. The reliable way through:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
xattr -dr com.apple.quarantine "/Applications/WarpEngine Store.app"
|
xattr -dr com.apple.quarantine "/Applications/WarpEngine Client.app"
|
||||||
```
|
```
|
||||||
|
|
||||||
If macOS offers *Open Anyway* under **System Settings ▸ Privacy & Security** after
|
If macOS offers *Open Anyway* under **System Settings ▸ Privacy & Security** after
|
||||||
a blocked attempt, that works as well. Notarisation is the only thing that removes
|
a blocked attempt, that works as well. Notarisation is the only thing that removes
|
||||||
the step entirely, and it needs a paid Apple Developer ID.
|
the step entirely, and it needs a paid Apple Developer ID.
|
||||||
|
|
||||||
Nothing the store itself downloads is affected: Python fetches those files, and
|
Nothing the store itself downloads is affected: the app fetches those files over its
|
||||||
Python does not set the quarantine flag.
|
own HTTP client, which does not set the quarantine flag.
|
||||||
|
|
||||||
**v1.0.0 could not be opened at all** — it reported *"is damaged"*. The bundle had
|
**v1.0.0 could not be opened at all** — it reported *"is damaged"*. The bundle had
|
||||||
never been signed; only its main executable carried the linker's ad-hoc signature,
|
never been signed; only its main executable carried the linker's ad-hoc signature,
|
||||||
@@ -57,6 +55,45 @@ so there was no resource seal and Gatekeeper refused it outright rather than
|
|||||||
asking. `scripts/after-pack.js` signs the bundle during the build now, and the
|
asking. `scripts/after-pack.js` signs the bundle during the build now, and the
|
||||||
result verifies as `valid on disk`.
|
result verifies as `valid on disk`.
|
||||||
|
|
||||||
|
## Signing in, and titles that cost money
|
||||||
|
|
||||||
|
**Nothing in this client knows anything about a particular store.** What a title costs,
|
||||||
|
whether it needs an account, where to buy it and where to sign in all arrive from the
|
||||||
|
catalog's own server — WarpEngine 0.5 answers `GET /api/service` with what it offers, and
|
||||||
|
puts an `access` block on every catalog entry. A client that carried those facts would
|
||||||
|
work for exactly one shop; this one asks.
|
||||||
|
|
||||||
|
Where the server offers no sign-in — every WarpEngine before 0.5, and any store that
|
||||||
|
sells nothing — the window shows none, and behaves exactly as it always did.
|
||||||
|
|
||||||
|
Where it does:
|
||||||
|
|
||||||
|
- the side menu grows an **Account** block: *Sign in…*, and *Sign out* once you are;
|
||||||
|
- signing in shows a **short code**. Your browser opens on the store's own page and you
|
||||||
|
type the code there; approving it signs this device in. Nothing is typed into this
|
||||||
|
window, and no password ever reaches it — that is the whole reason for the detour;
|
||||||
|
- the token is kept in the **OS keychain** (Keychain, libsecret, DPAPI) through
|
||||||
|
Electron's `safeStorage`, one per store. Where no keychain is available it is not
|
||||||
|
stored at all rather than written out in the clear: the cost is signing in again next
|
||||||
|
run.
|
||||||
|
|
||||||
|
On a card, what you may do with a title is separate from what this machine can run:
|
||||||
|
|
||||||
|
- **owned** or free → *Install*, as before;
|
||||||
|
- **not owned** → the **price** on the card and a **Buy** button, which opens the store's
|
||||||
|
page in your browser. Buying happens there, not here — a checkout rebuilt in this
|
||||||
|
window would be a second place to get card handling wrong. **Refresh** afterwards and
|
||||||
|
the card becomes an *Install*;
|
||||||
|
- **signed out, catalog gates it** → *Sign in to install*, because the catalog cannot say
|
||||||
|
whether it is yours until it knows who is asking.
|
||||||
|
|
||||||
|
Two new categories go with it: **Owned** and **To buy**. Owning something is not the
|
||||||
|
same as having installed it, which is the point of the first one.
|
||||||
|
|
||||||
|
A title nobody has bought is **not** dimmed. That treatment belongs to what this
|
||||||
|
*machine* cannot do — an unsupported platform, no build for this architecture — and
|
||||||
|
there is nothing wrong with the machine here.
|
||||||
|
|
||||||
## Which store it installs
|
## Which store it installs
|
||||||
|
|
||||||
On first run the client fetches the registry and offers what it finds. One store
|
On first run the client fetches the registry and offers what it finds. One store
|
||||||
@@ -64,47 +101,52 @@ and there is nothing to decide; several and the setup screen shows a picker.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org", "storeRepositoryUrl": null },
|
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org" },
|
||||||
{
|
{ "name": "Some Other Store", "catalogUrl": "https://games.example.org" }
|
||||||
"name": "Some Other Store",
|
|
||||||
"catalogUrl": "https://games.example.org",
|
|
||||||
"storeRepositoryUrl": "https://git.example.org/stores/other-desktop-store"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
**A store needs no repository of its own.** A name and a catalog are enough: the
|
**A name and a catalog are the whole record.** The store engine's built-in defaults
|
||||||
store engine's built-in defaults already cover the host-to-asset mapping, the
|
already cover the host-to-asset mapping, the install modes, the platforms and the
|
||||||
install modes, the platforms and the behaviour, so what is actually missing from
|
behaviour, so what is actually missing from them is identity — and identity is all a
|
||||||
them is identity — a slug, a name and a catalog URL — and that is exactly what a
|
registry says. Nothing a record carries decides where files go: how a store behaves is
|
||||||
registry record carries. With `storeRepositoryUrl` null the client writes a
|
fixed per installed client, which knows its own machine, and a copy of that on a server
|
||||||
three-section config and the store installs.
|
would be a second authority over decisions this side has already made.
|
||||||
|
|
||||||
From a record the client works out the rest:
|
From a record the client works out the rest:
|
||||||
|
|
||||||
- **the store id** — which names the store home and the folder games land in —
|
- **the store id** — which names the store home and the folder games land in — is a slug
|
||||||
comes from the repository name when there is one (`ttg-desktop-store` becomes
|
of the catalog host (`teletypegames.org` becomes `teletypegames`), or of the display
|
||||||
`ttg`), otherwise from the catalog host (`teletypegames.org` becomes
|
name if that fails. Derived from the *catalog* on purpose: the catalog is what a store
|
||||||
`teletypegames`), otherwise from the display name. A `config.json` that sets its
|
is, so two records naming the same one are the same store and land in the same place.
|
||||||
own id keeps it.
|
Reinstalling therefore never orphans what is already installed.
|
||||||
- **`catalogUrl` and `name`** override the config's own `store.base_url` and
|
- **the games folder** is that same slug inside the OS's usual place for programs, and it
|
||||||
`store.name`. The registry says which catalog this store is *for*, so it wins.
|
is the only subtree this store will ever delete from. That is the whole of how two
|
||||||
- **`storeRepositoryUrl`**, when given → the store's `config.json`, read from
|
stores on one machine stay out of each other's files: a subfolder, derived here.
|
||||||
`…/raw/branch/master/config.json`. That file stays the authority on how the store
|
- **released, archived and demo** titles are listed, where the engine alone would show
|
||||||
behaves: which platforms, which statuses, where things land. A repository
|
released and archived only — a catalog that publishes a demo means it to be played.
|
||||||
**without** a `config.json` is treated as no repository at all.
|
|
||||||
|
Because a record has no paths in it and no config, there is nothing for the window to
|
||||||
|
tamper with: `RegistryStoreDtoMapper.toModel` can take its choice at face value, and the
|
||||||
|
config that lands on disk is written by the installer from the engine's own defaults.
|
||||||
|
|
||||||
What the defaults produce, for a record with no repository: the games land in a
|
What the defaults produce, for a record with no repository: the games land in a
|
||||||
folder named after the store id, and released, archived **and demo** titles are
|
folder named after the store id, and released, archived **and demo** titles are
|
||||||
listed — a catalog that publishes a demo means it to be played.
|
listed — a catalog that publishes a demo means it to be played.
|
||||||
|
|
||||||
The registry address is the single thing about a particular site left in the
|
The registry address is the single thing about a particular site left in the client,
|
||||||
client, and `STORES_API` overrides it:
|
and it is decided in three places, most specific first:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
STORES_API=http://127.0.0.1:8731/stores npm start
|
STORES_API=http://127.0.0.1:8731/stores npm start # runtime: for trying something out
|
||||||
|
make dist STORES_API=https://games.example.org/api/stores # build: for shipping it
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The build variant is baked into the packaged app's own `package.json`
|
||||||
|
(`warpEngine.registryUrl`, written by `electron-builder --config.extraMetadata`), so a
|
||||||
|
client built for somebody else's catalog needs no source change and no environment on the
|
||||||
|
user's machine. With neither set, the address is ours.
|
||||||
|
|
||||||
Adding a store is therefore a database row on the site — see its ActiveAdmin
|
Adding a store is therefore a database row on the site — see its ActiveAdmin
|
||||||
panel — and not a release of this app.
|
panel — and not a release of this app.
|
||||||
|
|
||||||
@@ -119,11 +161,14 @@ 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
|
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
|
the id would not tell them apart. **Add a store…** brings up the registry
|
||||||
picker, the same one the first run offers.
|
picker, the same one the first run offers.
|
||||||
|
- **Account** appears only where the catalog offers a sign-in, and holds *Sign in…* or
|
||||||
|
*Sign out* — see above.
|
||||||
- **Actions** holds **Refresh**, which re-reads the catalog. Titles are installed
|
- **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.
|
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
|
- **Categories** narrows the grid, one category at a time, with the count next to
|
||||||
each: *Everything*, *Installed*, *Updates*, *Not installed*, then a row per
|
each: *Everything*, *Installed*, *Updates*, *Not installed*, then a row per
|
||||||
**platform** (`godot`, `tic80`, `love`, …) and per **kind** (native or hosted).
|
**platform** (`godot`, `tic80`, `love`, …) and per **kind** (native or hosted).
|
||||||
|
Where the catalog gates anything, **Owned** and **To buy** join them.
|
||||||
The axes are built from what the catalog actually contains — a platform with no
|
The axes are built from what the catalog actually contains — a platform with no
|
||||||
titles is not listed, and a category that disappears under you falls back to
|
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
|
*Everything* rather than leaving an empty grid. There is no genre in a
|
||||||
@@ -139,6 +184,14 @@ once it is there. **Remove** takes a title back out. Each card says whether it i
|
|||||||
build the catalog serves rather than packages, so its entry opens a page and needs
|
build the catalog serves rather than packages, so its entry opens a page and needs
|
||||||
the network.
|
the network.
|
||||||
|
|
||||||
|
**Everything in the catalog is listed, including what this machine cannot install.**
|
||||||
|
Those cards are dimmed, carry an *unsupported platform* or *no build for this machine*
|
||||||
|
badge with the engine's own explanation under it, and have nothing to press. A store
|
||||||
|
that hides them leaves you wondering whether the catalog is small or your machine is
|
||||||
|
unusual; this way it says which. They have a category of their own — *Not for this
|
||||||
|
machine* — and they are left out of the native/hosted counts, because a title with no
|
||||||
|
build has no mode to be counted under.
|
||||||
|
|
||||||
Every card carries a band of box art the same height — the first letter of the
|
Every card carries a band of box art the same height — the first letter of the
|
||||||
title when the catalog has no image — so titles and buttons line up across a row.
|
title when the catalog has no image — so titles and buttons line up across a row.
|
||||||
Until this was photographed, the grid was quietly broken: the rows split the
|
Until this was photographed, the grid was quietly broken: the rows split the
|
||||||
@@ -166,6 +219,7 @@ names. `make` on its own lists everything.
|
|||||||
| `make check` | **typecheck, lint and both test suites** — the gate |
|
| `make check` | **typecheck, lint and both test suites** — the gate |
|
||||||
| `make start` | run the app against whatever store is installed |
|
| `make start` | run the app against whatever store is installed |
|
||||||
| `make smoke` | drive the store with no window and no Electron at all |
|
| `make smoke` | drive the store with no window and no Electron at all |
|
||||||
|
| `SMOKE_HOME=<dir> SMOKE_TOKEN=<bearer> npm run smoke` | the same, against a sandbox store and as a signed-in person |
|
||||||
| `make uitest` | load the window once and report what rendered |
|
| `make uitest` | load the window once and report what rendered |
|
||||||
| `SELFTEST_SHOT=shot.png npm run uitest` | the same, and the window photographs itself into that file |
|
| `SELFTEST_SHOT=shot.png npm run uitest` | the same, and the window photographs itself into that file |
|
||||||
| `make test` | both test suites |
|
| `make test` | both test suites |
|
||||||
@@ -261,9 +315,14 @@ on its own: publishing 1.2.0 got *"invalid username, password or token"* on the
|
|||||||
second package while the first had just gone up with the same token, and the same
|
second package while the first had just gone up with the same token, and the same
|
||||||
command succeeded immediately afterwards.
|
command succeeded immediately afterwards.
|
||||||
|
|
||||||
Package names contain a space — `WarpEngine Store-1.2.0-arm64.dmg` — so the list of
|
Package names have no spaces in them — `WarpEngineClient-2.3.0-arm64.dmg` — because a
|
||||||
files is passed one path per line rather than as one string; splitting it on
|
space in a release asset is a space in every `curl`, script and shell command that ever
|
||||||
whitespace is what broke the first attempt at publishing 1.1.0.
|
touches it. The app itself is still called **WarpEngine Client**: that name is what
|
||||||
|
appears in the Dock and in `/Applications`, and only the file names were the problem.
|
||||||
|
|
||||||
|
The list of files is still passed one path per line rather than as one string, since a
|
||||||
|
path given on the command line can contain a space even when a built one cannot;
|
||||||
|
splitting it on whitespace is what broke the first attempt at publishing 1.1.0.
|
||||||
|
|
||||||
It needs `tea` installed and logged in — the devarea repo has `make tea` for that.
|
It needs `tea` installed and logged in — the devarea repo has `make tea` for that.
|
||||||
Overridable: `TAG`, `REPO`, `TEA_LOGIN`, `NOTES`, `DIST`.
|
Overridable: `TAG`, `REPO`, `TEA_LOGIN`, `NOTES`, `DIST`.
|
||||||
@@ -297,9 +356,9 @@ is the map** — the layers, every pattern in use, and the naming rules. The sho
|
|||||||
| Layer | What lives there |
|
| Layer | What lives there |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `src/shared/` | the IPC channel table, the bridge contract, the DTOs, the two message bundles |
|
| `src/shared/` | the IPC channel table, the bridge contract, the DTOs, the two message bundles |
|
||||||
| `src/domain/` | models, ports and errors — no Electron, no Node, no Python |
|
| `src/domain/` | models, ports and errors — no Electron, no Node |
|
||||||
| `src/application/` | services and the domain → DTO mappers |
|
| `src/application/` | services and the domain → DTO mappers |
|
||||||
| `src/infrastructure/` | the adapters: the Python CLI, HTTP, the filesystem, Electron itself |
|
| `src/infrastructure/` | the adapters: the store engine, HTTP, the archive reader, the filesystem, Electron itself |
|
||||||
| `src/main/` | the window, the IPC controllers, the composition root, the self-test |
|
| `src/main/` | the window, the IPC controllers, the composition root, the self-test |
|
||||||
| `src/preload/` | the bridge, bundled into one file — a sandboxed preload cannot require modules |
|
| `src/preload/` | the bridge, bundled into one file — a sandboxed preload cannot require modules |
|
||||||
| `src/renderer/` | the state store, the views and the renderer controllers |
|
| `src/renderer/` | the state store, the views and the renderer controllers |
|
||||||
@@ -320,17 +379,62 @@ Two properties are worth stating because they are what the layers buy:
|
|||||||
CSP that allows only its own script and stylesheet plus images over HTTPS. Links open
|
CSP that allows only its own script and stylesheet plus images over HTTPS. Links open
|
||||||
in the real browser; the window itself never navigates.
|
in the real browser; the window itself never navigates.
|
||||||
|
|
||||||
`PythonStoreCatalogGateway` is the only class that knows the store is a Python
|
`NativeStoreCatalogGateway` is the engine behind the `StoreCatalogGateway` port, and
|
||||||
program. It talks to the CLI through `--json`, which puts data on stdout and the
|
`src/infrastructure/engine/` is the engine itself: the catalog client, the release
|
||||||
human-readable log on stderr. That flag arrived with engine **1.1.0**, and the client
|
picker, the host match, the payload installer, the three launcher writers and the state
|
||||||
checks: an older store is met with an offer to refresh it rather than a failed call.
|
file. Above the port nothing knows any of that exists, which is the point — a second
|
||||||
|
host would be a second gateway, not a second code path.
|
||||||
|
|
||||||
`STORE_ENGINES` has one entry today. The RetroArch store has the same command shape,
|
`STORE_ENGINES` has one entry today. A RetroArch store writes playlists rather than
|
||||||
so a second entry is the whole change needed to drive it too — that is why the table is
|
menu entries, so it would be an entry there and a gateway of its own.
|
||||||
there.
|
|
||||||
|
### Reading a zip without a dependency
|
||||||
|
|
||||||
|
Node has no zip reader and this application has **no runtime dependencies**, so
|
||||||
|
`src/infrastructure/archive/ZipArchive.ts` is one over `node:zlib` — about 150 lines that
|
||||||
|
walk the central directory, inflate `stored` and `deflate` entries, and restore the
|
||||||
|
executable bit from each entry's external attributes. That last part is not a detail: the
|
||||||
|
archive records it, and without it nothing the store installs can start.
|
||||||
|
|
||||||
|
It reads what our own release pipeline produces and refuses the rest: a zip64 archive, an
|
||||||
|
unknown compression method and a path that would escape the destination are all errors
|
||||||
|
rather than best guesses.
|
||||||
|
|
||||||
|
### Which WarpEngine served the catalog
|
||||||
|
|
||||||
|
Every WarpEngine API response carries a `WarpEngine-Version` header, so the client knows
|
||||||
|
the engine's age without asking. `SUPPORTED_WARP_ENGINE_VERSIONS` lists the versions this
|
||||||
|
client is written against, and `selectCatalogDialect` maps each one to the `CatalogDialect`
|
||||||
|
that reads its catalog shape.
|
||||||
|
|
||||||
|
The switch over that list is exhaustive, which is the whole mechanism: adding a version to
|
||||||
|
the array stops the build — in the type checker *and* in the linter — until somebody says
|
||||||
|
what it reads like. A new engine version cannot arrive silently.
|
||||||
|
|
||||||
|
| What the header says | What happens |
|
||||||
|
|---|---|
|
||||||
|
| a supported version | its dialect reads the catalog, and the log names it |
|
||||||
|
| nothing at all | read as the oldest supported version, which is what an engine older than 0.4.0 is |
|
||||||
|
| older than anything supported | the same, and the log says so |
|
||||||
|
| newer than anything supported | the newest dialect is tried anyway, with a warning that titles may be missed |
|
||||||
|
|
||||||
|
Three versions share one dialect today, because the catalog's shape has not changed
|
||||||
|
across them. One class serving three versions is the honest way to say that.
|
||||||
|
|
||||||
## Verified, and not
|
## Verified, and not
|
||||||
|
|
||||||
|
**2.2.0** — the registry record was cut back to a name and a catalog, so the whole
|
||||||
|
install path was measured again against a local registry serving exactly that. The slug
|
||||||
|
came out `teletypegames` from the catalog host, the home `teletypegames-desktop`, the
|
||||||
|
games subfolder `teletypegames`, and installing the same record twice landed in the same
|
||||||
|
home. A record carrying `config` and `storeRepositoryUrl` — the fields a stale client or a
|
||||||
|
tampering renderer might still send — changed nothing, because neither exists in the model
|
||||||
|
any more. The site side was migrated and its specs re-run; the frontend was built, which
|
||||||
|
first required removing a dead `engines` list that had been failing `vue-tsc` on master.
|
||||||
|
|
||||||
|
Older entries below describe what was verified for the version they name, and some of
|
||||||
|
them predate the store engine moving into this application.
|
||||||
|
|
||||||
The pipeline's commands were run in the same containers it uses, before the pipeline was
|
The pipeline's commands were run in the same containers it uses, before the pipeline was
|
||||||
committed: `electronuserland/builder:22` installs, type-checks, lints, passes the smoke
|
committed: `electronuserland/builder:22` installs, type-checks, lints, passes the smoke
|
||||||
test (registry reached, store skipped as it should be on a machine that has none) and
|
test (registry reached, store skipped as it should be on a machine that has none) and
|
||||||
|
|||||||
+28
-84
@@ -1,90 +1,34 @@
|
|||||||
# WarpEngine Store 1.4.0
|
# WarpEngine Client 2.4.0
|
||||||
|
|
||||||
**A store no longer needs a repository of its own.** Until now every store in the
|
**The catalog can now say a title is not yours, and the client can do something about
|
||||||
registry pointed at a repository holding its `config.json`, and the client read that
|
it.** Where a store sells things, a card shows the **price** and a **Buy** button that
|
||||||
file to know what to install. It turns out almost nothing in there was necessary: the
|
opens the store's own page; where you own it, an **Install** as before. Two new
|
||||||
store engine's built-in defaults already cover the host-to-asset mapping, the install
|
categories go with it — **Owned** and **To buy** — because owning something is not the
|
||||||
modes, the platforms and the behaviour. What defaults cannot know is *identity* — a
|
same as having installed it.
|
||||||
slug, a name and a catalog URL — and that is exactly what a registry record carries.
|
|
||||||
|
|
||||||
So `storeRepositoryUrl` is now optional. A record with a name and a catalog URL is a
|
**Signing in, without a password ever reaching this window.** The side menu grows an
|
||||||
complete store: the client derives the slug from the catalog host
|
**Account** block, and signing in shows a short code: your browser opens on the store's
|
||||||
(`teletypegames.org` → `teletypegames`), writes a small config and installs. Given a
|
own page and you type it there. That detour is the point — a desktop application asking
|
||||||
repository it still reads it, and that file remains the authority on how the store
|
for a password is a desktop application people should not be giving one to. The token
|
||||||
behaves — which platforms it offers, which statuses it shows, where things land. A
|
lives in the OS keychain (Keychain, libsecret, DPAPI), one per store, and *Sign out*
|
||||||
repository without a `config.json` is treated as no repository at all.
|
revokes it at the server as well as forgetting it here.
|
||||||
|
|
||||||
With the defaults, games land in a folder named after the store id and released,
|
**None of this is knowledge about any particular store.** It all arrives from the
|
||||||
archived **and demo** titles are listed: a catalog that publishes a demo means it to
|
catalog's own server: WarpEngine 0.5 answers `GET /api/service` with what it offers, and
|
||||||
be played.
|
puts an `access` block on every entry. A client that carried those facts would work for
|
||||||
|
exactly one shop — this one asks, which is why the same build serves any of them.
|
||||||
|
|
||||||
The site's registry endpoint changed to match — `storeRepositoryUrl` answers `null`
|
**A store with no sign-in shows none.** Every WarpEngine before 0.5 has no descriptor at
|
||||||
when there is none — and adding a store is now genuinely one database row with two
|
all, and a 0.5 store that sells nothing reports none either. Both read as "a plain
|
||||||
fields filled in.
|
catalog", which is what this client assumed for its whole life until now, and the window
|
||||||
|
behaves accordingly: no Account block, no prices, no new categories.
|
||||||
|
|
||||||
**The "Install all" button is gone.** Titles are installed one at a time from their
|
**A title nobody has bought is not dimmed.** The dimming and the dashed badge belong to
|
||||||
own cards.
|
what this *machine* cannot do — an unsupported platform, no build for this architecture
|
||||||
|
— and there is nothing wrong with the machine when a title simply costs money.
|
||||||
|
|
||||||
**No footer.** The window carried a bar at the bottom at all times — a toggle and a
|
**A bearer token is never sent to a host that did not issue it.** A gated download
|
||||||
line of absolute paths — for something most sessions never need. The log is still
|
answers with a redirect to signed storage, often somebody else's server, and some object
|
||||||
there, with the two folder buttons in it, but it lives behind a quiet switch at the
|
stores refuse a request outright when an `Authorization` header rides along with the
|
||||||
bottom of the side menu and takes no room until it is opened. The grid gets the height
|
signature. The credential stops at the origin it belongs to; a redirect back to the
|
||||||
back.
|
catalog keeps it.
|
||||||
|
|
||||||
**The repository is now `warp-engine-client`.** The app has always been called
|
|
||||||
WarpEngine Store; `warp-engine-desktop-gui` described the role rather than the
|
|
||||||
product, and the host-specific engines keep their own shape
|
|
||||||
(`warp-engine-desktop-store`, `-retroarch-store`, `-batocera-store`). Gitea keeps a
|
|
||||||
redirect from the old path, and the releases and tags moved with the repository, so
|
|
||||||
existing links and clones still resolve.
|
|
||||||
|
|
||||||
### Three things a screenshot found
|
|
||||||
|
|
||||||
Photographing the setup screen — which no automated count had ever looked at — turned
|
|
||||||
up three faults that every check had passed:
|
|
||||||
|
|
||||||
- the store badge in the bar rendered as an empty pill when no store was open;
|
|
||||||
- the gate's store picker showed as an empty dropdown stub, because an explicit
|
|
||||||
`display` in the stylesheet beats the browser's own `[hidden]` rule;
|
|
||||||
- the gate went up while the *"No installable titles in the catalog"* line stayed on
|
|
||||||
screen underneath it.
|
|
||||||
|
|
||||||
The last one was a design fault, not a typo: whether the gate is up was an imperative
|
|
||||||
call on a view rather than state, so the gate and the grid could disagree. The setup
|
|
||||||
screen is now a field in the state store, and that one field decides which of the two
|
|
||||||
is drawn. The window test's gate assertion was wrong too — it demanded a store picker,
|
|
||||||
which only appears when the registry offers more than one store, so a perfectly good
|
|
||||||
window failed it.
|
|
||||||
|
|
||||||
### Linux and Windows packages now come from CI
|
|
||||||
|
|
||||||
A `vX.Y.Z` tag now starts the pipeline, which builds the AppImage, the deb, the NSIS
|
|
||||||
installer and the portable exe — Windows through Wine — **creates this release** and
|
|
||||||
attaches all four. macOS stays a local build, because Apple's toolchain and its signing
|
|
||||||
exist only on a Mac, so `make release` from a Mac pushes that package onto the same
|
|
||||||
release afterwards. Publishing uses a `gitea_token` repository secret in Woodpecker.
|
|
||||||
|
|
||||||
The Windows installer is not signed: Windows will warn about an unknown publisher until
|
|
||||||
there is a certificate.
|
|
||||||
|
|
||||||
### Opening it on macOS
|
|
||||||
|
|
||||||
Ad-hoc signed, **not notarised**, so macOS asks first:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
xattr -dr com.apple.quarantine "/Applications/WarpEngine Store.app"
|
|
||||||
```
|
|
||||||
|
|
||||||
### What is attached
|
|
||||||
|
|
||||||
The macOS arm64 package, built and verified here, plus whatever the pipeline attaches
|
|
||||||
for Linux (AppImage, deb) and Windows (installer, portable).
|
|
||||||
|
|
||||||
### Verified
|
|
||||||
|
|
||||||
A repository-less store was installed end to end against 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 three sections, engine 1.1.0
|
|
||||||
accepted it, it listed the same ten titles the configured store does, and a hosted
|
|
||||||
title synced into a sandbox with its menu entry written. `make check` is clean, and
|
|
||||||
the setup gate was photographed on a machine with no store at all.
|
|
||||||
|
|||||||
+54
-23
@@ -12,9 +12,9 @@ that reaches the window is a safety question, not a style one.
|
|||||||
|
|
||||||
```
|
```
|
||||||
shared ← contracts and strings both sides need (no logic, no I/O)
|
shared ← contracts and strings both sides need (no logic, no I/O)
|
||||||
domain ← models, ports, errors. Knows nothing about Electron, Node or Python
|
domain ← models, ports, errors. Knows nothing about Electron or Node
|
||||||
application ← services and DTO mappers. Orchestrates the domain through its ports
|
application ← services and DTO mappers. Orchestrates the domain through its ports
|
||||||
infrastructure ← adapters: the Python CLI, HTTP, the filesystem, Electron itself
|
infrastructure ← adapters: the store engine, HTTP, the filesystem, Electron itself
|
||||||
main ← the Electron host: window, IPC controllers, composition root
|
main ← the Electron host: window, IPC controllers, composition root
|
||||||
preload ← the bridge, and only the bridge
|
preload ← the bridge, and only the bridge
|
||||||
renderer ← the window: state store, views, controllers
|
renderer ← the window: state store, views, controllers
|
||||||
@@ -57,12 +57,19 @@ src/
|
|||||||
services/ CatalogService, StoreSelectionService, …
|
services/ CatalogService, StoreSelectionService, …
|
||||||
mappers/ domain → DTO
|
mappers/ domain → DTO
|
||||||
infrastructure/
|
infrastructure/
|
||||||
process/ Python: locating it, running it, reading its streams
|
engine/ the store engine: catalog, releases, install, state
|
||||||
|
dialects/ one per WarpEngine version's catalog shape
|
||||||
|
ServiceDescriptorClient what the catalog's server says it offers (GET /api/service)
|
||||||
|
DeviceSignInClient the device authorization grant, client side
|
||||||
|
launchers/ .desktop, .app bundle, .lnk — the three hosts
|
||||||
|
archive/ ZipArchive: a zip reader over node:zlib
|
||||||
|
files/ StoreFileSystem: atomic writes and the delete guard
|
||||||
repositories/ the port implementations
|
repositories/ the port implementations
|
||||||
mappers/ engine JSON → domain
|
http/ HttpTextClient, StoreHttpClient, HttpStatusError
|
||||||
http/ HttpTextClient, HttpStatusError
|
|
||||||
json/ JsonRecord: reading data that came from elsewhere
|
json/ JsonRecord: reading data that came from elsewhere
|
||||||
electron/ ApplicationEnvironment and GameLauncher adapters
|
config/ BuildConfiguration: what was decided when this was packaged
|
||||||
|
electron/ ApplicationEnvironment, GameLauncher, and the keychain
|
||||||
|
credential store
|
||||||
main/
|
main/
|
||||||
main.ts the entry point: one line of work
|
main.ts the entry point: one line of work
|
||||||
ElectronApplication.ts lifecycle, single instance, self-test mode
|
ElectronApplication.ts lifecycle, single instance, self-test mode
|
||||||
@@ -94,9 +101,13 @@ on this list, it belongs on this list.
|
|||||||
### Ports and adapters
|
### Ports and adapters
|
||||||
|
|
||||||
`domain/ports/*` are interfaces; `infrastructure/*` implements them; the composition
|
`domain/ports/*` are interfaces; `infrastructure/*` implements them; the composition
|
||||||
root is the only file that knows which implementation is in use. This is what makes
|
root is the only file that knows which implementation is in use. This is what makes the
|
||||||
the Python CLI, the registry HTTP call and Electron's `shell` replaceable — by a stub
|
store engine, the registry HTTP call and Electron's `shell` replaceable — by a stub in a
|
||||||
in a test, by a local endpoint in development, by a second engine later.
|
test, by a local endpoint in development, by a second host's engine later.
|
||||||
|
|
||||||
|
It has already paid for itself once: the engine used to be a Python CLI driven as a child
|
||||||
|
process, and replacing it with one that runs in process was a new adapter behind the same
|
||||||
|
port. Nothing in `application`, `main` or `renderer` changed shape for it.
|
||||||
|
|
||||||
### Repository and Gateway
|
### Repository and Gateway
|
||||||
|
|
||||||
@@ -104,8 +115,10 @@ Both are ports; the distinction is what is behind them.
|
|||||||
|
|
||||||
- **Repository** — a store of records this application owns the shape of:
|
- **Repository** — a store of records this application owns the shape of:
|
||||||
`InstalledStoreRepository`, `PreferencesRepository`, `StoreRegistryRepository`.
|
`InstalledStoreRepository`, `PreferencesRepository`, `StoreRegistryRepository`.
|
||||||
- **Gateway** — another program or service with its own protocol:
|
- **Gateway** — something with a protocol of its own, whether or not it is another
|
||||||
`StoreCatalogGateway` (the engine).
|
process: `StoreCatalogGateway` (the store engine), which today is
|
||||||
|
`NativeStoreCatalogGateway` in this application and was a Python CLI before it. The port
|
||||||
|
stays async because the work is: it downloads and unpacks.
|
||||||
|
|
||||||
### Service
|
### Service
|
||||||
|
|
||||||
@@ -118,8 +131,11 @@ controller or a view.
|
|||||||
Data crossing a boundary is a DTO, and a mapper converts. Two boundaries, two
|
Data crossing a boundary is a DTO, and a mapper converts. Two boundaries, two
|
||||||
directions:
|
directions:
|
||||||
|
|
||||||
- `infrastructure/mappers/Engine*Mapper` — engine JSON (snake_case) → domain model.
|
- `infrastructure/engine/dialects/*` — catalog JSON → typed catalog records. These are
|
||||||
These are the only files that know the engine's field names.
|
the only files that know a WarpEngine version's field names.
|
||||||
|
- `infrastructure/engine/StoreConfigurationReader`, `StoreStateRepository` — the two
|
||||||
|
snake_case files on disk → domain models. These are the only files that know the on-disk
|
||||||
|
field names, which are the shell engine's and stay that way.
|
||||||
- `application/mappers/*DtoMapper` — domain model → DTO for the bridge. Decisions the
|
- `application/mappers/*DtoMapper` — domain model → DTO for the bridge. Decisions the
|
||||||
window must not make live here: the absolute box-art URL, whether a title can be
|
window must not make live here: the absolute box-art URL, whether a title can be
|
||||||
launched at all.
|
launched at all.
|
||||||
@@ -175,8 +191,8 @@ never calls the bridge.
|
|||||||
### Error hierarchy with codes
|
### Error hierarchy with codes
|
||||||
|
|
||||||
`DomainError` is abstract with a `code`; subclasses name a single failure
|
`DomainError` is abstract with a `code`; subclasses name a single failure
|
||||||
(`PythonMissingError`, `StoreMissingError`, `EngineInvocationError`,
|
(`StoreMissingError`, `EngineInvocationError`, `RegistryUnavailableError`, `BusyError`).
|
||||||
`RegistryUnavailableError`, `BusyError`). The code is what crosses the bridge.
|
The code is what crosses the bridge.
|
||||||
|
|
||||||
### Frozen constant tables
|
### Frozen constant tables
|
||||||
|
|
||||||
@@ -184,9 +200,18 @@ never calls the bridge.
|
|||||||
type, so a typo is a compile error and adding an entry is the whole change. This is
|
type, so a typo is a compile error and adding an entry is the whole change. This is
|
||||||
the extension point for a second engine.
|
the extension point for a second engine.
|
||||||
|
|
||||||
|
### Build-time configuration
|
||||||
|
|
||||||
|
`infrastructure/config/BuildConfiguration.ts` reads the packaged `package.json`, which is
|
||||||
|
where a build records the registry it was made for (`warpEngine.registryUrl`, set by
|
||||||
|
`make dist STORES_API=…`). Precedence is runtime environment, then build, then the
|
||||||
|
built-in default — most specific first, and each one is a different audience: someone
|
||||||
|
trying it out, someone shipping a client for another site, us.
|
||||||
|
|
||||||
### Untrusted-data readers
|
### Untrusted-data readers
|
||||||
|
|
||||||
Anything parsed from outside — engine stdout, the registry — goes through
|
Anything parsed from outside — the catalog, a store's config, the state file, the
|
||||||
|
registry — goes through
|
||||||
`infrastructure/json/JsonRecord.ts`: `unknown` in, a typed value with a stated
|
`infrastructure/json/JsonRecord.ts`: `unknown` in, a typed value with a stated
|
||||||
fallback out. No `as` casts on foreign data.
|
fallback out. No `as` casts on foreign data.
|
||||||
|
|
||||||
@@ -210,7 +235,7 @@ The names are a pattern, not a preference, and are checked by
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Domain model | plain noun, no suffix | `Game`, `InstalledStore` |
|
| Domain model | plain noun, no suffix | `Game`, `InstalledStore` |
|
||||||
| Port | `<Subject>Repository` / `Gateway` / `Locator` / `Installer` / `Launcher` | `StoreCatalogGateway` |
|
| Port | `<Subject>Repository` / `Gateway` / `Locator` / `Installer` / `Launcher` | `StoreCatalogGateway` |
|
||||||
| Adapter | `<Technology><Port>` | `PythonStoreCatalogGateway`, `HttpStoreRegistryRepository`, `FileSystemInstalledStoreRepository` |
|
| Adapter | `<Technology><Port>` | `NativeStoreCatalogGateway`, `HttpStoreRegistryRepository`, `FileSystemInstalledStoreRepository` |
|
||||||
| Service | `<Area>Service` | `CatalogService` |
|
| Service | `<Area>Service` | `CatalogService` |
|
||||||
| Mapper | `<Subject>Mapper` / `<Subject>DtoMapper` | `EngineGameMapper`, `GameDtoMapper` |
|
| Mapper | `<Subject>Mapper` / `<Subject>DtoMapper` | `EngineGameMapper`, `GameDtoMapper` |
|
||||||
| Wire type | `<Subject>Dto` | `CatalogListingDto` |
|
| Wire type | `<Subject>Dto` | `CatalogListingDto` |
|
||||||
@@ -218,7 +243,7 @@ The names are a pattern, not a preference, and are checked by
|
|||||||
| Renderer controller | `<Area>Controller` | `StoreController` |
|
| Renderer controller | `<Area>Controller` | `StoreController` |
|
||||||
| View | `<Region>View` | `SideMenuView`, `GameCardView` |
|
| View | `<Region>View` | `SideMenuView`, `GameCardView` |
|
||||||
| Factory | `<Product>Factory` | `MainWindowFactory` |
|
| Factory | `<Product>Factory` | `MainWindowFactory` |
|
||||||
| Error | `<Cause>Error` | `PythonMissingError` |
|
| Error | `<Cause>Error` | `StoreMissingError` |
|
||||||
| Callback bag | `<Owner>Callbacks` | `SideMenuViewCallbacks` |
|
| Callback bag | `<Owner>Callbacks` | `SideMenuViewCallbacks` |
|
||||||
| Type parameter | `T`-prefixed | `TResult`, `TElement` |
|
| Type parameter | `T`-prefixed | `TResult`, `TElement` |
|
||||||
|
|
||||||
@@ -272,12 +297,18 @@ dependencies are declared here, and that is worth more than being strippable by
|
|||||||
the implementation to `preload.ts`, a `handle…` method to the right controller, and the
|
the implementation to `preload.ts`, a `handle…` method to the right controller, and the
|
||||||
behaviour to a service. The compiler names every file you missed.
|
behaviour to a service. The compiler names every file you missed.
|
||||||
|
|
||||||
**A new engine (e.g. RetroArch).** Add an entry to `STORE_ENGINES`. The store
|
**A new engine (e.g. RetroArch).** Add an entry to `STORE_ENGINES` — the store discovery,
|
||||||
discovery, the home suffix and the launcher name all read from that table; the CLI has
|
the home suffix and the launcher name all read from that table — and a `StoreCatalogGateway`
|
||||||
the same command shape, so `PythonStoreCatalogGateway` is unchanged.
|
implementation for that host. Everything above the port is unchanged.
|
||||||
|
|
||||||
**A new field from the engine.** `EngineGameMapper` reads it into the model, `GameDto`
|
**A new WarpEngine version.** Add it to `SUPPORTED_WARP_ENGINE_VERSIONS`. The build then
|
||||||
and `GameDtoMapper` carry it across if the window needs it, and a view renders it.
|
fails in `selectCatalogDialect` until the switch says which `CatalogDialect` reads it:
|
||||||
|
either an existing one, when the catalog's shape did not change, or a new one beside
|
||||||
|
`SoftwareListCatalogDialect`.
|
||||||
|
|
||||||
|
**A new field from the catalog.** The dialect reads it into `CatalogSoftware`,
|
||||||
|
`CatalogRelease` or `CatalogAsset`; `SelectedGame` and `Game` carry it if the survey or the
|
||||||
|
window needs it; `GameDto` and `GameDtoMapper` take it across the bridge.
|
||||||
|
|
||||||
**A new language.** Add `<Language>Messages.ts` typed as `MessageBundle`, add the code
|
**A new language.** Add `<Language>Messages.ts` typed as `MessageBundle`, add the code
|
||||||
to `LOCALES` and the bundle to `TranslationCatalog`. A missing key will not compile.
|
to `LOCALES` and the bundle to `TranslationCatalog`. A missing key will not compile.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "warp-engine-client",
|
"name": "warp-engine-client",
|
||||||
"version": "1.2.0",
|
"version": "2.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "warp-engine-client",
|
"name": "warp-engine-client",
|
||||||
"version": "1.2.0",
|
"version": "2.4.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^26.2.0",
|
"@types/node": "^26.2.0",
|
||||||
|
|||||||
+21
-5
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "warp-engine-client",
|
"name": "warp-engine-client",
|
||||||
"productName": "WarpEngine Store",
|
"productName": "WarpEngine Client",
|
||||||
"version": "1.4.0",
|
"version": "2.4.0",
|
||||||
"description": "Graphical client for a WarpEngine desktop store: install the catalog into your own application menu.",
|
"description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Teletype Games <games@teletype.hu>",
|
"author": "Teletype Games <games@teletype.hu>",
|
||||||
"homepage": "https://git.teletypegames.org/stores/warp-engine-client",
|
"homepage": "https://git.teletypegames.org/stores/warp-engine-client",
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "org.teletypegames.warpstore.gui",
|
"appId": "org.teletypegames.warpstore.gui",
|
||||||
"productName": "WarpEngine Store",
|
"productName": "WarpEngine Client",
|
||||||
"files": [
|
"files": [
|
||||||
"build/**/*",
|
"build/**/*",
|
||||||
"package.json"
|
"package.json"
|
||||||
@@ -44,7 +44,11 @@
|
|||||||
"target": [
|
"target": [
|
||||||
"dmg",
|
"dmg",
|
||||||
"zip"
|
"zip"
|
||||||
]
|
],
|
||||||
|
"artifactName": "WarpEngineClient-${version}-${arch}-mac.${ext}"
|
||||||
|
},
|
||||||
|
"dmg": {
|
||||||
|
"artifactName": "WarpEngineClient-${version}-${arch}.${ext}"
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
@@ -52,6 +56,12 @@
|
|||||||
"portable"
|
"portable"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"nsis": {
|
||||||
|
"artifactName": "WarpEngineClient-Setup-${version}-${arch}.${ext}"
|
||||||
|
},
|
||||||
|
"portable": {
|
||||||
|
"artifactName": "WarpEngineClient-Portable-${version}-${arch}.${ext}"
|
||||||
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"category": "Game",
|
"category": "Game",
|
||||||
"target": [
|
"target": [
|
||||||
@@ -59,10 +69,16 @@
|
|||||||
"deb"
|
"deb"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"appImage": {
|
||||||
|
"artifactName": "WarpEngineClient-${version}-${arch}.${ext}"
|
||||||
|
},
|
||||||
"afterPack": "scripts/after-pack.js"
|
"afterPack": "scripts/after-pack.js"
|
||||||
},
|
},
|
||||||
"allowScripts": {
|
"allowScripts": {
|
||||||
"electron@43.4.0": true,
|
"electron@43.4.0": true,
|
||||||
"esbuild@0.28.2": true
|
"esbuild@0.28.2": true
|
||||||
|
},
|
||||||
|
"warpEngine": {
|
||||||
|
"registryUrl": "https://teletypegames.org/api/stores"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,9 +53,10 @@ api() {
|
|||||||
curl -fsS -X "$method" -H "$AUTH" "$FORGE$path" "$@"
|
curl -fsS -X "$method" -H "$AUTH" "$FORGE$path" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Package names contain spaces — "WarpEngine Store Setup 1.4.0.exe" does — so the list
|
# The list lives one path per line in a file and is read with `while IFS= read -r`. The
|
||||||
# lives one path per line in a file and is read with `while IFS= read -r`. A single
|
# built package names have no spaces in them any more, but a path given on the command
|
||||||
# variable looped over with $list splits on the space and uploads nothing.
|
# line still can — and a single variable looped over with $list splits on the space and
|
||||||
|
# uploads nothing, which is a silent way to publish a release with no assets.
|
||||||
LIST="$(mktemp)"
|
LIST="$(mktemp)"
|
||||||
trap 'rm -f "$LIST"' EXIT
|
trap 'rm -f "$LIST"' EXIT
|
||||||
if [ "$#" -gt 0 ]; then
|
if [ "$#" -gt 0 ]; then
|
||||||
|
|||||||
+30
-38
@@ -20,23 +20,14 @@ say() { echo "[release] $*"; }
|
|||||||
die() { echo "[release] error: $*" >&2; exit 1; }
|
die() { echo "[release] error: $*" >&2; exit 1; }
|
||||||
|
|
||||||
command -v tea >/dev/null 2>&1 || die "tea is not installed — see the devarea repo, 'make tea'"
|
command -v tea >/dev/null 2>&1 || die "tea is not installed — see the devarea repo, 'make tea'"
|
||||||
command -v python3 >/dev/null 2>&1 || die "python3 is required"
|
command -v node >/dev/null 2>&1 || die "node is required"
|
||||||
[ -f package.json ] || die "run this from the repository root"
|
[ -f package.json ] || die "run this from the repository root"
|
||||||
|
|
||||||
# Version and title in one go, through a heredoc rather than a quoted one-liner:
|
# Version and title from package.json, read with node. This project needs no interpreter
|
||||||
# nesting python quoting inside shell quoting inside a command substitution is how
|
# beyond the one it already builds with — the app itself carries no Python any more, and
|
||||||
# this produced an empty title on its first outing.
|
# neither should the script that ships it.
|
||||||
VERSION="$(python3 - <<'PY'
|
VERSION="$(node -p 'require("./package.json").version')"
|
||||||
import json
|
TITLE="$(node -p 'const d = require("./package.json"); (d.productName || d.name) + " " + d.version')"
|
||||||
print(json.load(open("package.json"))["version"])
|
|
||||||
PY
|
|
||||||
)"
|
|
||||||
TITLE="$(python3 - <<'PY'
|
|
||||||
import json
|
|
||||||
d = json.load(open("package.json"))
|
|
||||||
print((d.get("productName") or d["name"]) + " " + d["version"])
|
|
||||||
PY
|
|
||||||
)"
|
|
||||||
TAG="${TAG:-v$VERSION}"
|
TAG="${TAG:-v$VERSION}"
|
||||||
[ -n "$VERSION" ] || die "cannot read the version from package.json"
|
[ -n "$VERSION" ] || die "cannot read the version from package.json"
|
||||||
[ -n "$TITLE" ] || die "cannot work out a release title"
|
[ -n "$TITLE" ] || die "cannot work out a release title"
|
||||||
@@ -52,9 +43,10 @@ REPO="${REPO:-$(git remote get-url origin 2>/dev/null |
|
|||||||
#
|
#
|
||||||
# - the version filter, because dist/ keeps whatever earlier builds left there
|
# - the version filter, because dist/ keeps whatever earlier builds left there
|
||||||
# and a release would quietly get the previous version's files attached;
|
# and a release would quietly get the previous version's files attached;
|
||||||
# - the spaces. "WarpEngine Store-1.1.0-arm64.dmg" has one, so the list lives one
|
# - the spaces. The built names have none since 2.3.0 — `WarpEngineClient-2.3.0-arm64.dmg`
|
||||||
# path per line in a file and is read with `while IFS= read -r`. Holding it in
|
# — but a path given as an argument still can, so the list stays one path per line in
|
||||||
# a single variable and looping over $list splits it on the space.
|
# a file, read with `while IFS= read -r`. Holding it in a single variable and looping
|
||||||
|
# over $list splits it on the space, and publishes nothing.
|
||||||
LIST="$(mktemp)"
|
LIST="$(mktemp)"
|
||||||
trap 'rm -f "$LIST"' EXIT
|
trap 'rm -f "$LIST"' EXIT
|
||||||
if [ "$#" -gt 0 ]; then
|
if [ "$#" -gt 0 ]; then
|
||||||
@@ -71,12 +63,12 @@ say "$REPO $TAG (version $VERSION), login $LOGIN"
|
|||||||
# The release id, or empty when there is no such tag. `tea api` exits 0 even for a
|
# The release id, or empty when there is no such tag. `tea api` exits 0 even for a
|
||||||
# 404 — it answers {"message":"not found"} — so the body is what has to be read.
|
# 404 — it answers {"message":"not found"} — so the body is what has to be read.
|
||||||
release_id() {
|
release_id() {
|
||||||
tea api "/repos/$REPO/releases/tags/$TAG" 2>/dev/null | python3 -c '
|
tea api "/repos/$REPO/releases/tags/$TAG" 2>/dev/null | node -e '
|
||||||
import json, sys
|
try {
|
||||||
try:
|
console.log(JSON.parse(require("fs").readFileSync(0, "utf8")).id || "")
|
||||||
print(json.load(sys.stdin).get("id") or "")
|
} catch {
|
||||||
except Exception:
|
// Not JSON, or no such release: an empty answer is the "no release yet" case.
|
||||||
pass
|
}
|
||||||
'
|
'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,13 +95,12 @@ RELEASE_ID="$(release_id)"
|
|||||||
# refusing is what makes a rebuild-and-upload repeatable. Called before every
|
# refusing is what makes a rebuild-and-upload repeatable. Called before every
|
||||||
# attempt, so a retry cannot leave two copies behind.
|
# attempt, so a retry cannot leave two copies behind.
|
||||||
drop_existing() {
|
drop_existing() {
|
||||||
ids="$(tea api "/repos/$REPO/releases/$RELEASE_ID/assets" | python3 -c "
|
ids="$(tea api "/repos/$REPO/releases/$RELEASE_ID/assets" | node -e '
|
||||||
import json, sys
|
const name = process.argv[1]
|
||||||
name = sys.argv[1]
|
for (const asset of JSON.parse(require("fs").readFileSync(0, "utf8"))) {
|
||||||
for a in json.load(sys.stdin):
|
if (asset.name === name) console.log(asset.id)
|
||||||
if a['name'] == name:
|
}
|
||||||
print(a['id'])
|
' -- "$1")"
|
||||||
" "$1")"
|
|
||||||
for id in $ids; do
|
for id in $ids; do
|
||||||
say "replacing $1"
|
say "replacing $1"
|
||||||
tea api -X DELETE "/repos/$REPO/releases/$RELEASE_ID/assets/$id" >/dev/null
|
tea api -X DELETE "/repos/$REPO/releases/$RELEASE_ID/assets/$id" >/dev/null
|
||||||
@@ -120,7 +111,7 @@ while IFS= read -r asset; do
|
|||||||
[ -n "$asset" ] || continue
|
[ -n "$asset" ] || continue
|
||||||
[ -f "$asset" ] || die "no such file: $asset"
|
[ -f "$asset" ] || die "no such file: $asset"
|
||||||
name="$(basename "$asset")"
|
name="$(basename "$asset")"
|
||||||
size="$(python3 -c "import os,sys; print(f'{os.path.getsize(sys.argv[1])/1e6:.0f} MB')" "$asset")"
|
size="$(node -e 'console.log((require("fs").statSync(process.argv[1]).size / 1e6).toFixed(0) + " MB")' -- "$asset")"
|
||||||
|
|
||||||
# Retried, because a 100 MB upload does fail on its own: publishing 1.2.0 got
|
# Retried, because a 100 MB upload does fail on its own: publishing 1.2.0 got
|
||||||
# "invalid username, password or token" on the second package while the first
|
# "invalid username, password or token" on the second package while the first
|
||||||
@@ -140,9 +131,10 @@ while IFS= read -r asset; do
|
|||||||
done < "$LIST"
|
done < "$LIST"
|
||||||
|
|
||||||
say "done:"
|
say "done:"
|
||||||
tea api "/repos/$REPO/releases/$RELEASE_ID" | python3 -c "
|
tea api "/repos/$REPO/releases/$RELEASE_ID" | node -e '
|
||||||
import json, sys
|
const release = JSON.parse(require("fs").readFileSync(0, "utf8"))
|
||||||
r = json.load(sys.stdin)
|
for (const asset of release.assets || []) {
|
||||||
for a in r.get('assets') or []:
|
console.log(` ${asset.name} ${(asset.size / 1e6).toFixed(0)} MB`)
|
||||||
print(f\" {a['name']} {a['size']/1e6:.0f} MB\")
|
}
|
||||||
print(f\" {r['html_url']}\")"
|
console.log(` ${release.html_url}`)
|
||||||
|
'
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import type { EngineVersion } from '../../domain/models/EngineVersion'
|
|
||||||
import type { EngineVersionDto } from '../../shared/contracts/dto/EngineVersionDto'
|
|
||||||
|
|
||||||
export class EngineVersionDtoMapper {
|
|
||||||
public toDto (version: EngineVersion): EngineVersionDto {
|
|
||||||
return { text: version.text, supported: version.supported }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readAccessVerdict, type CatalogPrice } from '../../domain/models/CatalogAccess'
|
||||||
import type { Game } from '../../domain/models/Game'
|
import type { Game } from '../../domain/models/Game'
|
||||||
import type { GameDto } from '../../shared/contracts/dto/GameDto'
|
import type { GameDto } from '../../shared/contracts/dto/GameDto'
|
||||||
|
|
||||||
@@ -6,10 +7,11 @@ const ABSOLUTE_URL = /^https?:\/\//
|
|||||||
/**
|
/**
|
||||||
* A title as the window may see it.
|
* A title as the window may see it.
|
||||||
*
|
*
|
||||||
* Two decisions live here rather than in the renderer: the box art is resolved
|
* Three decisions live here rather than in the renderer: the box art is resolved
|
||||||
* against the catalog's base URL, and whether a title can be launched is answered
|
* against the catalog's base URL, whether a title can be launched is answered here —
|
||||||
* here — so the window never receives a filesystem path it could be talked into
|
* so the window never receives a filesystem path it could be talked into opening —
|
||||||
* opening.
|
* and the catalog's access block is reduced to a verdict and a printed price, because
|
||||||
|
* a view that had to reason about entitlement is a view with a rule in it.
|
||||||
*/
|
*/
|
||||||
export class GameDtoMapper {
|
export class GameDtoMapper {
|
||||||
public toDto (game: Game, catalogBaseUrl: string): GameDto {
|
public toDto (game: Game, catalogBaseUrl: string): GameDto {
|
||||||
@@ -26,7 +28,13 @@ export class GameDtoMapper {
|
|||||||
installed: game.installed,
|
installed: game.installed,
|
||||||
updateAvailable: game.updateAvailable,
|
updateAvailable: game.updateAvailable,
|
||||||
installedVersion: game.installedVersion,
|
installedVersion: game.installedVersion,
|
||||||
launchable: this.isLaunchable(game)
|
launchable: this.isLaunchable(game),
|
||||||
|
installable: game.installable,
|
||||||
|
unavailableReason: game.unavailableReason,
|
||||||
|
unavailableDetail: game.unavailableDetail,
|
||||||
|
accessVerdict: readAccessVerdict(game.access),
|
||||||
|
priceLabel: formatPrice(game.access?.price ?? null),
|
||||||
|
purchaseUrl: game.access?.purchaseUrl ?? null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,3 +54,25 @@ export class GameDtoMapper {
|
|||||||
return game.menuEntryPath !== null || game.executablePath !== null
|
return game.menuEntryPath !== null || game.executablePath !== null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A price as a person reads it, in the currency the catalog named.
|
||||||
|
*
|
||||||
|
* `Intl` with the *catalog's* currency and the system locale: the store decides what it
|
||||||
|
* charges in, the reader's machine decides where the symbol and the separators go.
|
||||||
|
* There is no conversion here and there must not be — inventing an exchange rate would
|
||||||
|
* be quoting a price nobody agreed to.
|
||||||
|
*/
|
||||||
|
function formatPrice (price: CatalogPrice | null): string | null {
|
||||||
|
if (price === null) return null
|
||||||
|
if (price.amountCents <= 0) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(undefined, {
|
||||||
|
style: 'currency', currency: price.currency
|
||||||
|
}).format(price.amountCents / 100)
|
||||||
|
} catch {
|
||||||
|
// An unknown currency code: better the number and the code than nothing at all.
|
||||||
|
return `${(price.amountCents / 100).toFixed(2)} ${price.currency}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export class RegistryStoreDtoMapper {
|
|||||||
return {
|
return {
|
||||||
name: store.name,
|
name: store.name,
|
||||||
catalogUrl: store.catalogUrl,
|
catalogUrl: store.catalogUrl,
|
||||||
storeRepositoryUrl: store.storeRepositoryUrl,
|
|
||||||
storeId: deriveStoreId(store)
|
storeId: deriveStoreId(store)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,10 +17,6 @@ export class RegistryStoreDtoMapper {
|
|||||||
|
|
||||||
/** The window hands a record straight back when asking for an install. */
|
/** The window hands a record straight back when asking for an install. */
|
||||||
public toModel (dto: RegistryStoreDto): RegistryStore {
|
public toModel (dto: RegistryStoreDto): RegistryStore {
|
||||||
return {
|
return { name: dto.name, catalogUrl: dto.catalogUrl }
|
||||||
name: dto.name,
|
|
||||||
catalogUrl: dto.catalogUrl,
|
|
||||||
storeRepositoryUrl: dto.storeRepositoryUrl
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
NO_ACCOUNT, type SignInOutcome, type SignInPrompt, type StoreAccount
|
||||||
|
} from '../../domain/models/StoreAccount'
|
||||||
|
import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
|
||||||
|
import type { StoreSelectionService } from './StoreSelectionService'
|
||||||
|
|
||||||
|
/** A sign-in that is under way: what to show, and how it ended. */
|
||||||
|
export interface SignInSession {
|
||||||
|
readonly prompt: SignInPrompt
|
||||||
|
readonly finished: Promise<SignInResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignInResult {
|
||||||
|
readonly outcome: SignInOutcome
|
||||||
|
readonly account: StoreAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signing in to the store that is open, and out of it again.
|
||||||
|
*
|
||||||
|
* The waiting lives here rather than in the gateway because it is orchestration: a loop
|
||||||
|
* with a cancel and a deadline in it, over a port that only knows how to ask once. That
|
||||||
|
* split is also what keeps the port testable without a clock.
|
||||||
|
*
|
||||||
|
* One sign-in at a time, per application rather than per store: a second one started
|
||||||
|
* while the first is waiting would leave two loops racing to write the same token, and
|
||||||
|
* a person can only be at one browser tab anyway.
|
||||||
|
*/
|
||||||
|
export class AccountService {
|
||||||
|
private cancelled = false
|
||||||
|
private active: SignInSession | null = null
|
||||||
|
|
||||||
|
public constructor (
|
||||||
|
private readonly catalogGateway: StoreCatalogGateway,
|
||||||
|
private readonly selection: StoreSelectionService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Null where no store is open — the window asks before anything is chosen. */
|
||||||
|
public async readAccount (): Promise<StoreAccount> {
|
||||||
|
const store = this.selection.findCurrentStore()
|
||||||
|
if (store === null) return NO_ACCOUNT
|
||||||
|
|
||||||
|
return await this.catalogGateway.readAccount(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the store for a code, then keep polling until somebody answers.
|
||||||
|
*
|
||||||
|
* Returns as soon as there is something to show: the code has to be on screen while
|
||||||
|
* the polling happens, and a person cannot answer a code they have not seen yet.
|
||||||
|
*/
|
||||||
|
public async beginSignIn (clientName: string): Promise<SignInSession> {
|
||||||
|
if (this.active !== null) return this.active
|
||||||
|
|
||||||
|
const store = this.selection.requireCurrentStore()
|
||||||
|
const prompt = await this.catalogGateway.requestSignIn(store, clientName)
|
||||||
|
this.cancelled = false
|
||||||
|
|
||||||
|
const session: SignInSession = { prompt, finished: this.awaitAnswer(prompt) }
|
||||||
|
this.active = session
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Give up waiting. The code stays valid at the server until it expires by itself. */
|
||||||
|
public cancelSignIn (): void {
|
||||||
|
this.cancelled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
public async signOut (): Promise<StoreAccount> {
|
||||||
|
const store = this.selection.findCurrentStore()
|
||||||
|
if (store === null) return NO_ACCOUNT
|
||||||
|
|
||||||
|
this.cancelSignIn()
|
||||||
|
return await this.catalogGateway.signOut(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCancelled (): boolean {
|
||||||
|
return this.cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
private async awaitAnswer (prompt: SignInPrompt): Promise<SignInResult> {
|
||||||
|
const store = this.selection.requireCurrentStore()
|
||||||
|
const deadline = Date.now() + prompt.expiresInSeconds * 1000
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (!this.isCancelled()) {
|
||||||
|
await delay(prompt.intervalSeconds * 1000)
|
||||||
|
// Read through a method, not the field: cancelling happens *during* the delay
|
||||||
|
// above, and a flow analysis that only sees the loop condition concludes this
|
||||||
|
// can never be true.
|
||||||
|
if (this.isCancelled()) break
|
||||||
|
// The server's own expiry is the authority; this one only stops the loop when
|
||||||
|
// the server has stopped answering at all.
|
||||||
|
if (Date.now() > deadline) return { outcome: 'expired', account: await this.readAccount() }
|
||||||
|
|
||||||
|
const result = await this.catalogGateway.pollSignIn(store, prompt.deviceCode)
|
||||||
|
if (result.state === 'approved') return { outcome: 'signedIn', account: result.account }
|
||||||
|
if (result.state === 'denied') return { outcome: 'denied', account: result.account }
|
||||||
|
if (result.state === 'expired') return { outcome: 'expired', account: result.account }
|
||||||
|
}
|
||||||
|
return { outcome: 'cancelled', account: await this.readAccount() }
|
||||||
|
} finally {
|
||||||
|
this.active = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delay (milliseconds: number): Promise<void> {
|
||||||
|
await new Promise<void>((resolve: () => void): void => {
|
||||||
|
setTimeout((): void => { resolve() }, milliseconds)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
import { MINIMUM_ENGINE_VERSION, formatVersion } from '../../domain/models/EngineVersion'
|
|
||||||
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
||||||
import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment'
|
import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment'
|
||||||
import type { PythonRuntimeLocator } from '../../domain/ports/PythonRuntimeLocator'
|
|
||||||
import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto'
|
import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto'
|
||||||
import { TranslationCatalog } from '../../shared/i18n/TranslationCatalog'
|
import { TranslationCatalog } from '../../shared/i18n/TranslationCatalog'
|
||||||
import { EngineVersionDtoMapper } from '../mappers/EngineVersionDtoMapper'
|
|
||||||
import { InstalledStoreDtoMapper } from '../mappers/InstalledStoreDtoMapper'
|
import { InstalledStoreDtoMapper } from '../mappers/InstalledStoreDtoMapper'
|
||||||
import type { PreferencesService } from './PreferencesService'
|
import type { PreferencesService } from './PreferencesService'
|
||||||
import type { StoreProvisioningService } from './StoreProvisioningService'
|
import type { StoreProvisioningService } from './StoreProvisioningService'
|
||||||
@@ -13,39 +10,32 @@ import type { StoreSelectionService } from './StoreSelectionService'
|
|||||||
/**
|
/**
|
||||||
* Everything the window needs before it can paint anything, in one answer.
|
* Everything the window needs before it can paint anything, in one answer.
|
||||||
*
|
*
|
||||||
* One call rather than six, because the first frame should not be a sequence of
|
* One call rather than six, because the first frame should not be a sequence of round
|
||||||
* round trips — and because the decisions the window makes from it (no Python, no
|
* trips. There is one decision left in it — whether a store is set up yet — now that
|
||||||
* store, an engine too old) all depend on each other.
|
* the engine ships with the application and cannot be missing or out of date.
|
||||||
*/
|
*/
|
||||||
export class ApplicationStateService {
|
export class ApplicationStateService {
|
||||||
public constructor (
|
public constructor (
|
||||||
private readonly preferences: PreferencesService,
|
private readonly preferences: PreferencesService,
|
||||||
private readonly selection: StoreSelectionService,
|
private readonly selection: StoreSelectionService,
|
||||||
private readonly provisioning: StoreProvisioningService,
|
private readonly provisioning: StoreProvisioningService,
|
||||||
private readonly pythonLocator: PythonRuntimeLocator,
|
|
||||||
private readonly environment: ApplicationEnvironment,
|
private readonly environment: ApplicationEnvironment,
|
||||||
private readonly translations: TranslationCatalog = new TranslationCatalog(),
|
private readonly translations: TranslationCatalog = new TranslationCatalog(),
|
||||||
private readonly storeMapper: InstalledStoreDtoMapper = new InstalledStoreDtoMapper(),
|
private readonly storeMapper: InstalledStoreDtoMapper = new InstalledStoreDtoMapper()
|
||||||
private readonly engineMapper: EngineVersionDtoMapper = new EngineVersionDtoMapper()
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public readState (): AppStateDto {
|
public readState (): AppStateDto {
|
||||||
const locale = this.preferences.readLocale()
|
const locale = this.preferences.readLocale()
|
||||||
const stores = this.selection.listStores()
|
const stores = this.selection.listStores()
|
||||||
const current: InstalledStore | null = this.selection.findCurrentStore()
|
const current: InstalledStore | null = this.selection.findCurrentStore()
|
||||||
const engine = current === null ? null : this.selection.findEngineVersion(current)
|
|
||||||
const runtime = this.pythonLocator.findRuntime()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locale,
|
locale,
|
||||||
locales: this.translations.locales,
|
locales: this.translations.locales,
|
||||||
messages: this.translations.readBundle(locale),
|
messages: this.translations.readBundle(locale),
|
||||||
navigationOpen: this.preferences.readNavigationOpen(),
|
navigationOpen: this.preferences.readNavigationOpen(),
|
||||||
pythonVersion: runtime === null ? null : runtime.version,
|
|
||||||
currentStore: current === null ? null : this.storeMapper.toDto(current),
|
currentStore: current === null ? null : this.storeMapper.toDto(current),
|
||||||
stores: this.storeMapper.toDtoList(stores),
|
stores: this.storeMapper.toDtoList(stores),
|
||||||
engine: engine === null ? null : this.engineMapper.toDto(engine),
|
|
||||||
minimumEngineVersion: formatVersion(MINIMUM_ENGINE_VERSION),
|
|
||||||
registryUrl: this.provisioning.registryUrl,
|
registryUrl: this.provisioning.registryUrl,
|
||||||
defaultStoreRoot: this.selection.readDefaultStoreRoot(),
|
defaultStoreRoot: this.selection.readDefaultStoreRoot(),
|
||||||
appVersion: this.environment.readVersion()
|
appVersion: this.environment.readVersion()
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ export class StoreProvisioningService {
|
|||||||
return this.registry.listStores()
|
return this.registry.listStores()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the chosen store.
|
||||||
|
*
|
||||||
|
* The window's choice is taken at face value, which is safe because a record is only a
|
||||||
|
* name and a catalog: there is no path in it and nothing that decides what may be
|
||||||
|
* deleted. The store's own configuration is written by the installer from the engine's
|
||||||
|
* defaults, so the renderer cannot influence where anything lands.
|
||||||
|
*/
|
||||||
public async installStore (
|
public async installStore (
|
||||||
store: RegistryStore,
|
store: RegistryStore,
|
||||||
progress?: EngineProgressListener
|
progress?: EngineProgressListener
|
||||||
@@ -38,4 +46,5 @@ export class StoreProvisioningService {
|
|||||||
const installed = await this.installer.installEngine(home, store, progress)
|
const installed = await this.installer.installEngine(home, store, progress)
|
||||||
return this.selection.adoptStore(installed)
|
return this.selection.adoptStore(installed)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { StoreMissingError } from '../../domain/errors/StoreMissingError'
|
import { StoreMissingError } from '../../domain/errors/StoreMissingError'
|
||||||
import type { EngineVersion } from '../../domain/models/EngineVersion'
|
|
||||||
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
||||||
import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository'
|
import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository'
|
||||||
import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
|
|
||||||
import type { PreferencesService } from './PreferencesService'
|
import type { PreferencesService } from './PreferencesService'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,7 +16,6 @@ export class StoreSelectionService {
|
|||||||
|
|
||||||
public constructor (
|
public constructor (
|
||||||
private readonly stores: InstalledStoreRepository,
|
private readonly stores: InstalledStoreRepository,
|
||||||
private readonly catalogGateway: StoreCatalogGateway,
|
|
||||||
private readonly preferences: PreferencesService
|
private readonly preferences: PreferencesService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -58,15 +55,6 @@ export class StoreSelectionService {
|
|||||||
return store
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The engine version of a store, checked per store rather than once: two stores
|
|
||||||
* on one machine can be at different versions, and the one being switched to may
|
|
||||||
* be the older one.
|
|
||||||
*/
|
|
||||||
public findEngineVersion (store: InstalledStore): EngineVersion | null {
|
|
||||||
return this.catalogGateway.readEngineVersion(store)
|
|
||||||
}
|
|
||||||
|
|
||||||
public readDefaultStoreRoot (): string {
|
public readDefaultStoreRoot (): string {
|
||||||
return this.stores.readRoots()[0] ?? ''
|
return this.stores.readRoots()[0] ?? ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { DomainError } from './DomainError'
|
|
||||||
|
|
||||||
export class PythonMissingError extends DomainError {
|
|
||||||
public override readonly code: string = 'PYTHON_MISSING'
|
|
||||||
|
|
||||||
public constructor () {
|
|
||||||
super('python3 was not found on this machine')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* What a catalog says about getting one title.
|
||||||
|
*
|
||||||
|
* The vocabulary is the engine's and deliberately generic — `gated`, `entitled`, a
|
||||||
|
* price. One client reads many catalogs, so a field named after what a particular shop
|
||||||
|
* calls the thing it sells is a field that works in exactly one shop.
|
||||||
|
*
|
||||||
|
* Absent (`null` where this appears) is its own answer: an engine too old to have an
|
||||||
|
* opinion. That is not the same as "not gated", and only one of the two is a reason to
|
||||||
|
* offer somebody a sign-in.
|
||||||
|
*/
|
||||||
|
export interface CatalogAccess {
|
||||||
|
/** Downloading needs an entitlement. */
|
||||||
|
readonly gated: boolean
|
||||||
|
/** For the signed-in caller; null when nobody was signed in to ask about. */
|
||||||
|
readonly entitled: boolean | null
|
||||||
|
readonly price: CatalogPrice | null
|
||||||
|
/** Where a person goes to get it. Absolute — it opens in their own browser. */
|
||||||
|
readonly purchaseUrl: string | null
|
||||||
|
/** Where a hosted build is played, when the catalog serves it somewhere of its own. */
|
||||||
|
readonly webUrl: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogPrice {
|
||||||
|
readonly amountCents: number
|
||||||
|
readonly currency: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can this caller install this title?
|
||||||
|
*
|
||||||
|
* Three answers, because the middle one is real: yes; no, and here is where to buy it;
|
||||||
|
* and "the catalog would tell you if you signed in". A client that collapsed the last
|
||||||
|
* two would either hide a title somebody owns or offer to sell them one they have.
|
||||||
|
*/
|
||||||
|
export type AccessVerdict = 'open' | 'entitled' | 'purchasable' | 'signInRequired'
|
||||||
|
|
||||||
|
export function readAccessVerdict (access: CatalogAccess | null): AccessVerdict {
|
||||||
|
if (access?.gated !== true) return 'open'
|
||||||
|
if (access.entitled === true) return 'entitled'
|
||||||
|
return access.entitled === false ? 'purchasable' : 'signInRequired'
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Game } from './Game'
|
import type { Game } from './Game'
|
||||||
|
import type { StoreAccount } from './StoreAccount'
|
||||||
import type { StorePaths } from './StorePaths'
|
import type { StorePaths } from './StorePaths'
|
||||||
|
|
||||||
/** One reading of a store's catalog. */
|
/** One reading of a store's catalog. */
|
||||||
@@ -6,4 +7,13 @@ export interface CatalogListing {
|
|||||||
readonly games: readonly Game[]
|
readonly games: readonly Game[]
|
||||||
readonly skipped: readonly string[]
|
readonly skipped: readonly string[]
|
||||||
readonly paths: StorePaths | null
|
readonly paths: StorePaths | null
|
||||||
|
/**
|
||||||
|
* Where this machine stands with the store, as of this reading.
|
||||||
|
*
|
||||||
|
* Part of the listing rather than a call of its own because it is the same answer
|
||||||
|
* from the same request: the catalog was fetched with whatever credential we hold,
|
||||||
|
* and what it said about entitlements is only meaningful next to whether anybody was
|
||||||
|
* signed in when it said it.
|
||||||
|
*/
|
||||||
|
readonly account: StoreAccount
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* The three places a desktop store writes, resolved for this machine.
|
||||||
|
*
|
||||||
|
* `installRoot` holds the payloads and `menuDirectory` the launchers the user
|
||||||
|
* actually sees. `sources` records where each answer came from — a default, the
|
||||||
|
* config or an override — because "why is my menu entry there" is the first
|
||||||
|
* question anyone asks of a store that writes outside its own folder.
|
||||||
|
*/
|
||||||
|
export interface DesktopLayout {
|
||||||
|
readonly operatingSystem: string
|
||||||
|
readonly installRoot: string
|
||||||
|
readonly menuDirectory: string
|
||||||
|
readonly iconDirectory: string | null
|
||||||
|
readonly sources: Readonly<Record<string, string>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where each OS keeps installed programs and its application menu. */
|
||||||
|
export const OPERATING_SYSTEM_PATHS: Readonly<Record<string, Readonly<Record<string, string | null>>>> = {
|
||||||
|
linux: {
|
||||||
|
installRoot: '$XDG_DATA_HOME|~/.local/share',
|
||||||
|
menuDirectory: '$XDG_DATA_HOME|~/.local/share/applications',
|
||||||
|
iconDirectory: '$XDG_DATA_HOME|~/.local/share/icons'
|
||||||
|
},
|
||||||
|
darwin: {
|
||||||
|
installRoot: '~/Library/Application Support',
|
||||||
|
menuDirectory: '~/Applications',
|
||||||
|
iconDirectory: null
|
||||||
|
},
|
||||||
|
windows: {
|
||||||
|
installRoot: '$LOCALAPPDATA|~/AppData/Local',
|
||||||
|
menuDirectory: '$APPDATA|~/AppData/Roaming/Microsoft/Windows/Start Menu/Programs',
|
||||||
|
iconDirectory: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A file name the OS will accept, from a human title. */
|
||||||
|
export function toSafeFileName (text: string): string {
|
||||||
|
return text.trim().replace(/[\\/:*?"<>|]/g, '_') || 'game'
|
||||||
|
}
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
/**
|
|
||||||
* The engine version this client found, and whether it can drive it.
|
|
||||||
*
|
|
||||||
* `--json` arrived with engine 1.1.0. An older engine is not broken, it simply
|
|
||||||
* cannot be driven from a window — and it will be met in the wild, because the CLI
|
|
||||||
* shipped before this client did.
|
|
||||||
*/
|
|
||||||
export interface EngineVersion {
|
|
||||||
readonly text: string
|
|
||||||
readonly numbers: readonly number[] | null
|
|
||||||
readonly supported: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MINIMUM_ENGINE_VERSION: readonly number[] = [1, 1, 0]
|
|
||||||
|
|
||||||
export function parseVersionNumbers (text: string): readonly number[] | null {
|
|
||||||
const match = /(\d+)\.(\d+)\.(\d+)/.exec(text)
|
|
||||||
return match ? match.slice(1, 4).map((part: string): number => Number(part)) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAtLeast (version: readonly number[] | null, minimum: readonly number[]): boolean {
|
|
||||||
if (version === null) return false
|
|
||||||
for (let index = 0; index < minimum.length; index += 1) {
|
|
||||||
const found = version[index] ?? 0
|
|
||||||
const needed = minimum[index] ?? 0
|
|
||||||
if (found > needed) return true
|
|
||||||
if (found < needed) return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatVersion (numbers: readonly number[]): string {
|
|
||||||
return numbers.join('.')
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
|
import type { CatalogAccess } from './CatalogAccess'
|
||||||
|
|
||||||
/** How a title runs: unpacked on this machine, or served as a web build. */
|
/** How a title runs: unpacked on this machine, or served as a web build. */
|
||||||
export type GameMode = 'app' | 'web'
|
export type GameMode = 'app' | 'web'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a title cannot be installed here, as the engine codes it.
|
||||||
|
*
|
||||||
|
* `platformOff` is the store not carrying that platform at all — a C64 cartridge on a
|
||||||
|
* desktop — and the other three are about this machine or this catalog: no asset kind
|
||||||
|
* for the os and architecture, no release carrying it, or the adapter refusing it.
|
||||||
|
*/
|
||||||
|
export type UnavailableReason = 'platformOff' | 'hostAsset' | 'noAsset' | 'vetoed'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A catalog entry, with what the store did about it on this machine.
|
* A catalog entry, with what the store did about it on this machine.
|
||||||
*
|
*
|
||||||
@@ -24,4 +35,20 @@ export interface Game {
|
|||||||
readonly menuEntryPath: string | null
|
readonly menuEntryPath: string | null
|
||||||
readonly executablePath: string | null
|
readonly executablePath: string | null
|
||||||
readonly hostedUrl: string | null
|
readonly hostedUrl: string | null
|
||||||
|
/**
|
||||||
|
* False for a title this machine cannot install. It is still listed: a catalog that
|
||||||
|
* hides what your machine cannot run leaves you wondering which of the two is small.
|
||||||
|
*/
|
||||||
|
readonly installable: boolean
|
||||||
|
readonly unavailableReason: UnavailableReason | null
|
||||||
|
/** The engine's sentence for it, for a tooltip or the log. */
|
||||||
|
readonly unavailableDetail: string | null
|
||||||
|
/**
|
||||||
|
* What the catalog says about getting it, or null where it said nothing.
|
||||||
|
*
|
||||||
|
* Kept separate from `installable`: that one is about this machine — no build for
|
||||||
|
* this architecture — and this one is about this person. A title can be perfectly
|
||||||
|
* installable and still not yours.
|
||||||
|
*/
|
||||||
|
readonly access: CatalogAccess | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* The machine the store is running on, and how a config value depends on it.
|
||||||
|
*
|
||||||
|
* A native binary only starts on the architecture it was built for, so the release
|
||||||
|
* picker has to know: an x86_64 build installs on a Raspberry Pi and then does
|
||||||
|
* nothing, which is worse than not offering it at all.
|
||||||
|
*/
|
||||||
|
export interface HostMachine {
|
||||||
|
readonly operatingSystem: string
|
||||||
|
readonly architecture: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A config value that may differ per machine.
|
||||||
|
*
|
||||||
|
* A plain value is the same everywhere — that is how a `cartridge` is described,
|
||||||
|
* being data for an emulator. Where it differs, the value is a map keyed by host.
|
||||||
|
*/
|
||||||
|
export type HostSpecific<TValue> = TValue | Readonly<Record<string, TValue>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a host-specific value; the most specific key wins.
|
||||||
|
*
|
||||||
|
* {"linux-aarch64": …, "aarch64": …, "linux": …, "*": …}
|
||||||
|
*
|
||||||
|
* With no matching key and no `*` the answer is `null`, and the caller reports the
|
||||||
|
* title as unavailable rather than installing something that cannot run.
|
||||||
|
*/
|
||||||
|
export function resolveForHost<TValue> (
|
||||||
|
value: HostSpecific<TValue> | null | undefined,
|
||||||
|
host: HostMachine
|
||||||
|
): TValue | null {
|
||||||
|
if (value === null || value === undefined) return null
|
||||||
|
if (!isHostMap(value)) return value
|
||||||
|
for (const key of hostKeys(host)) {
|
||||||
|
const found = value[key]
|
||||||
|
if (found !== undefined) return found
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hostKeys (host: HostMachine): readonly string[] {
|
||||||
|
return [
|
||||||
|
`${host.operatingSystem}-${host.architecture}`,
|
||||||
|
host.architecture,
|
||||||
|
host.operatingSystem,
|
||||||
|
'*'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeHost (host: HostMachine): string {
|
||||||
|
return `${host.operatingSystem}/${host.architecture}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A host map, as opposed to a value that happens to be an object.
|
||||||
|
*
|
||||||
|
* Only plain objects are maps: an array is a value here — `kind` may be a list of
|
||||||
|
* asset kinds in order of preference, and that is not keyed by anything.
|
||||||
|
*/
|
||||||
|
function isHostMap<TValue> (
|
||||||
|
value: HostSpecific<TValue>
|
||||||
|
): value is Readonly<Record<string, TValue>> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { SelectedGame } from './SelectedGame'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One title as it exists on this machine: what was written, and where.
|
||||||
|
*
|
||||||
|
* This is the shape `state.json` carries, keyed `<scope>:<name>`. Every path in it
|
||||||
|
* is something the store put there and may therefore delete — which is why an
|
||||||
|
* uninstall reads the record rather than guessing at paths.
|
||||||
|
*
|
||||||
|
* The catalog's `access` block is deliberately *not* part of it. Whether somebody may
|
||||||
|
* download a title is the server's answer to a question asked now; a copy of it on disk
|
||||||
|
* would go stale the moment a purchase or a refund happened, and a stale "yes" is the
|
||||||
|
* dangerous direction. What is installed stays installed either way.
|
||||||
|
*/
|
||||||
|
export interface InstalledRecord extends Omit<SelectedGame, 'access'> {
|
||||||
|
/** The unpacked archive's directory; null for a hosted entry, which has none. */
|
||||||
|
readonly payload: string | null
|
||||||
|
readonly executable: string | null
|
||||||
|
/** `bundle` for a macOS `.app` the archive already contained, `exe` for a binary. */
|
||||||
|
readonly executableKind: string | null
|
||||||
|
readonly icon: string | null
|
||||||
|
/** The menu entry actually written, which is not always the one intended. */
|
||||||
|
readonly menuEntry: string | null
|
||||||
|
readonly url: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scope a state record belongs to.
|
||||||
|
*
|
||||||
|
* `scope` is what the engine writes today. `system` is what the Batocera store
|
||||||
|
* wrote before the shared core existed, and there the two were the same string —
|
||||||
|
* so an installed machine keeps working without a migration.
|
||||||
|
*/
|
||||||
|
export function recordScope (scope: string | null, system: string | null): string {
|
||||||
|
return scope ?? system ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two scopes may both carry a game called `foo` — key on both. */
|
||||||
|
export function gameKey (record: { readonly name: string; readonly scope: string }): string {
|
||||||
|
return `${record.scope}:${record.name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve user-typed `name` or `scope:name` arguments to state keys. */
|
||||||
|
export function matchStateKeys (
|
||||||
|
installed: ReadonlyMap<string, InstalledRecord>,
|
||||||
|
names: readonly string[]
|
||||||
|
): readonly string[] {
|
||||||
|
const wanted = new Set(names.map((name: string): string => name.toLowerCase()))
|
||||||
|
const keys: string[] = []
|
||||||
|
for (const [key, record] of installed) {
|
||||||
|
if (wanted.has(key.toLowerCase()) || wanted.has(record.name.toLowerCase())) keys.push(key)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep only the games the user named, by bare name or `scope:name`. */
|
||||||
|
export function limitToNames (
|
||||||
|
games: readonly SelectedGame[],
|
||||||
|
names: readonly string[]
|
||||||
|
): readonly SelectedGame[] {
|
||||||
|
const wanted = new Set(names.map((name: string): string => name.toLowerCase()))
|
||||||
|
return games.filter((game: SelectedGame): boolean =>
|
||||||
|
wanted.has(game.name.toLowerCase()) || wanted.has(gameKey(game).toLowerCase()))
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
/** A store engine installed on this machine, with everything needed to run it. */
|
/**
|
||||||
|
* A store set up on this machine.
|
||||||
|
*
|
||||||
|
* A home and a config, which is all a store is now that the engine is part of this
|
||||||
|
* application: the home holds the state and the catalog cache, and the config says
|
||||||
|
* what the store offers and where its games go.
|
||||||
|
*/
|
||||||
export interface InstalledStore {
|
export interface InstalledStore {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly home: string
|
readonly home: string
|
||||||
readonly scriptPath: string
|
|
||||||
readonly configPath: string
|
readonly configPath: string
|
||||||
readonly engine: string
|
readonly engine: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
/** The Python 3 this machine has, and how to invoke it. */
|
|
||||||
export interface PythonRuntime {
|
|
||||||
readonly command: string
|
|
||||||
readonly arguments: readonly string[]
|
|
||||||
readonly version: string
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* A store the site's registry offers.
|
* A store the site's registry offers: a name and a catalog.
|
||||||
*
|
*
|
||||||
* A name and a catalog are what make a store; the repository is optional. When
|
* That is the whole record, and it is enough. How a store behaves is not the registry's
|
||||||
* there is one it stays the authority on how that store behaves — which platforms
|
* business — this client carries its own store engine, whose defaults cover the
|
||||||
* it offers, where things land — and when there is not, the engine's own defaults
|
* host-to-asset mapping, the install modes, the platforms and the behaviour — so what
|
||||||
* cover all of it and this record covers the identity. That is the whole reason a
|
* was actually missing from those defaults is identity, and identity is all this is.
|
||||||
* store needs no repository of its own.
|
*
|
||||||
|
* Keeping two stores on one machine out of each other's files is a subfolder, derived
|
||||||
|
* here from the store's own slug rather than told to us by a server.
|
||||||
*/
|
*/
|
||||||
export interface RegistryStore {
|
export interface RegistryStore {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly catalogUrl: string
|
readonly catalogUrl: string
|
||||||
readonly storeRepositoryUrl: string | null
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { CatalogAccess } from './CatalogAccess'
|
||||||
|
import type { UnavailableReason } from './Game'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A catalog entry the store can install here, with the release it chose.
|
||||||
|
*
|
||||||
|
* `scope` is how the host groups its library — for a desktop that is the catalog
|
||||||
|
* platform. It is what `state.json` is keyed by (`<scope>:<name>`), so two scopes
|
||||||
|
* can carry a game of the same name without colliding.
|
||||||
|
*/
|
||||||
|
export interface SelectedGame {
|
||||||
|
readonly name: string
|
||||||
|
readonly scope: string
|
||||||
|
readonly platform: string
|
||||||
|
readonly kind: string
|
||||||
|
readonly version: string
|
||||||
|
readonly asset: string
|
||||||
|
/**
|
||||||
|
* The catalog-side path, kept because not every asset is a download: an `html`
|
||||||
|
* build is a hosted directory, and the entry is a link to it.
|
||||||
|
*/
|
||||||
|
readonly assetPath: string
|
||||||
|
readonly title: string
|
||||||
|
readonly description: string
|
||||||
|
readonly author: string
|
||||||
|
readonly imageUrl: string | null
|
||||||
|
readonly createdAt: string | null
|
||||||
|
/** `app` for a native archive, `web` for a hosted page. */
|
||||||
|
readonly mode: string
|
||||||
|
/** What the catalog says about getting it; null from an engine that cannot say. */
|
||||||
|
readonly access: CatalogAccess | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A title the store offers but this machine cannot install.
|
||||||
|
*
|
||||||
|
* A store that hides these is lying about its catalog by omission: "there is
|
||||||
|
* nothing for your machine" is an answer, and an absent entry is not. Titles the
|
||||||
|
* store *chooses* not to offer — the wrong status, an `only`/`exclude` list — are
|
||||||
|
* not here, because that is editorial rather than a limitation of the machine.
|
||||||
|
*/
|
||||||
|
export interface UnavailableEntry {
|
||||||
|
readonly name: string
|
||||||
|
readonly title: string
|
||||||
|
readonly platform: string
|
||||||
|
readonly description: string
|
||||||
|
readonly author: string
|
||||||
|
readonly imageUrl: string | null
|
||||||
|
readonly version: string
|
||||||
|
readonly reason: UnavailableReason
|
||||||
|
/** The sentence behind the code, for a tooltip or the log. */
|
||||||
|
readonly detail: string
|
||||||
|
/** Carried here too: a title with no build for this machine can still have a price. */
|
||||||
|
readonly access: CatalogAccess | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a survey of the catalog found: installable, why not, and what was skipped. */
|
||||||
|
export interface CatalogSurvey {
|
||||||
|
readonly games: readonly SelectedGame[]
|
||||||
|
readonly skipped: readonly string[]
|
||||||
|
readonly unavailable: readonly UnavailableEntry[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* What one catalog's server says about itself.
|
||||||
|
*
|
||||||
|
* This is how the client stops being built for a particular store. Whether there is a
|
||||||
|
* sign-in here, where it lives, whether any title can be gated — all of it used to be
|
||||||
|
* knowledge the client would have had to carry, and a client that carries it works for
|
||||||
|
* exactly one catalog. Now the server answers, and the same binary serves any of them.
|
||||||
|
*
|
||||||
|
* Every field is optional in practice: an engine older than 0.5 has no descriptor at
|
||||||
|
* all, and `DEFAULT_SERVICE_DESCRIPTOR` is what that means — a plain catalog, nothing
|
||||||
|
* gated, nobody to sign in as. That is what this client always assumed.
|
||||||
|
*/
|
||||||
|
export interface ServiceDescriptor {
|
||||||
|
readonly engineVersion: string | null
|
||||||
|
/** Whether any title in this catalog can require an entitlement. */
|
||||||
|
readonly catalogGated: boolean
|
||||||
|
/** Null where the server offers no sign-in, which is most of them. */
|
||||||
|
readonly auth: AuthDescriptor | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthDescriptor {
|
||||||
|
/** The device authorization grant, for a client with no browser of its own. */
|
||||||
|
readonly device: DeviceAuthDescriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceAuthDescriptor {
|
||||||
|
/** Where to ask for a code pair. */
|
||||||
|
readonly authorizeUrl: string
|
||||||
|
/** Where to poll for the token. */
|
||||||
|
readonly tokenUrl: string
|
||||||
|
/** Where to throw the token away again. */
|
||||||
|
readonly revokeUrl: string | null
|
||||||
|
/** Where a person takes the code, opened in their own browser. */
|
||||||
|
readonly verificationUrl: string
|
||||||
|
/** Seconds the server asks the client to wait between polls. */
|
||||||
|
readonly interval: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SERVICE_DESCRIPTOR: ServiceDescriptor = {
|
||||||
|
engineVersion: null,
|
||||||
|
catalogGated: false,
|
||||||
|
auth: null
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Whether this machine is signed in to one store, and whether it could be.
|
||||||
|
*
|
||||||
|
* Two booleans rather than one, because the interesting case is the first being false:
|
||||||
|
* most catalogs have no sign-in at all, and a client that shows a greyed-out "Sign in"
|
||||||
|
* on them is telling people about a door that does not exist.
|
||||||
|
*/
|
||||||
|
export interface StoreAccount {
|
||||||
|
readonly signInAvailable: boolean
|
||||||
|
readonly signedIn: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NO_ACCOUNT: StoreAccount = { signInAvailable: false, signedIn: false }
|
||||||
|
|
||||||
|
/** What to show a person while they finish signing in somewhere else. */
|
||||||
|
export interface SignInPrompt {
|
||||||
|
/** Opaque to the window: it is the client's half of the exchange, not the person's. */
|
||||||
|
readonly deviceCode: string
|
||||||
|
/** The short one, shown on screen and typed into a browser. */
|
||||||
|
readonly userCode: string
|
||||||
|
/** Opened in the person's own browser. */
|
||||||
|
readonly verificationUrl: string
|
||||||
|
readonly intervalSeconds: number
|
||||||
|
readonly expiresInSeconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a sign-in ended. `cancelled` is this side giving up, `denied` is the person. */
|
||||||
|
export type SignInOutcome = 'signedIn' | 'denied' | 'expired' | 'cancelled'
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { HostSpecific } from './HostMachine'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How one store behaves: what it offers, where things land, how it talks to the API.
|
||||||
|
*
|
||||||
|
* This is the shape of a store's `config.json` after it has been merged onto the
|
||||||
|
* defaults below. On disk the file is snake_case, which is the format the store
|
||||||
|
* repositories publish; `StoreConfigurationReader` is the only place that knows it.
|
||||||
|
*/
|
||||||
|
export interface StoreConfiguration {
|
||||||
|
readonly store: StoreDescriptor
|
||||||
|
readonly paths: PathsConfiguration
|
||||||
|
readonly install: InstallConfiguration
|
||||||
|
readonly catalog: CatalogConfiguration
|
||||||
|
/** Which catalog platforms this store offers at all, by platform name. */
|
||||||
|
readonly platforms: Readonly<Record<string, PlatformConfiguration>>
|
||||||
|
readonly behavior: BehaviorConfiguration
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreDescriptor {
|
||||||
|
/** Short slug: names the store home, the log prefix and the launcher files. */
|
||||||
|
readonly id: string
|
||||||
|
/** Human-readable: the Start-menu folder on Windows, and what the user sees. */
|
||||||
|
readonly name: string
|
||||||
|
readonly baseUrl: string
|
||||||
|
readonly api: ApiConfiguration
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiConfiguration {
|
||||||
|
readonly catalog: string
|
||||||
|
readonly download: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PathsConfiguration {
|
||||||
|
/** All null means "work it out from the OS"; set one to pin it. */
|
||||||
|
readonly installRoot: string | null
|
||||||
|
readonly menuDirectory: string | null
|
||||||
|
readonly iconDirectory: string | null
|
||||||
|
/**
|
||||||
|
* Our own folder inside the install root: the prune boundary, and what keeps two
|
||||||
|
* stores on one machine out of each other's files.
|
||||||
|
*/
|
||||||
|
readonly subfolder: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetSpecification {
|
||||||
|
/** One kind, or several in order of preference. */
|
||||||
|
readonly kind: HostSpecific<string | readonly string[]> | null
|
||||||
|
readonly extension: HostSpecific<string> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallConfiguration {
|
||||||
|
/** Tried in this order; the first that has an asset wins. */
|
||||||
|
readonly modes: readonly string[]
|
||||||
|
readonly specifications: Readonly<Record<string, AssetSpecification>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogConfiguration {
|
||||||
|
readonly statuses: readonly string[]
|
||||||
|
readonly ownerId: number | null
|
||||||
|
readonly only: readonly string[]
|
||||||
|
readonly exclude: readonly string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformConfiguration {
|
||||||
|
readonly enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BehaviorConfiguration {
|
||||||
|
readonly prune: boolean
|
||||||
|
/** Seconds. */
|
||||||
|
readonly timeout: number
|
||||||
|
readonly insecure: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const APP_MODE = 'app'
|
||||||
|
export const WEB_MODE = 'web'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a store gets when its config says nothing.
|
||||||
|
*
|
||||||
|
* These were the desktop engine's own defaults, and they stay the defaults: a store
|
||||||
|
* that publishes no `config.json` installs on the strength of this table alone, so
|
||||||
|
* what a store actually has to supply is identity — a slug, a name and a catalog.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_STORE_CONFIGURATION: StoreConfiguration = {
|
||||||
|
store: {
|
||||||
|
id: 'warp',
|
||||||
|
name: 'WarpEngine Store',
|
||||||
|
baseUrl: 'https://example.org',
|
||||||
|
api: { catalog: '/api/software', download: '/api/download' }
|
||||||
|
},
|
||||||
|
paths: {
|
||||||
|
installRoot: null,
|
||||||
|
menuDirectory: null,
|
||||||
|
iconDirectory: null,
|
||||||
|
subfolder: 'warp'
|
||||||
|
},
|
||||||
|
install: {
|
||||||
|
modes: [APP_MODE, WEB_MODE],
|
||||||
|
specifications: {
|
||||||
|
// Which asset to unpack, per host. The most specific key wins; a list is
|
||||||
|
// tried in order of preference. On Apple Silicon `mac_universal` comes first
|
||||||
|
// and `mac_x64` last, because that one needs Rosetta.
|
||||||
|
[APP_MODE]: {
|
||||||
|
kind: {
|
||||||
|
'linux-x86_64': 'linux_x64',
|
||||||
|
'linux-aarch64': 'linux_arm64',
|
||||||
|
'linux-armhf': 'linux_armhf',
|
||||||
|
'linux-x86': 'linux_x86',
|
||||||
|
'windows-x86_64': ['win_x64', 'win_x86'],
|
||||||
|
'windows-x86': 'win_x86',
|
||||||
|
'darwin-aarch64': ['mac_universal', 'mac_arm64', 'mac_x64'],
|
||||||
|
'darwin-x86_64': ['mac_universal', 'mac_x64']
|
||||||
|
},
|
||||||
|
extension: '.zip'
|
||||||
|
},
|
||||||
|
// A browser build is hosted, not downloaded: there is no archive for it, so
|
||||||
|
// the entry opens the published page. It needs the network to play.
|
||||||
|
[WEB_MODE]: { kind: 'html', extension: '' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
catalog: {
|
||||||
|
statuses: ['released', 'archived'],
|
||||||
|
ownerId: null,
|
||||||
|
only: [],
|
||||||
|
exclude: []
|
||||||
|
},
|
||||||
|
platforms: {
|
||||||
|
ebitengine: { enabled: true },
|
||||||
|
godot: { enabled: true },
|
||||||
|
love: { enabled: true },
|
||||||
|
bevy: { enabled: true },
|
||||||
|
tic80: { enabled: true },
|
||||||
|
phaser: { enabled: true },
|
||||||
|
// A cartridge is data for an emulator: nothing a desktop menu can launch. The
|
||||||
|
// Batocera and RetroArch stores are where those belong.
|
||||||
|
c64: { enabled: false }
|
||||||
|
},
|
||||||
|
behavior: { prune: true, timeout: 60, insecure: false }
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* A store engine this client knows how to drive.
|
* A store engine this client knows how to drive.
|
||||||
*
|
*
|
||||||
* There is one today. The table exists because the RetroArch store has the same
|
* There is one today: the desktop engine, which is the code in
|
||||||
* command shape, so a second entry — not a second code path — is what adding it
|
* `infrastructure/engine`. The table stays because a second host — RetroArch
|
||||||
* would take.
|
* playlists rather than menu entries — would be a second entry and a second
|
||||||
|
* `StoreCatalogGateway`, not a second code path through the application.
|
||||||
|
*
|
||||||
|
* `homeSuffix` is load-bearing rather than cosmetic: it is how an existing store
|
||||||
|
* home is recognised, so it has to keep saying `-desktop`.
|
||||||
*/
|
*/
|
||||||
export interface StoreEngine {
|
export interface StoreEngine {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly scriptFileName: string
|
|
||||||
/** The installer names a store home `<store id><homeSuffix>`. */
|
/** The installer names a store home `<store id><homeSuffix>`. */
|
||||||
readonly homeSuffix: string
|
readonly homeSuffix: string
|
||||||
readonly launcherSuffix: string
|
readonly launcherSuffix: string
|
||||||
@@ -15,7 +18,6 @@ export interface StoreEngine {
|
|||||||
|
|
||||||
export const DESKTOP_STORE_ENGINE: StoreEngine = {
|
export const DESKTOP_STORE_ENGINE: StoreEngine = {
|
||||||
id: 'desktop',
|
id: 'desktop',
|
||||||
scriptFileName: 'desktop_store.py',
|
|
||||||
homeSuffix: '-desktop',
|
homeSuffix: '-desktop',
|
||||||
launcherSuffix: '-desktop-store'
|
launcherSuffix: '-desktop-store'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,28 +3,22 @@ import type { RegistryStore } from './RegistryStore'
|
|||||||
/**
|
/**
|
||||||
* A store id, from whatever the registry gave us.
|
* A store id, from whatever the registry gave us.
|
||||||
*
|
*
|
||||||
* The id names the store home, the folder games land in and the launcher files, so
|
* The id names the store home, the folder games land in and the launcher files, so it
|
||||||
* it has to be short and filesystem-safe. Three sources, in order of how much they
|
* has to be short and filesystem-safe. Two sources, in order of how much they were
|
||||||
* were meant to be a name:
|
* meant to be a name:
|
||||||
*
|
*
|
||||||
* 1. the repository name — `ttg-desktop-store` becomes `ttg`;
|
* 1. the catalog host — `https://teletypegames.org` becomes `teletypegames`;
|
||||||
* 2. the catalog host — `https://teletypegames.org` becomes `teletypegames`;
|
* 2. the display name, slugged, as a last resort.
|
||||||
* 3. the display name, slugged, as a last resort.
|
|
||||||
*
|
*
|
||||||
* The store's own config.json overrides all of it whenever one exists.
|
* Derived rather than carried, and derived from the catalog: the catalog is what a store
|
||||||
|
* *is*, so two records naming the same catalog are the same store and land in the same
|
||||||
|
* place, which is what keeps a reinstall from orphaning what is already there.
|
||||||
*/
|
*/
|
||||||
export function deriveStoreId (store: RegistryStore): string {
|
export function deriveStoreId (store: RegistryStore): string {
|
||||||
const fromRepository = store.storeRepositoryUrl === null
|
return toSlug(readHostLabel(store.catalogUrl)) || toSlug(store.name) || 'store'
|
||||||
? ''
|
|
||||||
: (lastSegment(store.storeRepositoryUrl).replace(/-(desktop-)?store$/, ''))
|
|
||||||
return toSlug(fromRepository) || toSlug(readHostLabel(store.catalogUrl)) || toSlug(store.name) || 'store'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function lastSegment (url: string): string {
|
/** `https://www.teletypegames.org/x` -> `teletypegames`. */
|
||||||
return url.replace(/\/+$/, '').split('/').pop() ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `https://www.teletypegames.org/x` → `teletypegames`. */
|
|
||||||
function readHostLabel (catalogUrl: string): string {
|
function readHostLabel (catalogUrl: string): string {
|
||||||
try {
|
try {
|
||||||
const host = new URL(catalogUrl).hostname.replace(/^www\./, '')
|
const host = new URL(catalogUrl).hostname.replace(/^www\./, '')
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Which WarpEngine a catalog is served by, and whether this client knows it.
|
||||||
|
*
|
||||||
|
* Every WarpEngine API response carries the engine's version in a header, set before
|
||||||
|
* the action runs so that even an error response has it. That is what lets a client
|
||||||
|
* branch on the engine's age without a round trip to ask — and this file is where the
|
||||||
|
* branching starts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `WarpEngine::VERSION_HEADER` on the server side. */
|
||||||
|
export const WARP_ENGINE_VERSION_HEADER = 'warpengine-version'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The engine versions this client is written against, oldest first.
|
||||||
|
*
|
||||||
|
* Minor precision, because that is the granularity the engine changes its API at: a
|
||||||
|
* patch release fixes something behind the same shapes. Adding an entry here is a
|
||||||
|
* compile error until `selectCatalogDialect` says which dialect it gets, which is the
|
||||||
|
* point — a new engine version should not be able to arrive silently.
|
||||||
|
*/
|
||||||
|
export const SUPPORTED_WARP_ENGINE_VERSIONS = ['0.2', '0.3', '0.4', '0.5'] as const
|
||||||
|
|
||||||
|
export type SupportedWarpEngineVersion = typeof SUPPORTED_WARP_ENGINE_VERSIONS[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why the version this client will use is not simply the one the server named.
|
||||||
|
*
|
||||||
|
* - `exact` — the header named a version in the supported list;
|
||||||
|
* - `absent` — no header at all. An engine older than 0.4.0 does not send one, so
|
||||||
|
* this means "old", not "broken", and the oldest dialect is the honest
|
||||||
|
* reading of it;
|
||||||
|
* - `older` — a version below everything here: same treatment, but it said so;
|
||||||
|
* - `newer` — a version above everything here. The newest dialect is tried anyway,
|
||||||
|
* because listing nothing is worse than listing what still parses, but
|
||||||
|
* this is the case worth putting in the log.
|
||||||
|
*/
|
||||||
|
export type WarpEngineVersionMatch = 'exact' | 'absent' | 'older' | 'newer'
|
||||||
|
|
||||||
|
export interface WarpEngineVersion {
|
||||||
|
/** As the header spelled it, or null when there was none. */
|
||||||
|
readonly text: string | null
|
||||||
|
/** The supported version whose dialect will be used. Never null: one always applies. */
|
||||||
|
readonly resolved: SupportedWarpEngineVersion
|
||||||
|
readonly match: WarpEngineVersionMatch
|
||||||
|
readonly supported: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const OLDEST: SupportedWarpEngineVersion = SUPPORTED_WARP_ENGINE_VERSIONS[0]
|
||||||
|
const NEWEST: SupportedWarpEngineVersion =
|
||||||
|
SUPPORTED_WARP_ENGINE_VERSIONS[SUPPORTED_WARP_ENGINE_VERSIONS.length - 1] ?? OLDEST
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the header into a decision.
|
||||||
|
*
|
||||||
|
* An unparseable value is treated as an absent one: a header that does not look like a
|
||||||
|
* version tells us nothing about the engine, and guessing from a malformed string is
|
||||||
|
* worse than admitting we do not know.
|
||||||
|
*/
|
||||||
|
export function readWarpEngineVersion (headerValue: string | null): WarpEngineVersion {
|
||||||
|
if (headerValue === null || headerValue.trim().length === 0) {
|
||||||
|
return { text: null, resolved: OLDEST, match: 'absent', supported: false }
|
||||||
|
}
|
||||||
|
const text = headerValue.trim()
|
||||||
|
const numbers = parseVersion(text)
|
||||||
|
if (numbers === null) {
|
||||||
|
return { text, resolved: OLDEST, match: 'absent', supported: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${String(numbers[0])}.${String(numbers[1])}`
|
||||||
|
const exact = SUPPORTED_WARP_ENGINE_VERSIONS
|
||||||
|
.find((candidate: SupportedWarpEngineVersion): boolean => candidate === key)
|
||||||
|
if (exact !== undefined) {
|
||||||
|
return { text, resolved: exact, match: 'exact', supported: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const newer = compareVersions(numbers, parseVersion(NEWEST) ?? [0, 0]) > 0
|
||||||
|
return newer
|
||||||
|
? { text, resolved: NEWEST, match: 'newer', supported: false }
|
||||||
|
: { text, resolved: OLDEST, match: 'older', supported: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One sentence for the log, which is where an unsupported engine has to show up. */
|
||||||
|
export function describeWarpEngineVersion (version: WarpEngineVersion): string {
|
||||||
|
const supported = SUPPORTED_WARP_ENGINE_VERSIONS.join(', ')
|
||||||
|
switch (version.match) {
|
||||||
|
case 'exact':
|
||||||
|
return `WarpEngine ${version.text ?? ''}`
|
||||||
|
case 'absent':
|
||||||
|
return 'the catalog sent no WarpEngine-Version header — reading it as ' +
|
||||||
|
`${OLDEST}, which is what an engine older than 0.4.0 is`
|
||||||
|
case 'older':
|
||||||
|
return `WarpEngine ${version.text ?? ''} is older than anything this client knows ` +
|
||||||
|
`(${supported}) — reading it as ${OLDEST}`
|
||||||
|
case 'newer':
|
||||||
|
return `WarpEngine ${version.text ?? ''} is newer than this client knows ` +
|
||||||
|
`(${supported}) — reading it as ${NEWEST}, so some titles may be missed`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersion (text: string): readonly [number, number] | null {
|
||||||
|
const match = /(\d+)\.(\d+)/.exec(text)
|
||||||
|
if (match === null) return null
|
||||||
|
return [Number(match[1]), Number(match[2])]
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareVersions (left: readonly [number, number], right: readonly [number, number]): number {
|
||||||
|
if (left[0] !== right[0]) return left[0] - right[0]
|
||||||
|
return left[1] - right[1]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Where a store's sign-in token is kept between runs.
|
||||||
|
*
|
||||||
|
* One token per store, keyed by store id, because the client serves several stores at
|
||||||
|
* once and being signed in to one says nothing about the others.
|
||||||
|
*
|
||||||
|
* A port rather than a file path because the storage is the host's business: on a
|
||||||
|
* desktop it is the OS keychain, in a test it is a map. Nothing above this layer knows
|
||||||
|
* which, and nothing above it should — the token is the one value in this application
|
||||||
|
* that must not end up somewhere it can be read by looking.
|
||||||
|
*/
|
||||||
|
export interface CredentialRepository {
|
||||||
|
readToken: (storeId: string) => string | null
|
||||||
|
writeToken: (storeId: string, token: string) => void
|
||||||
|
clearToken: (storeId: string) => void
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import type { PythonRuntime } from '../models/PythonRuntime'
|
|
||||||
|
|
||||||
export interface PythonRuntimeLocator {
|
|
||||||
findRuntime: () => PythonRuntime | null
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,35 @@
|
|||||||
import type { CatalogListing } from '../models/CatalogListing'
|
import type { CatalogListing } from '../models/CatalogListing'
|
||||||
import type { EngineProgressListener } from '../models/EngineProgress'
|
import type { EngineProgressListener } from '../models/EngineProgress'
|
||||||
import type { EngineVersion } from '../models/EngineVersion'
|
|
||||||
import type { InstalledStore } from '../models/InstalledStore'
|
import type { InstalledStore } from '../models/InstalledStore'
|
||||||
|
import type { SignInPrompt, StoreAccount } from '../models/StoreAccount'
|
||||||
import type { StorePaths } from '../models/StorePaths'
|
import type { StorePaths } from '../models/StorePaths'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The store engine, as an interface.
|
* The store engine, as an interface.
|
||||||
*
|
*
|
||||||
* Every catalog operation this client performs is one call on this port; the CLI
|
* Every catalog operation this client performs is one call on this port. The engine
|
||||||
* behind it stays the product, and nothing above this line knows it is Python.
|
* behind it runs in this process, but nothing above this line knows that either —
|
||||||
|
* the port is what let it stop being a child process without a change up here.
|
||||||
*/
|
*/
|
||||||
export interface StoreCatalogGateway {
|
export interface StoreCatalogGateway {
|
||||||
listGames: (store: InstalledStore, progress?: EngineProgressListener) => Promise<CatalogListing>
|
listGames: (store: InstalledStore, progress?: EngineProgressListener) => Promise<CatalogListing>
|
||||||
readPaths: (store: InstalledStore, progress?: EngineProgressListener) => Promise<StorePaths>
|
readPaths: (store: InstalledStore, progress?: EngineProgressListener) => Promise<StorePaths>
|
||||||
syncGames: (store: InstalledStore, names: readonly string[], progress?: EngineProgressListener) => Promise<void>
|
syncGames: (store: InstalledStore, names: readonly string[], progress?: EngineProgressListener) => Promise<void>
|
||||||
removeGame: (store: InstalledStore, name: string, progress?: EngineProgressListener) => Promise<void>
|
removeGame: (store: InstalledStore, name: string, progress?: EngineProgressListener) => Promise<void>
|
||||||
readEngineVersion: (store: InstalledStore) => EngineVersion | null
|
|
||||||
|
/** Whether this store offers a sign-in, and whether we are holding a token for it. */
|
||||||
|
readAccount: (store: InstalledStore) => Promise<StoreAccount>
|
||||||
|
/**
|
||||||
|
* Ask the store for a code pair. The *waiting* is not here: polling is a loop with a
|
||||||
|
* cancel in it, which is orchestration, and orchestration belongs above this port.
|
||||||
|
*/
|
||||||
|
requestSignIn: (store: InstalledStore, clientName: string) => Promise<SignInPrompt>
|
||||||
|
/** One poll. Returns the account once it is answered, or null while it is not. */
|
||||||
|
pollSignIn: (store: InstalledStore, deviceCode: string) => Promise<SignInPollResult>
|
||||||
|
signOut: (store: InstalledStore) => Promise<StoreAccount>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignInPollResult {
|
||||||
|
readonly state: 'pending' | 'approved' | 'denied' | 'expired'
|
||||||
|
readonly account: StoreAccount
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { inflateRawSync } from 'node:zlib'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A zip reader over `node:zlib`, because Node has no zip and this client has no
|
||||||
|
* runtime dependencies.
|
||||||
|
*
|
||||||
|
* Only what the store actually needs to read: the archives come from our own
|
||||||
|
* release pipeline, so `stored` and `deflate` are the only compression methods
|
||||||
|
* that occur and there is no encryption to support. What is *not* optional is the
|
||||||
|
* unix mode — the archive records the executable bit and without it nothing we
|
||||||
|
* install can start, so every entry is read from the central directory, which is
|
||||||
|
* the only place that carries it.
|
||||||
|
*
|
||||||
|
* Symlinks are written as regular files holding their target path. That is also
|
||||||
|
* what Python's `ZipFile.extractall` does, so an archive that installed before
|
||||||
|
* installs the same way now.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const END_OF_CENTRAL_DIRECTORY = 0x06054b50
|
||||||
|
const CENTRAL_FILE_HEADER = 0x02014b50
|
||||||
|
const LOCAL_FILE_HEADER = 0x04034b50
|
||||||
|
|
||||||
|
const END_OF_CENTRAL_DIRECTORY_SIZE = 22
|
||||||
|
const CENTRAL_FILE_HEADER_SIZE = 46
|
||||||
|
const LOCAL_FILE_HEADER_SIZE = 30
|
||||||
|
|
||||||
|
/** A zip comment is a u16 length, so the record cannot start further back than this. */
|
||||||
|
const MAX_COMMENT_SIZE = 0xffff
|
||||||
|
|
||||||
|
const STORED = 0
|
||||||
|
const DEFLATED = 8
|
||||||
|
|
||||||
|
/** The u16/u32 sentinels a zip64 archive puts in the fields it has outgrown. */
|
||||||
|
const ZIP64_U16 = 0xffff
|
||||||
|
const ZIP64_U32 = 0xffffffff
|
||||||
|
|
||||||
|
const DEFAULT_FILE_MODE = 0o644
|
||||||
|
const DEFAULT_DIRECTORY_MODE = 0o755
|
||||||
|
const EXECUTABLE_BITS = 0o111
|
||||||
|
|
||||||
|
export interface ZipEntry {
|
||||||
|
readonly fileName: string
|
||||||
|
readonly directory: boolean
|
||||||
|
readonly compressionMethod: number
|
||||||
|
readonly compressedSize: number
|
||||||
|
readonly uncompressedSize: number
|
||||||
|
/** From the external attributes' high word; 0 when the archive carries no unix mode. */
|
||||||
|
readonly unixMode: number
|
||||||
|
readonly localHeaderOffset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ZipArchive {
|
||||||
|
private constructor (
|
||||||
|
private readonly buffer: Buffer,
|
||||||
|
public readonly entries: readonly ZipEntry[]
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static open (archivePath: string): ZipArchive {
|
||||||
|
const buffer = fs.readFileSync(archivePath)
|
||||||
|
return new ZipArchive(buffer, readCentralDirectory(buffer, archivePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpack everything into `destination`, restoring the executable bit.
|
||||||
|
*
|
||||||
|
* Every entry path is resolved and checked against the destination before it is
|
||||||
|
* written: a zip may name `../` and this store writes into the user's own data
|
||||||
|
* directory.
|
||||||
|
*/
|
||||||
|
public extractAll (destination: string): void {
|
||||||
|
const root = path.resolve(destination)
|
||||||
|
fs.mkdirSync(root, { recursive: true })
|
||||||
|
|
||||||
|
for (const entry of this.entries) {
|
||||||
|
const target = path.resolve(root, entry.fileName)
|
||||||
|
if (target !== root && !target.startsWith(root + path.sep)) {
|
||||||
|
throw new Error(`${entry.fileName} would be written outside ${root}`)
|
||||||
|
}
|
||||||
|
if (entry.directory) {
|
||||||
|
fs.mkdirSync(target, { recursive: true, mode: DEFAULT_DIRECTORY_MODE })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||||
|
fs.writeFileSync(target, this.readEntry(entry), { mode: this.fileMode(entry) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry's bytes, decompressed. */
|
||||||
|
public readEntry (entry: ZipEntry): Buffer {
|
||||||
|
const signature = this.buffer.readUInt32LE(entry.localHeaderOffset)
|
||||||
|
if (signature !== LOCAL_FILE_HEADER) {
|
||||||
|
throw new Error(`${entry.fileName}: no local header at ${String(entry.localHeaderOffset)}`)
|
||||||
|
}
|
||||||
|
// The local header's own sizes may be zero when a data descriptor follows, so
|
||||||
|
// the lengths come from the central directory and only the two variable-length
|
||||||
|
// fields are read here.
|
||||||
|
const nameLength = this.buffer.readUInt16LE(entry.localHeaderOffset + 26)
|
||||||
|
const extraLength = this.buffer.readUInt16LE(entry.localHeaderOffset + 28)
|
||||||
|
const start = entry.localHeaderOffset + LOCAL_FILE_HEADER_SIZE + nameLength + extraLength
|
||||||
|
const raw = this.buffer.subarray(start, start + entry.compressedSize)
|
||||||
|
|
||||||
|
if (entry.compressionMethod === STORED) return Buffer.from(raw)
|
||||||
|
if (entry.compressionMethod === DEFLATED) return inflateRawSync(raw)
|
||||||
|
throw new Error(`${entry.fileName}: unsupported compression method ${String(entry.compressionMethod)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mode to write a file with.
|
||||||
|
*
|
||||||
|
* A zip made on Windows carries no unix mode at all, and there the archive
|
||||||
|
* simply cannot tell us — 0644 is the safe answer, and `find_program` looks for
|
||||||
|
* `.exe` on that host anyway rather than for the executable bit.
|
||||||
|
*/
|
||||||
|
private fileMode (entry: ZipEntry): number {
|
||||||
|
if (entry.unixMode === 0) return DEFAULT_FILE_MODE
|
||||||
|
const permissions = entry.unixMode & 0o7777
|
||||||
|
if (permissions === 0) return DEFAULT_FILE_MODE
|
||||||
|
return (permissions & EXECUTABLE_BITS) === 0 ? permissions : permissions | EXECUTABLE_BITS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCentralDirectory (buffer: Buffer, archivePath: string): readonly ZipEntry[] {
|
||||||
|
const end = findEndOfCentralDirectory(buffer, archivePath)
|
||||||
|
const entryCount = buffer.readUInt16LE(end + 10)
|
||||||
|
const directoryOffset = buffer.readUInt32LE(end + 16)
|
||||||
|
|
||||||
|
if (entryCount === ZIP64_U16 || directoryOffset === ZIP64_U32) {
|
||||||
|
throw new Error(`${archivePath} is a zip64 archive, which this reader does not support`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: ZipEntry[] = []
|
||||||
|
let cursor = directoryOffset
|
||||||
|
for (let index = 0; index < entryCount; index += 1) {
|
||||||
|
if (cursor + CENTRAL_FILE_HEADER_SIZE > buffer.length) {
|
||||||
|
throw new Error(`${archivePath}: the central directory ends mid-entry`)
|
||||||
|
}
|
||||||
|
if (buffer.readUInt32LE(cursor) !== CENTRAL_FILE_HEADER) {
|
||||||
|
throw new Error(`${archivePath}: no central directory entry at ${String(cursor)}`)
|
||||||
|
}
|
||||||
|
const nameLength = buffer.readUInt16LE(cursor + 28)
|
||||||
|
const extraLength = buffer.readUInt16LE(cursor + 30)
|
||||||
|
const commentLength = buffer.readUInt16LE(cursor + 32)
|
||||||
|
const fileName = buffer.toString('utf8', cursor + CENTRAL_FILE_HEADER_SIZE, cursor + CENTRAL_FILE_HEADER_SIZE + nameLength)
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
fileName,
|
||||||
|
directory: fileName.endsWith('/'),
|
||||||
|
compressionMethod: buffer.readUInt16LE(cursor + 10),
|
||||||
|
compressedSize: buffer.readUInt32LE(cursor + 20),
|
||||||
|
uncompressedSize: buffer.readUInt32LE(cursor + 24),
|
||||||
|
unixMode: buffer.readUInt32LE(cursor + 38) >>> 16,
|
||||||
|
localHeaderOffset: buffer.readUInt32LE(cursor + 42)
|
||||||
|
})
|
||||||
|
cursor += CENTRAL_FILE_HEADER_SIZE + nameLength + extraLength + commentLength
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The record is at the end, behind a comment of unknown length, so scan backwards. */
|
||||||
|
function findEndOfCentralDirectory (buffer: Buffer, archivePath: string): number {
|
||||||
|
const earliest = Math.max(0, buffer.length - END_OF_CENTRAL_DIRECTORY_SIZE - MAX_COMMENT_SIZE)
|
||||||
|
for (let offset = buffer.length - END_OF_CENTRAL_DIRECTORY_SIZE; offset >= earliest; offset -= 1) {
|
||||||
|
if (buffer.readUInt32LE(offset) === END_OF_CENTRAL_DIRECTORY) return offset
|
||||||
|
}
|
||||||
|
throw new Error(`${archivePath} is not a zip archive`)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { safeStorage } from 'electron'
|
||||||
|
import type { CredentialRepository } from '../../domain/ports/CredentialRepository'
|
||||||
|
import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment'
|
||||||
|
|
||||||
|
const FILE_NAME = 'credentials.json'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens in the OS keychain's own encryption, in the application's data directory.
|
||||||
|
*
|
||||||
|
* Not in the store home next to `config.json` and `state.json`: those two are the
|
||||||
|
* store's public description of itself and its record of what it installed, both
|
||||||
|
* meant to be read and both copied around when somebody moves a library. A password
|
||||||
|
* does not belong in either.
|
||||||
|
*
|
||||||
|
* `safeStorage` is Electron's wrapper over the platform keychain (Keychain on macOS,
|
||||||
|
* libsecret on Linux, DPAPI on Windows). Where it is unavailable — a Linux box with no
|
||||||
|
* secret service — this stores nothing at all rather than falling back to plain text.
|
||||||
|
* The cost is signing in again next run; the alternative is a readable token on disk
|
||||||
|
* for somebody who thought it was encrypted.
|
||||||
|
*/
|
||||||
|
export class SafeStorageCredentialRepository implements CredentialRepository {
|
||||||
|
public constructor (private readonly environment: ApplicationEnvironment) {}
|
||||||
|
|
||||||
|
public readToken (storeId: string): string | null {
|
||||||
|
if (!this.available()) return null
|
||||||
|
const encoded = this.readAll()[storeId]
|
||||||
|
if (typeof encoded !== 'string') return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return safeStorage.decryptString(Buffer.from(encoded, 'base64'))
|
||||||
|
} catch {
|
||||||
|
// A token encrypted under a keychain this machine no longer has. Signing in
|
||||||
|
// again is the only way through, and an unreadable entry is not worth an error.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public writeToken (storeId: string, token: string): void {
|
||||||
|
if (!this.available()) return
|
||||||
|
|
||||||
|
const all = { ...this.readAll() }
|
||||||
|
all[storeId] = safeStorage.encryptString(token).toString('base64')
|
||||||
|
this.writeAll(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearToken (storeId: string): void {
|
||||||
|
const all = this.readAll()
|
||||||
|
if (!(storeId in all)) return
|
||||||
|
|
||||||
|
// Rebuilt without the key rather than deleted from a copy: the linter forbids a
|
||||||
|
// dynamic delete, and this says the same thing without pretending the object was
|
||||||
|
// ever mutable.
|
||||||
|
const remaining = Object.fromEntries(
|
||||||
|
Object.entries(all).filter(([key]: readonly [string, unknown]): boolean => key !== storeId)
|
||||||
|
)
|
||||||
|
this.writeAll(remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
public available (): boolean {
|
||||||
|
try {
|
||||||
|
return safeStorage.isEncryptionAvailable()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readAll (): Record<string, unknown> {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(fs.readFileSync(this.filePath(), 'utf8'))
|
||||||
|
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeAll (all: Record<string, unknown>): void {
|
||||||
|
try {
|
||||||
|
const target = this.filePath()
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||||
|
// 0600 as well as the encryption: defence in depth costs one argument here, and
|
||||||
|
// the file is only ever read by this application.
|
||||||
|
fs.writeFileSync(target, `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 })
|
||||||
|
} catch {
|
||||||
|
// A token that could not be saved means signing in again next run, which is not
|
||||||
|
// worth stopping the application for.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private filePath (): string {
|
||||||
|
return this.environment.resolveUserDataPath(FILE_NAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { StoreConfiguration } from '../../domain/models/StoreConfiguration'
|
||||||
|
import {
|
||||||
|
WARP_ENGINE_VERSION_HEADER, describeWarpEngineVersion, readWarpEngineVersion,
|
||||||
|
type WarpEngineVersion
|
||||||
|
} from '../../domain/models/WarpEngineVersion'
|
||||||
|
import { describe, type StoreFileSystem } from '../files/StoreFileSystem'
|
||||||
|
import { StoreHttpClient } from '../http/StoreHttpClient'
|
||||||
|
import { asRecord } from '../json/JsonRecord'
|
||||||
|
|
||||||
|
const CLIENT_VERSION = '2.0.0'
|
||||||
|
|
||||||
|
const IMAGE_EXTENSIONS: Readonly<Record<string, string>> = {
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/webp': '.webp',
|
||||||
|
'image/gif': '.gif'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadedImage {
|
||||||
|
readonly body: Buffer
|
||||||
|
readonly extension: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A catalog, and which engine served it. */
|
||||||
|
export interface FetchedCatalog {
|
||||||
|
readonly catalog: unknown
|
||||||
|
readonly engineVersion: WarpEngineVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalog API, as this store talks to it.
|
||||||
|
*
|
||||||
|
* Every URL the store fetches is built here, and the catalog is cached next to the
|
||||||
|
* config so a sync survives an outage — the machine that lost its network still has
|
||||||
|
* a library, and being told what is installed matters more than being current.
|
||||||
|
*/
|
||||||
|
export class CatalogClient {
|
||||||
|
private readonly http: StoreHttpClient
|
||||||
|
|
||||||
|
public constructor (
|
||||||
|
private readonly configuration: StoreConfiguration,
|
||||||
|
private readonly files: StoreFileSystem,
|
||||||
|
private readonly cachePath: string,
|
||||||
|
private readonly log: (line: string) => void,
|
||||||
|
/**
|
||||||
|
* The bearer token to send, asked for per request rather than held.
|
||||||
|
*
|
||||||
|
* Every call this client makes goes to the catalog's own host, so the credential
|
||||||
|
* belongs on all of them: the catalog needs it to say what this person owns, and
|
||||||
|
* the download needs it to be allowed at all.
|
||||||
|
*/
|
||||||
|
bearerToken: () => string | null = (): null => null
|
||||||
|
) {
|
||||||
|
this.http = new StoreHttpClient({
|
||||||
|
userAgent: `warp-engine-client/${CLIENT_VERSION} (${configuration.store.id})`,
|
||||||
|
timeout: configuration.behavior.timeout,
|
||||||
|
insecure: configuration.behavior.insecure,
|
||||||
|
bearerToken
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same HTTP client, for the service descriptor and the sign-in flow. */
|
||||||
|
public httpClient (): StoreHttpClient {
|
||||||
|
return this.http
|
||||||
|
}
|
||||||
|
|
||||||
|
public apiUrl (endpoint: 'catalog' | 'download', parameters?: Readonly<Record<string, string>>): string {
|
||||||
|
const { baseUrl, api } = this.configuration.store
|
||||||
|
const url = `${baseUrl}/${api[endpoint].replace(/^\/+/, '')}`
|
||||||
|
if (parameters === undefined) return url
|
||||||
|
return `${url}?${new URLSearchParams(parameters).toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /api/download` rather than `/file/`, so downloads are counted. */
|
||||||
|
public downloadUrl (asset: string): string {
|
||||||
|
return this.apiUrl('download', { path: asset })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalog, and the version of the engine that served it.
|
||||||
|
*
|
||||||
|
* The version comes from the response header, so it costs no extra request. A cache
|
||||||
|
* hit has no header — the cache file holds the catalog exactly as the engine sent it,
|
||||||
|
* which is the format the shell engine wrote and worth keeping — and then the version
|
||||||
|
* is reported as absent, which resolves to the oldest dialect this client knows.
|
||||||
|
*/
|
||||||
|
public async fetchCatalog (): Promise<FetchedCatalog> {
|
||||||
|
const ownerId = this.configuration.catalog.ownerId
|
||||||
|
const url = this.apiUrl('catalog', ownerId === null ? undefined : { owner_id: String(ownerId) })
|
||||||
|
try {
|
||||||
|
const { body, headers } = await this.http.readBytes(url)
|
||||||
|
const catalog = asRecord(JSON.parse(body.toString('utf8')))
|
||||||
|
if (catalog === null) throw new Error('the catalog is not a JSON object')
|
||||||
|
this.files.writeJson(this.cachePath, catalog)
|
||||||
|
|
||||||
|
const engineVersion = readWarpEngineVersion(headers[WARP_ENGINE_VERSION_HEADER] ?? null)
|
||||||
|
const sentence = describeWarpEngineVersion(engineVersion)
|
||||||
|
if (engineVersion.supported) this.log(sentence)
|
||||||
|
else this.log(`warning: ${sentence}`)
|
||||||
|
return { catalog, engineVersion }
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const cached = asRecord(this.files.readJson(this.cachePath))
|
||||||
|
if (cached === null) throw new Error(`cannot fetch the catalog from ${url}: ${describe(error)}`)
|
||||||
|
this.log(`warning: catalog fetch failed (${describe(error)}) — using the cached copy`)
|
||||||
|
return { catalog: cached, engineVersion: readWarpEngineVersion(null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async downloadAsset (asset: string, destination: string): Promise<number> {
|
||||||
|
return await this.http.download(this.downloadUrl(asset), destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Box art, or null if it cannot be had.
|
||||||
|
*
|
||||||
|
* Missing box art is not a reason to fail an install: the menu entry gets the
|
||||||
|
* generic icon and the game still starts.
|
||||||
|
*/
|
||||||
|
public async downloadImage (imageUrl: string, name: string): Promise<DownloadedImage | null> {
|
||||||
|
try {
|
||||||
|
const { body, contentType } = await this.http.readBytes(this.absoluteImageUrl(imageUrl))
|
||||||
|
const mediaType = contentType.split(';')[0]?.trim().toLowerCase() ?? ''
|
||||||
|
return { body, extension: IMAGE_EXTENSIONS[mediaType] ?? '.png' }
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.log(`warning: box art for ${name} failed: ${describe(error)}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The catalog gives a server-relative `imageUrl`; absolute ones pass through. */
|
||||||
|
public absoluteImageUrl (imageUrl: string): string {
|
||||||
|
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) return imageUrl
|
||||||
|
return this.configuration.store.baseUrl + imageUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { describeHost, resolveForHost, type HostMachine } from '../../domain/models/HostMachine'
|
||||||
|
import type { UnavailableReason } from '../../domain/models/Game'
|
||||||
|
import { gameKey } from '../../domain/models/InstalledRecord'
|
||||||
|
import type {
|
||||||
|
CatalogSurvey, SelectedGame, UnavailableEntry
|
||||||
|
} from '../../domain/models/SelectedGame'
|
||||||
|
import type { AssetSpecification, StoreConfiguration } from '../../domain/models/StoreConfiguration'
|
||||||
|
import type { CatalogEntry, CatalogSoftware } from './dialects/CatalogDialect'
|
||||||
|
import { pickRelease } from './ReleasePicker'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalog, turned into what this machine can and cannot install.
|
||||||
|
*
|
||||||
|
* Two decisions layered on each other. The inner one asks, per install mode, "which
|
||||||
|
* titles have *this* asset for this host"; the outer one asks it once per mode and
|
||||||
|
* merges the answers in the config's mode order, so a title with no native build for
|
||||||
|
* this machine is still installable as a hosted page. That fallback is what makes a
|
||||||
|
* desktop store the only one that can carry the whole catalog.
|
||||||
|
*
|
||||||
|
* A title counts as unavailable only when every mode failed it, and then the *first*
|
||||||
|
* mode's verdict is the one kept: `app` comes first, so a person is told "no native
|
||||||
|
* build for this machine" rather than the web mode's complaint about the same title.
|
||||||
|
*
|
||||||
|
* The entries arrive already typed, from whichever `CatalogDialect` the serving engine
|
||||||
|
* version selected — nothing here knows what the catalog's JSON looks like.
|
||||||
|
*/
|
||||||
|
export class CatalogSurveyor {
|
||||||
|
public constructor (
|
||||||
|
private readonly configuration: StoreConfiguration,
|
||||||
|
private readonly log: (line: string) => void
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public survey (entries: readonly CatalogEntry[], host: HostMachine): CatalogSurvey {
|
||||||
|
const chosen = new Map<string, SelectedGame>()
|
||||||
|
const unmet = new Map<string, UnavailableEntry>()
|
||||||
|
const reasonsByName = new Map<string, string[]>()
|
||||||
|
|
||||||
|
for (const mode of this.configuration.install.modes) {
|
||||||
|
const specification = this.configuration.install.specifications[mode]
|
||||||
|
if (specification === undefined) {
|
||||||
|
this.log(`warning: install mode '${mode}' has no asset spec — ignoring it`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const pass = this.surveyMode(entries, host, mode, specification)
|
||||||
|
|
||||||
|
for (const game of pass.games) {
|
||||||
|
const key = gameKey(game)
|
||||||
|
if (!chosen.has(key)) chosen.set(key, game)
|
||||||
|
}
|
||||||
|
for (const [name, reason] of pass.reasons) {
|
||||||
|
const collected = reasonsByName.get(name) ?? []
|
||||||
|
collected.push(`${mode}: ${reason}`)
|
||||||
|
reasonsByName.set(name, collected)
|
||||||
|
}
|
||||||
|
for (const entry of pass.unavailable) {
|
||||||
|
if (!unmet.has(entry.name)) unmet.set(entry.name, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A title that some later mode could serve is not skipped at all: nobody needs to
|
||||||
|
// hear that the native build was missing when the game installed anyway.
|
||||||
|
const installedNames = new Set([...chosen.values()].map((game: SelectedGame): string => game.name))
|
||||||
|
const skipped = [...reasonsByName.entries()]
|
||||||
|
.filter(([name]: readonly [string, readonly string[]]): boolean => !installedNames.has(name))
|
||||||
|
.map(([name, reasons]: readonly [string, readonly string[]]): string =>
|
||||||
|
`${name}: ${reasons.join('; ')}`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
games: [...chosen.values()],
|
||||||
|
skipped,
|
||||||
|
unavailable: [...unmet.values()]
|
||||||
|
.filter((entry: UnavailableEntry): boolean => !installedNames.has(entry.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One pass over the catalog, asking for one mode's asset. */
|
||||||
|
private surveyMode (
|
||||||
|
entries: readonly CatalogEntry[],
|
||||||
|
host: HostMachine,
|
||||||
|
mode: string,
|
||||||
|
specification: AssetSpecification
|
||||||
|
): ModeSurvey {
|
||||||
|
const { statuses, only, exclude } = this.filters()
|
||||||
|
const games: SelectedGame[] = []
|
||||||
|
const reasons: [string, string][] = []
|
||||||
|
const unavailable: UnavailableEntry[] = []
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const software = entry.software
|
||||||
|
const name = software.name
|
||||||
|
|
||||||
|
// Editorial filters: a title excluded here is in none of the three lists, because
|
||||||
|
// that is the store's choice rather than a limit of the machine.
|
||||||
|
if (statuses.size > 0 && !statuses.has(software.status.toLowerCase())) continue
|
||||||
|
if (only.size > 0 && !only.has(name.toLowerCase())) continue
|
||||||
|
if (exclude.has(name.toLowerCase())) continue
|
||||||
|
|
||||||
|
const platform = this.configuration.platforms[software.platform]
|
||||||
|
if (platform?.enabled !== true) {
|
||||||
|
// Reported rather than hidden: to somebody looking at a catalog, a platform
|
||||||
|
// switched off reads as "not supported here".
|
||||||
|
unavailable.push(toUnavailable(entry, 'platformOff',
|
||||||
|
`${software.platform} is not carried by this store`))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const wanted = readKinds(resolveForHost(specification.kind, host))
|
||||||
|
if (wanted.length === 0) {
|
||||||
|
const reason = `${software.platform} has no asset kind for ${describeHost(host)}`
|
||||||
|
reasons.push([name, reason])
|
||||||
|
unavailable.push(toUnavailable(entry, 'hostAsset', reason))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const extension = resolveForHost(specification.extension, host) ?? ''
|
||||||
|
|
||||||
|
const release = pickRelease(entry.releaseCandidates, wanted, extension)
|
||||||
|
if (release === null) {
|
||||||
|
const reason = `no '${wanted.join('/')}' asset in any release`
|
||||||
|
reasons.push([name, reason])
|
||||||
|
unavailable.push(toUnavailable(entry, 'noAsset', reason))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
games.push({
|
||||||
|
name,
|
||||||
|
scope: software.platform,
|
||||||
|
platform: software.platform,
|
||||||
|
kind: release.kind,
|
||||||
|
version: release.version,
|
||||||
|
asset: release.assetName,
|
||||||
|
assetPath: release.assetPath,
|
||||||
|
title: software.title,
|
||||||
|
description: software.description,
|
||||||
|
author: software.author,
|
||||||
|
imageUrl: software.imageUrl,
|
||||||
|
createdAt: release.createdAt,
|
||||||
|
mode,
|
||||||
|
access: entry.access
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { games, reasons, unavailable }
|
||||||
|
}
|
||||||
|
|
||||||
|
private filters (): CatalogFilters {
|
||||||
|
const lower = (values: readonly string[]): ReadonlySet<string> =>
|
||||||
|
new Set(values.map((value: string): string => value.toLowerCase()))
|
||||||
|
return {
|
||||||
|
statuses: lower(this.configuration.catalog.statuses),
|
||||||
|
only: lower(this.configuration.catalog.only),
|
||||||
|
exclude: lower(this.configuration.catalog.exclude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatalogFilters {
|
||||||
|
readonly statuses: ReadonlySet<string>
|
||||||
|
readonly only: ReadonlySet<string>
|
||||||
|
readonly exclude: ReadonlySet<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModeSurvey {
|
||||||
|
readonly games: readonly SelectedGame[]
|
||||||
|
/** `[name, reason]`, kept per name so several modes' complaints can be joined. */
|
||||||
|
readonly reasons: readonly (readonly [string, string])[]
|
||||||
|
readonly unavailable: readonly UnavailableEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One unavailable record, with enough for a client to draw a card. */
|
||||||
|
function toUnavailable (
|
||||||
|
entry: CatalogEntry,
|
||||||
|
reason: UnavailableReason,
|
||||||
|
detail: string
|
||||||
|
): UnavailableEntry {
|
||||||
|
const software: CatalogSoftware = entry.software
|
||||||
|
return {
|
||||||
|
access: entry.access,
|
||||||
|
name: software.name,
|
||||||
|
title: software.title,
|
||||||
|
platform: software.platform,
|
||||||
|
description: software.description,
|
||||||
|
author: software.author,
|
||||||
|
imageUrl: software.imageUrl,
|
||||||
|
version: entry.latestRelease?.version ?? '',
|
||||||
|
reason,
|
||||||
|
detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readKinds (value: string | readonly string[] | null): readonly string[] {
|
||||||
|
if (value === null) return []
|
||||||
|
const kinds = typeof value === 'string' ? [value] : value
|
||||||
|
return kinds.filter((kind: string): boolean => kind.length > 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import {
|
||||||
|
OPERATING_SYSTEM_PATHS, toSafeFileName, type DesktopLayout
|
||||||
|
} from '../../domain/models/DesktopLayout'
|
||||||
|
import type { InstalledRecord } from '../../domain/models/InstalledRecord'
|
||||||
|
import type { SelectedGame } from '../../domain/models/SelectedGame'
|
||||||
|
import type { StoreConfiguration } from '../../domain/models/StoreConfiguration'
|
||||||
|
import { expandHome, expandPathSpecification } from '../files/StoreFileSystem'
|
||||||
|
import type { HostMachineDetector } from './HostMachineDetector'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this store writes on this machine.
|
||||||
|
*
|
||||||
|
* The XDG and Windows environment variables are the correct answer when they are
|
||||||
|
* set, and a hardcoded path is only the fallback: a machine that moved its data
|
||||||
|
* directory should still get its own menu. A value pinned in the config beats both.
|
||||||
|
*/
|
||||||
|
export class DesktopLayoutResolver {
|
||||||
|
public constructor (
|
||||||
|
private readonly configuration: StoreConfiguration,
|
||||||
|
private readonly hosts: HostMachineDetector
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public resolveLayout (): DesktopLayout {
|
||||||
|
const operatingSystem = this.hosts.findHost().operatingSystem
|
||||||
|
const defaults = OPERATING_SYSTEM_PATHS[operatingSystem]
|
||||||
|
if (defaults === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`no desktop layout is known for '${operatingSystem}' — set paths.install_root and paths.menu_dir`)
|
||||||
|
}
|
||||||
|
const sources: Record<string, string> = { os: operatingSystem }
|
||||||
|
|
||||||
|
const pick = (key: 'installRoot' | 'menuDirectory' | 'iconDirectory'): string | null => {
|
||||||
|
const configured = this.configuration.paths[key]
|
||||||
|
if (configured !== null && configured.length > 0) {
|
||||||
|
sources[key] = 'config'
|
||||||
|
return path.resolve(expandHome(configured))
|
||||||
|
}
|
||||||
|
sources[key] = 'default'
|
||||||
|
return expandPathSpecification(defaults[key] ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const installRoot = pick('installRoot')
|
||||||
|
const menuDirectory = pick('menuDirectory')
|
||||||
|
const iconDirectory = pick('iconDirectory')
|
||||||
|
if (installRoot === null || menuDirectory === null) {
|
||||||
|
throw new Error(`cannot resolve the install root or menu directory for '${operatingSystem}'`)
|
||||||
|
}
|
||||||
|
return { operatingSystem, installRoot, menuDirectory, iconDirectory, sources }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The only payload subtree this store may delete from. */
|
||||||
|
public ownedRoot (layout: DesktopLayout): string {
|
||||||
|
return path.join(layout.installRoot, this.configuration.paths.subfolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
public payloadDirectory (layout: DesktopLayout, game: SelectedGame | InstalledRecord): string {
|
||||||
|
return path.join(this.ownedRoot(layout), game.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Box art path.
|
||||||
|
*
|
||||||
|
* Kept inside our own folder rather than the shared icon theme, so an uninstall
|
||||||
|
* never has to reach into it.
|
||||||
|
*/
|
||||||
|
public iconPath (layout: DesktopLayout, game: SelectedGame | InstalledRecord): string {
|
||||||
|
return path.join(this.ownedRoot(layout), 'icons', `${game.name}.png`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Windows puts programs in a Start-menu folder; the others do not. */
|
||||||
|
public menuGroup (layout: DesktopLayout): string {
|
||||||
|
if (layout.operatingSystem === 'windows') {
|
||||||
|
return path.join(layout.menuDirectory, toSafeFileName(this.configuration.store.name))
|
||||||
|
}
|
||||||
|
return layout.menuDirectory
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { DeviceAuthDescriptor } from '../../domain/models/ServiceDescriptor'
|
||||||
|
import type { StoreHttpClient } from '../http/StoreHttpClient'
|
||||||
|
import { asRecord, readNumber, readOptionalString, readString } from '../json/JsonRecord'
|
||||||
|
|
||||||
|
/** What the server said when asked for a code pair. */
|
||||||
|
export interface DeviceCodeRequest {
|
||||||
|
readonly deviceCode: string
|
||||||
|
/** Short enough to read off this screen and type into a browser. */
|
||||||
|
readonly userCode: string
|
||||||
|
readonly verificationUrl: string
|
||||||
|
readonly intervalSeconds: number
|
||||||
|
readonly expiresInSeconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeviceSignInState = 'pending' | 'approved' | 'denied' | 'expired'
|
||||||
|
|
||||||
|
export interface DevicePollResult {
|
||||||
|
readonly state: DeviceSignInState
|
||||||
|
/** Present exactly once: on the poll that finds the grant newly approved. */
|
||||||
|
readonly token: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The device authorization grant, client side.
|
||||||
|
*
|
||||||
|
* The client has no browser of its own, so it cannot host a login form without asking
|
||||||
|
* somebody to type a password into a window that is not one. Instead it asks for a pair
|
||||||
|
* of codes, shows the short one, sends the person to the server's own page, and polls
|
||||||
|
* with the long one until it is answered.
|
||||||
|
*
|
||||||
|
* Every address comes from the service descriptor rather than from here. That is the
|
||||||
|
* point: this class knows the *shape* of the flow, which is the engine's, and nothing
|
||||||
|
* about any particular store's addresses.
|
||||||
|
*/
|
||||||
|
export class DeviceSignInClient {
|
||||||
|
public constructor (
|
||||||
|
private readonly http: StoreHttpClient,
|
||||||
|
private readonly device: DeviceAuthDescriptor
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async requestCode (clientName: string): Promise<DeviceCodeRequest> {
|
||||||
|
const { json } = await this.http.requestJson(this.device.authorizeUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
payload: { client_name: clientName }
|
||||||
|
})
|
||||||
|
const record = asRecord(json)
|
||||||
|
if (record === null) throw new Error('the server did not answer with a device code')
|
||||||
|
|
||||||
|
const deviceCode = readOptionalString(record, 'deviceCode')
|
||||||
|
const userCode = readOptionalString(record, 'userCode')
|
||||||
|
if (deviceCode === null || userCode === null) {
|
||||||
|
throw new Error('the server did not answer with a device code')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceCode,
|
||||||
|
userCode,
|
||||||
|
verificationUrl: readOptionalString(record, 'verificationUrl') ?? this.device.verificationUrl,
|
||||||
|
// The server's own pacing wins over the descriptor's: it knows what it can take.
|
||||||
|
intervalSeconds: Math.max(1, readNumber(record, 'interval', this.device.interval)),
|
||||||
|
expiresInSeconds: Math.max(1, readNumber(record, 'expiresIn', 600))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async poll (deviceCode: string): Promise<DevicePollResult> {
|
||||||
|
// 404 is a real answer here — the grant was swept or never existed — so it is read
|
||||||
|
// rather than thrown, and reported as expired: from the client's side those are the
|
||||||
|
// same situation, and both mean start again.
|
||||||
|
const { json, statusCode } = await this.http.requestJson(this.device.tokenUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
payload: { device_code: deviceCode },
|
||||||
|
accept: [ 404, 410 ]
|
||||||
|
})
|
||||||
|
if (statusCode !== 200) return { state: 'expired', token: null }
|
||||||
|
|
||||||
|
const record = asRecord(json)
|
||||||
|
if (record === null) return { state: 'pending', token: null }
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: toState(readString(record, 'state')),
|
||||||
|
token: readOptionalString(record, 'token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signing out: the token this client carries is revoked at the server.
|
||||||
|
*
|
||||||
|
* There is no token argument because there is nowhere to put one — the credential
|
||||||
|
* rides on the request as a bearer header, from the same supplier every other call
|
||||||
|
* uses. Best effort on purpose: the token is thrown away locally either way, and a
|
||||||
|
* server that cannot be reached must not leave somebody stuck signed in.
|
||||||
|
*/
|
||||||
|
public async revoke (): Promise<boolean> {
|
||||||
|
if (this.device.revokeUrl === null) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { statusCode } = await this.http.requestJson(this.device.revokeUrl, {
|
||||||
|
method: 'DELETE',
|
||||||
|
accept: [ 204, 401 ]
|
||||||
|
})
|
||||||
|
return statusCode === 204
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toState (value: string): DeviceSignInState {
|
||||||
|
switch (value) {
|
||||||
|
case 'approved':
|
||||||
|
case 'denied':
|
||||||
|
case 'expired':
|
||||||
|
return value
|
||||||
|
default:
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import type { DesktopLayout } from '../../domain/models/DesktopLayout'
|
||||||
|
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
|
||||||
|
import { gameKey, type InstalledRecord } from '../../domain/models/InstalledRecord'
|
||||||
|
import type { SelectedGame } from '../../domain/models/SelectedGame'
|
||||||
|
import { APP_MODE, type StoreConfiguration } from '../../domain/models/StoreConfiguration'
|
||||||
|
import { describe, type StoreFileSystem } from '../files/StoreFileSystem'
|
||||||
|
import type { CatalogClient } from './CatalogClient'
|
||||||
|
import type { DesktopLayoutResolver } from './DesktopLayoutResolver'
|
||||||
|
import type { LauncherWriter } from './launchers/LauncherWriter'
|
||||||
|
import type { PayloadInstaller } from './PayloadInstaller'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installing and uninstalling one title, and the sweep over all of them.
|
||||||
|
*
|
||||||
|
* The rule the whole class turns on: an install writes three things — a payload, an
|
||||||
|
* icon and a menu entry — and the record of what was written is what an uninstall
|
||||||
|
* reads. Nothing is ever deleted by reconstructing a path from a name, because a
|
||||||
|
* path built from a name is a guess and this runs inside the user's home directory.
|
||||||
|
*/
|
||||||
|
export class GameInstaller {
|
||||||
|
public constructor (
|
||||||
|
private readonly configuration: StoreConfiguration,
|
||||||
|
private readonly layouts: DesktopLayoutResolver,
|
||||||
|
private readonly payloads: PayloadInstaller,
|
||||||
|
private readonly launchers: LauncherWriter,
|
||||||
|
private readonly catalog: CatalogClient,
|
||||||
|
private readonly files: StoreFileSystem,
|
||||||
|
private readonly log: (line: string) => void
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install every game in `games`, updating `installed` in place.
|
||||||
|
*
|
||||||
|
* Returns whether anything changed, which is what decides between "done" and
|
||||||
|
* "already up to date" — a sync that rewrites nothing should say so.
|
||||||
|
*/
|
||||||
|
public async installAll (
|
||||||
|
layout: DesktopLayout,
|
||||||
|
games: readonly SelectedGame[],
|
||||||
|
installed: Map<string, InstalledRecord>,
|
||||||
|
prune: boolean,
|
||||||
|
progress: EngineProgressListener
|
||||||
|
): Promise<boolean> {
|
||||||
|
let changed = false
|
||||||
|
let failed = 0
|
||||||
|
progress.onEvent?.({ event: 'plan', count: games.length })
|
||||||
|
|
||||||
|
for (const game of games) {
|
||||||
|
const key = gameKey(game)
|
||||||
|
let previous = installed.get(key) ?? null
|
||||||
|
|
||||||
|
// A different asset or a different mode is not an update in place: the old
|
||||||
|
// payload and the old kind of menu entry both have to go first.
|
||||||
|
if (previous !== null && (previous.asset !== game.asset || previous.mode !== game.mode)) {
|
||||||
|
this.log(`${game.name}: ${previous.version} (${previous.mode}) -> ` +
|
||||||
|
`${game.version} (${game.mode}), removing the old install`)
|
||||||
|
this.removeGame(layout, previous)
|
||||||
|
previous = null
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.onEvent?.({ event: 'begin', name: game.name, title: game.title })
|
||||||
|
try {
|
||||||
|
const result = await this.installGame(layout, game, previous)
|
||||||
|
progress.onEvent?.({
|
||||||
|
event: 'installed', name: game.name, title: game.title, changed: result.changed
|
||||||
|
})
|
||||||
|
if (result.changed || !sameRecord(installed.get(key) ?? null, result.record)) changed = true
|
||||||
|
installed.set(key, result.record)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
failed += 1
|
||||||
|
const reason = describe(error)
|
||||||
|
this.log(`warning: ${game.name} failed: ${reason}`)
|
||||||
|
progress.onEvent?.({ event: 'failed', name: game.name, error: reason })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prune) {
|
||||||
|
const keep = new Set(games.map((game: SelectedGame): string => gameKey(game)))
|
||||||
|
for (const [key, record] of [...installed]) {
|
||||||
|
if (keep.has(key)) continue
|
||||||
|
this.log(`pruning ${key} (no longer in the catalog or filtered out)`)
|
||||||
|
this.removeGame(layout, record)
|
||||||
|
progress.onEvent?.({ event: 'pruned', name: record.name })
|
||||||
|
installed.delete(key)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.onEvent?.({ event: 'finished', installed: installed.size, failed })
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Install one title in its chosen mode. */
|
||||||
|
public async installGame (
|
||||||
|
layout: DesktopLayout,
|
||||||
|
game: SelectedGame,
|
||||||
|
previous: InstalledRecord | null
|
||||||
|
): Promise<{ readonly record: InstalledRecord; readonly changed: boolean }> {
|
||||||
|
const payloadDirectory = this.layouts.payloadDirectory(layout, game)
|
||||||
|
let changed = false
|
||||||
|
let payload: string | null = null
|
||||||
|
let executable: string | null = null
|
||||||
|
let executableKind: string | null = null
|
||||||
|
let url: string | null = null
|
||||||
|
|
||||||
|
if (game.mode === APP_MODE) {
|
||||||
|
if (previous !== null && isStillInstalled(previous, game, payloadDirectory)) {
|
||||||
|
executable = previous.executable
|
||||||
|
executableKind = previous.executableKind
|
||||||
|
} else {
|
||||||
|
const size = await this.payloads.unpack(game, payloadDirectory)
|
||||||
|
changed = true
|
||||||
|
const program = this.payloads.findProgram(payloadDirectory, game.name, layout.operatingSystem)
|
||||||
|
if (program === null) {
|
||||||
|
fs.rmSync(payloadDirectory, { recursive: true, force: true })
|
||||||
|
throw new Error(`no program was found inside ${game.asset}`)
|
||||||
|
}
|
||||||
|
executable = program.executablePath
|
||||||
|
executableKind = program.kind
|
||||||
|
this.log(`installed ${game.name} ${game.version} (${String(size)} bytes) -> ${payloadDirectory}`)
|
||||||
|
}
|
||||||
|
payload = payloadDirectory
|
||||||
|
} else {
|
||||||
|
// Nothing to unpack: the browser build is hosted, so the entry is a link.
|
||||||
|
url = this.launchers.webUrl(game)
|
||||||
|
if (previous?.url !== url) {
|
||||||
|
changed = true
|
||||||
|
this.log(`added ${game.name} ${game.version} as a web entry -> ${url}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const withoutMenu: InstalledRecord = {
|
||||||
|
...game,
|
||||||
|
payload,
|
||||||
|
executable,
|
||||||
|
executableKind,
|
||||||
|
icon: await this.installIcon(layout, game),
|
||||||
|
menuEntry: null,
|
||||||
|
url
|
||||||
|
}
|
||||||
|
const menuEntry = this.launchers.writeLauncher(layout, withoutMenu)
|
||||||
|
if ((previous?.menuEntry ?? null) !== menuEntry) changed = true
|
||||||
|
|
||||||
|
return { record: { ...withoutMenu, menuEntry }, changed }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete one title's payload, icon and menu entry — and nothing else. */
|
||||||
|
public removeGame (layout: DesktopLayout, record: InstalledRecord): void {
|
||||||
|
const owned = this.layouts.ownedRoot(layout)
|
||||||
|
for (const target of [record.payload, record.icon]) {
|
||||||
|
if (target !== null) this.files.removeWithin(target, owned)
|
||||||
|
}
|
||||||
|
if (record.menuEntry !== null) {
|
||||||
|
this.files.removeWithin(record.menuEntry, layout.menuDirectory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove everything this store installed, folders included. */
|
||||||
|
public purge (layout: DesktopLayout, installed: Map<string, InstalledRecord>): void {
|
||||||
|
for (const [key, record] of [...installed]) {
|
||||||
|
this.removeGame(layout, record)
|
||||||
|
installed.delete(key)
|
||||||
|
}
|
||||||
|
const owned = this.layouts.ownedRoot(layout)
|
||||||
|
this.files.pruneEmptyDirectories([path.join(owned, 'icons'), owned], layout.installRoot)
|
||||||
|
if (layout.operatingSystem === 'windows') {
|
||||||
|
this.files.pruneEmptyDirectories([this.layouts.menuGroup(layout)], layout.menuDirectory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async installIcon (layout: DesktopLayout, game: SelectedGame): Promise<string | null> {
|
||||||
|
if (game.imageUrl === null) return null
|
||||||
|
const iconPath = this.layouts.iconPath(layout, game)
|
||||||
|
if (hasContent(iconPath)) return iconPath
|
||||||
|
|
||||||
|
const image = await this.catalog.downloadImage(game.imageUrl, game.name)
|
||||||
|
if (image === null) return null
|
||||||
|
// The name says .png because that is what a desktop entry and `sips` expect; a
|
||||||
|
// non-PNG cover still displays on Linux, which sniffs the content.
|
||||||
|
this.files.writeAtomic(iconPath, image.body)
|
||||||
|
return iconPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the previous install is still there and still the right one.
|
||||||
|
*
|
||||||
|
* All four conditions matter: the same asset, a payload directory that exists, a
|
||||||
|
* recorded executable, and that executable still on disk. A user who deleted the
|
||||||
|
* folder by hand should get a reinstall rather than a menu entry that does nothing.
|
||||||
|
*/
|
||||||
|
function isStillInstalled (
|
||||||
|
previous: InstalledRecord,
|
||||||
|
game: SelectedGame,
|
||||||
|
payloadDirectory: string
|
||||||
|
): boolean {
|
||||||
|
return previous.asset === game.asset &&
|
||||||
|
fs.existsSync(payloadDirectory) &&
|
||||||
|
previous.executable !== null &&
|
||||||
|
fs.existsSync(previous.executable)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasContent (filePath: string): boolean {
|
||||||
|
try {
|
||||||
|
return fs.statSync(filePath).size > 0
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameRecord (left: InstalledRecord | null, right: InstalledRecord): boolean {
|
||||||
|
return left !== null && JSON.stringify(left) === JSON.stringify(right)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { HostMachine } from '../../domain/models/HostMachine'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This machine, as the release picker needs to know it.
|
||||||
|
*
|
||||||
|
* Node already normalises what `uname -m` reports, but not to the names the catalog
|
||||||
|
* uses, and the catalog's names are the ones the asset kinds are keyed by.
|
||||||
|
*/
|
||||||
|
export class HostMachineDetector {
|
||||||
|
private cached: HostMachine | null = null
|
||||||
|
|
||||||
|
public findHost (): HostMachine {
|
||||||
|
this.cached ??= {
|
||||||
|
operatingSystem: readOperatingSystem(),
|
||||||
|
architecture: readArchitecture()
|
||||||
|
}
|
||||||
|
return this.cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node's names for the architectures the catalog has a name for.
|
||||||
|
*
|
||||||
|
* Anything not in the table passes through unchanged: it will match no asset kind,
|
||||||
|
* and the survey then reports the titles as unavailable on this machine — which is
|
||||||
|
* the honest answer for a host nobody has built for.
|
||||||
|
*/
|
||||||
|
const ARCHITECTURE_NAMES: Readonly<Record<string, string>> = {
|
||||||
|
x64: 'x86_64',
|
||||||
|
arm64: 'aarch64',
|
||||||
|
arm: 'armhf',
|
||||||
|
ia32: 'x86'
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPERATING_SYSTEM_NAMES: Readonly<Record<string, string>> = {
|
||||||
|
darwin: 'darwin',
|
||||||
|
win32: 'windows',
|
||||||
|
linux: 'linux'
|
||||||
|
}
|
||||||
|
|
||||||
|
function readArchitecture (): string {
|
||||||
|
return ARCHITECTURE_NAMES[process.arch] ?? process.arch
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOperatingSystem (): string {
|
||||||
|
return OPERATING_SYSTEM_NAMES[process.platform] ?? process.platform
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import type { CatalogListing } from '../../domain/models/CatalogListing'
|
||||||
|
import type { DesktopLayout } from '../../domain/models/DesktopLayout'
|
||||||
|
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
|
||||||
|
import type { Game, GameMode } from '../../domain/models/Game'
|
||||||
|
import {
|
||||||
|
gameKey, limitToNames, matchStateKeys, type InstalledRecord
|
||||||
|
} from '../../domain/models/InstalledRecord'
|
||||||
|
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
||||||
|
import type {
|
||||||
|
CatalogSurvey, SelectedGame, UnavailableEntry
|
||||||
|
} from '../../domain/models/SelectedGame'
|
||||||
|
import type { ServiceDescriptor } from '../../domain/models/ServiceDescriptor'
|
||||||
|
import type { SignInPrompt, StoreAccount } from '../../domain/models/StoreAccount'
|
||||||
|
import { APP_MODE, WEB_MODE, type StoreConfiguration } from '../../domain/models/StoreConfiguration'
|
||||||
|
import type { StorePaths } from '../../domain/models/StorePaths'
|
||||||
|
import type { CredentialRepository } from '../../domain/ports/CredentialRepository'
|
||||||
|
import type { SignInPollResult, StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
|
||||||
|
import { StoreFileSystem } from '../files/StoreFileSystem'
|
||||||
|
import { CatalogClient, type FetchedCatalog } from './CatalogClient'
|
||||||
|
import { CatalogSurveyor } from './CatalogSurveyor'
|
||||||
|
import type { CatalogEntry } from './dialects/CatalogDialect'
|
||||||
|
import { selectCatalogDialect } from './dialects/CatalogDialectSelector'
|
||||||
|
import { DesktopLayoutResolver } from './DesktopLayoutResolver'
|
||||||
|
import { DeviceSignInClient } from './DeviceSignInClient'
|
||||||
|
import { GameInstaller } from './GameInstaller'
|
||||||
|
import { HostMachineDetector } from './HostMachineDetector'
|
||||||
|
import { LauncherWriter } from './launchers/LauncherWriter'
|
||||||
|
import { PayloadInstaller } from './PayloadInstaller'
|
||||||
|
import { ServiceDescriptorClient } from './ServiceDescriptorClient'
|
||||||
|
import { StoreConfigurationReader } from './StoreConfigurationReader'
|
||||||
|
import { StoreStateRepository } from './StoreStateRepository'
|
||||||
|
|
||||||
|
const STATE_FILE_NAME = 'state.json'
|
||||||
|
const CATALOG_CACHE_FILE_NAME = 'catalog.json'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The store engine, in process.
|
||||||
|
*
|
||||||
|
* This is the whole of what used to be `desktop_store.py` and `warpstore.py` driven
|
||||||
|
* as a child process: the catalog, the release choice, the host match, the unpacking,
|
||||||
|
* the menu entry and the state. Nothing is serialised to JSON lines and parsed back,
|
||||||
|
* so the progress events below are the typed events the window already expects, and
|
||||||
|
* a `Game` is built rather than read out of somebody else's field names.
|
||||||
|
*
|
||||||
|
* What has *not* changed is what lands on disk. `config.json` and `state.json` keep
|
||||||
|
* the shell engine's snake_case shape, so a machine whose library was installed by
|
||||||
|
* the CLI keeps it.
|
||||||
|
*/
|
||||||
|
export class NativeStoreCatalogGateway implements StoreCatalogGateway {
|
||||||
|
private readonly hosts = new HostMachineDetector()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The credentials are injected because they are the host's to keep: on a desktop the
|
||||||
|
* OS keychain, in the smoke test a map in memory. Nothing here knows which.
|
||||||
|
*/
|
||||||
|
public constructor (private readonly credentials: CredentialRepository = NO_CREDENTIALS) {}
|
||||||
|
|
||||||
|
public async listGames (
|
||||||
|
store: InstalledStore,
|
||||||
|
progress: EngineProgressListener = {}
|
||||||
|
): Promise<CatalogListing> {
|
||||||
|
const engine = this.openStore(store, progress)
|
||||||
|
const host = this.hosts.findHost()
|
||||||
|
engine.log(`host: ${host.operatingSystem}/${host.architecture}`)
|
||||||
|
|
||||||
|
const [ descriptor, entries ] = await Promise.all([
|
||||||
|
engine.service.fetchDescriptor(), this.readEntries(engine)
|
||||||
|
])
|
||||||
|
const survey = engine.surveyor.survey(entries, host)
|
||||||
|
const installed = engine.state.readState()
|
||||||
|
|
||||||
|
// One list, both kinds: a client that hides what it cannot install leaves the
|
||||||
|
// visitor wondering whether the catalog is small or their machine is unusual.
|
||||||
|
const games = [
|
||||||
|
...survey.games.map((game: SelectedGame): Game => this.toGame(engine, game, installed)),
|
||||||
|
...survey.unavailable.map((entry: UnavailableEntry): Game => toUnavailableGame(entry))
|
||||||
|
].sort((left: Game, right: Game): number =>
|
||||||
|
left.title.toLowerCase().localeCompare(right.title.toLowerCase()))
|
||||||
|
|
||||||
|
return {
|
||||||
|
games,
|
||||||
|
skipped: survey.skipped,
|
||||||
|
paths: this.toPaths(engine),
|
||||||
|
account: toAccount(descriptor, this.credentials.readToken(store.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this store's things go.
|
||||||
|
*
|
||||||
|
* Synchronous work behind an async port, which is deliberate: the port was shaped
|
||||||
|
* for a child process and stays shaped for one, so a future engine that has to go
|
||||||
|
* to the network for this needs no change above.
|
||||||
|
*/
|
||||||
|
public readPaths (
|
||||||
|
store: InstalledStore,
|
||||||
|
progress: EngineProgressListener = {}
|
||||||
|
): Promise<StorePaths> {
|
||||||
|
return Promise.resolve(this.toPaths(this.openStore(store, progress)))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async syncGames (
|
||||||
|
store: InstalledStore,
|
||||||
|
names: readonly string[],
|
||||||
|
progress: EngineProgressListener = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const engine = this.openStore(store, progress)
|
||||||
|
const survey: CatalogSurvey = engine.surveyor.survey(
|
||||||
|
await this.readEntries(engine), this.hosts.findHost())
|
||||||
|
for (const reason of survey.skipped) engine.log(`skipped ${reason}`)
|
||||||
|
|
||||||
|
const wanted = names.length > 0 ? limitToNames(survey.games, names) : survey.games
|
||||||
|
const installed = engine.state.readState()
|
||||||
|
// Pruning is for a full sync only: asked for two titles, the store must not take
|
||||||
|
// the absence of the rest as a reason to uninstall them.
|
||||||
|
const prune = engine.configuration.behavior.prune && names.length === 0
|
||||||
|
|
||||||
|
const changed = await engine.installer.installAll(
|
||||||
|
engine.layout, wanted, installed, prune, progress)
|
||||||
|
engine.state.writeState(installed)
|
||||||
|
if (!changed) {
|
||||||
|
engine.log('already up to date')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
engine.launchers.refreshMenu(engine.layout)
|
||||||
|
engine.log(`done — the games are in ${engine.layouts.menuGroup(engine.layout)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeGame (
|
||||||
|
store: InstalledStore,
|
||||||
|
name: string,
|
||||||
|
progress: EngineProgressListener = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const engine = this.openStore(store, progress)
|
||||||
|
const installed = engine.state.readState()
|
||||||
|
const keys = matchStateKeys(installed, [name])
|
||||||
|
if (keys.length === 0) {
|
||||||
|
engine.log(`not installed: ${name}`)
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
for (const key of keys) {
|
||||||
|
const record = installed.get(key)
|
||||||
|
if (record === undefined) continue
|
||||||
|
engine.installer.removeGame(engine.layout, record)
|
||||||
|
progress.onEvent?.({ event: 'removed', name: record.name })
|
||||||
|
installed.delete(key)
|
||||||
|
}
|
||||||
|
engine.state.writeState(installed)
|
||||||
|
engine.launchers.refreshMenu(engine.layout)
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
public async readAccount (store: InstalledStore): Promise<StoreAccount> {
|
||||||
|
const engine = this.openStore(store, {})
|
||||||
|
const descriptor = await engine.service.fetchDescriptor()
|
||||||
|
return toAccount(descriptor, this.credentials.readToken(store.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async requestSignIn (store: InstalledStore, clientName: string): Promise<SignInPrompt> {
|
||||||
|
const { client } = await this.openSignIn(store)
|
||||||
|
const requested = await client.requestCode(clientName)
|
||||||
|
return {
|
||||||
|
deviceCode: requested.deviceCode,
|
||||||
|
userCode: requested.userCode,
|
||||||
|
verificationUrl: requested.verificationUrl,
|
||||||
|
intervalSeconds: requested.intervalSeconds,
|
||||||
|
expiresInSeconds: requested.expiresInSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One poll. The token is written here, on the single answer that carries it — a
|
||||||
|
* caller that had to remember to save it would eventually forget.
|
||||||
|
*/
|
||||||
|
public async pollSignIn (store: InstalledStore, deviceCode: string): Promise<SignInPollResult> {
|
||||||
|
const { client, descriptor, log } = await this.openSignIn(store)
|
||||||
|
const result = await client.poll(deviceCode)
|
||||||
|
if (result.state === 'approved' && result.token !== null) {
|
||||||
|
this.credentials.writeToken(store.id, result.token)
|
||||||
|
log('signed in')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: result.state,
|
||||||
|
account: toAccount(descriptor, this.credentials.readToken(store.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign out: tell the server, then forget the token locally regardless.
|
||||||
|
*
|
||||||
|
* The local half is what matters and must not depend on the network — somebody
|
||||||
|
* signing out on a train has to actually be signed out.
|
||||||
|
*/
|
||||||
|
public async signOut (store: InstalledStore): Promise<StoreAccount> {
|
||||||
|
const engine = this.openStore(store, {})
|
||||||
|
const descriptor = await engine.service.fetchDescriptor()
|
||||||
|
if (descriptor.auth !== null && this.credentials.readToken(store.id) !== null) {
|
||||||
|
await new DeviceSignInClient(engine.catalog.httpClient(), descriptor.auth.device).revoke()
|
||||||
|
}
|
||||||
|
this.credentials.clearToken(store.id)
|
||||||
|
engine.log('signed out')
|
||||||
|
return toAccount(descriptor, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sign-in client for one store, or a clear error if the store offers none. */
|
||||||
|
private async openSignIn (store: InstalledStore): Promise<SignInContext> {
|
||||||
|
const engine = this.openStore(store, {})
|
||||||
|
const descriptor = await engine.service.fetchDescriptor()
|
||||||
|
if (descriptor.auth === null) {
|
||||||
|
throw new Error(`${store.name} does not offer signing in`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
client: new DeviceSignInClient(engine.catalog.httpClient(), descriptor.auth.device),
|
||||||
|
descriptor,
|
||||||
|
log: engine.log
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the catalog and read it with the dialect its engine version calls for.
|
||||||
|
*
|
||||||
|
* This is the one place a WarpEngine version turns into behaviour: the header decides
|
||||||
|
* which dialect parses the response, and everything downstream sees typed entries.
|
||||||
|
*/
|
||||||
|
private async readEntries (engine: StoreEngineContext): Promise<readonly CatalogEntry[]> {
|
||||||
|
const fetched: FetchedCatalog = await engine.catalog.fetchCatalog()
|
||||||
|
return selectCatalogDialect(fetched.engineVersion.resolved).listEntries(fetched.catalog)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble the engine for one store.
|
||||||
|
*
|
||||||
|
* Per call rather than cached: the config on disk is the authority and the user may
|
||||||
|
* have edited it, and a store's home is cheap to read.
|
||||||
|
*/
|
||||||
|
private openStore (store: InstalledStore, progress: EngineProgressListener): StoreEngineContext {
|
||||||
|
const log = (line: string): void => { progress.onLog?.(`[${store.id}-store] ${line}`) }
|
||||||
|
const files = new StoreFileSystem(`${store.id}-store`, log)
|
||||||
|
const configuration = new StoreConfigurationReader(files).readConfiguration(store.configPath)
|
||||||
|
|
||||||
|
const layouts = new DesktopLayoutResolver(configuration, this.hosts)
|
||||||
|
const layout = layouts.resolveLayout()
|
||||||
|
const catalog = new CatalogClient(
|
||||||
|
configuration, files, path.join(store.home, CATALOG_CACHE_FILE_NAME), log,
|
||||||
|
(): string | null => this.credentials.readToken(store.id))
|
||||||
|
const launchers = new LauncherWriter(configuration, layouts, files, log)
|
||||||
|
|
||||||
|
return {
|
||||||
|
configuration,
|
||||||
|
layout,
|
||||||
|
layouts,
|
||||||
|
catalog,
|
||||||
|
launchers,
|
||||||
|
log,
|
||||||
|
service: new ServiceDescriptorClient(catalog.httpClient(), configuration.store.baseUrl, log),
|
||||||
|
surveyor: new CatalogSurveyor(configuration, log),
|
||||||
|
state: new StoreStateRepository(files, path.join(store.home, STATE_FILE_NAME), log),
|
||||||
|
installer: new GameInstaller(
|
||||||
|
configuration, layouts,
|
||||||
|
new PayloadInstaller(catalog, files, log),
|
||||||
|
launchers, catalog, files, log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toGame (
|
||||||
|
engine: StoreEngineContext,
|
||||||
|
game: SelectedGame,
|
||||||
|
installed: ReadonlyMap<string, InstalledRecord>
|
||||||
|
): Game {
|
||||||
|
const record = installed.get(gameKey(game)) ?? null
|
||||||
|
return {
|
||||||
|
name: game.name,
|
||||||
|
title: game.title,
|
||||||
|
platform: game.platform,
|
||||||
|
version: game.version,
|
||||||
|
mode: toMode(game.mode),
|
||||||
|
kind: game.kind,
|
||||||
|
description: game.description,
|
||||||
|
author: game.author,
|
||||||
|
imagePath: game.imageUrl,
|
||||||
|
installed: record !== null,
|
||||||
|
updateAvailable: record !== null && record.asset !== game.asset,
|
||||||
|
installedVersion: record?.version ?? null,
|
||||||
|
menuEntryPath: record?.menuEntry ?? null,
|
||||||
|
executablePath: record?.executable ?? null,
|
||||||
|
// The catalog's own play address wins where it gives one: a store that gates its
|
||||||
|
// web builds serves them from a page that knows how to ask somebody to sign in,
|
||||||
|
// and the raw /file/ directory under it does not.
|
||||||
|
hostedUrl: game.mode === WEB_MODE
|
||||||
|
? game.access?.webUrl ?? engine.launchers.webUrl(game)
|
||||||
|
: null,
|
||||||
|
installable: true,
|
||||||
|
unavailableReason: null,
|
||||||
|
unavailableDetail: null,
|
||||||
|
access: game.access
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toPaths (engine: StoreEngineContext): StorePaths {
|
||||||
|
const host = this.hosts.findHost()
|
||||||
|
return {
|
||||||
|
operatingSystem: engine.layout.operatingSystem,
|
||||||
|
architecture: host.architecture,
|
||||||
|
installRoot: engine.layout.installRoot,
|
||||||
|
menuDirectory: engine.layout.menuDirectory,
|
||||||
|
storeFolder: engine.layouts.ownedRoot(engine.layout),
|
||||||
|
menuGroup: engine.layouts.menuGroup(engine.layout),
|
||||||
|
catalogBaseUrl: engine.configuration.store.baseUrl,
|
||||||
|
storeName: engine.configuration.store.name,
|
||||||
|
storeId: engine.configuration.store.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything one store's operations need, assembled from its home. */
|
||||||
|
interface StoreEngineContext {
|
||||||
|
readonly configuration: StoreConfiguration
|
||||||
|
readonly layout: DesktopLayout
|
||||||
|
readonly layouts: DesktopLayoutResolver
|
||||||
|
readonly catalog: CatalogClient
|
||||||
|
readonly service: ServiceDescriptorClient
|
||||||
|
readonly launchers: LauncherWriter
|
||||||
|
readonly surveyor: CatalogSurveyor
|
||||||
|
readonly state: StoreStateRepository
|
||||||
|
readonly installer: GameInstaller
|
||||||
|
readonly log: (line: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A title this machine cannot install, in the same shape as an installable one.
|
||||||
|
*
|
||||||
|
* The mode is `app` for want of a truer answer: a title with no build has no mode,
|
||||||
|
* and nothing reads this one because `installable` is false.
|
||||||
|
*/
|
||||||
|
function toUnavailableGame (entry: UnavailableEntry): Game {
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
title: entry.title,
|
||||||
|
platform: entry.platform,
|
||||||
|
version: entry.version,
|
||||||
|
mode: APP_MODE,
|
||||||
|
kind: '',
|
||||||
|
description: entry.description,
|
||||||
|
author: entry.author,
|
||||||
|
imagePath: entry.imageUrl,
|
||||||
|
installed: false,
|
||||||
|
updateAvailable: false,
|
||||||
|
installedVersion: null,
|
||||||
|
menuEntryPath: null,
|
||||||
|
executablePath: null,
|
||||||
|
hostedUrl: null,
|
||||||
|
installable: false,
|
||||||
|
unavailableReason: entry.reason,
|
||||||
|
unavailableDetail: entry.detail,
|
||||||
|
access: entry.access
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SignInContext {
|
||||||
|
readonly client: DeviceSignInClient
|
||||||
|
readonly descriptor: ServiceDescriptor
|
||||||
|
readonly log: (line: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holding a token for a store that has no sign-in is not being signed in.
|
||||||
|
*
|
||||||
|
* It happens: a store can lose its identity configuration, or a client can keep a token
|
||||||
|
* from before. Reporting it as signed in would offer a "sign out" for a door that is no
|
||||||
|
* longer there.
|
||||||
|
*/
|
||||||
|
function toAccount (descriptor: ServiceDescriptor, token: string | null): StoreAccount {
|
||||||
|
const available = descriptor.auth !== null
|
||||||
|
return { signInAvailable: available, signedIn: available && token !== null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A client with nowhere to keep a token is a client that is never signed in.
|
||||||
|
*
|
||||||
|
* The two writers throw nothing away and record nothing: this is the shape the smoke
|
||||||
|
* test runs in, where there is no Electron and therefore no keychain, and a store with
|
||||||
|
* no sign-in behaves exactly as it always did.
|
||||||
|
*/
|
||||||
|
const NO_CREDENTIALS: CredentialRepository = {
|
||||||
|
readToken: (): null => null,
|
||||||
|
writeToken: (storeId: string, token: string): void => { void storeId; void token },
|
||||||
|
clearToken: (storeId: string): void => { void storeId }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMode (mode: string): GameMode {
|
||||||
|
return mode === WEB_MODE ? WEB_MODE : APP_MODE
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import type { SelectedGame } from '../../domain/models/SelectedGame'
|
||||||
|
import { ZipArchive } from '../archive/ZipArchive'
|
||||||
|
import type { StoreFileSystem } from '../files/StoreFileSystem'
|
||||||
|
import type { CatalogClient } from './CatalogClient'
|
||||||
|
|
||||||
|
/** Files that are never the program: data, libraries and documentation. */
|
||||||
|
const NEVER_A_PROGRAM: readonly string[] =
|
||||||
|
['.txt', '.md', '.json', '.so', '.dll', '.dylib', '.pck', '.dat']
|
||||||
|
|
||||||
|
export type ExecutableKind = 'bundle' | 'exe'
|
||||||
|
|
||||||
|
export interface FoundProgram {
|
||||||
|
readonly kind: ExecutableKind
|
||||||
|
readonly executablePath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getting a native build onto the disk and finding what to launch in it.
|
||||||
|
*
|
||||||
|
* The archive is downloaded to a part file beside the payload and unpacked only once
|
||||||
|
* it is complete, and the payload directory is replaced rather than merged: a build
|
||||||
|
* that dropped a file between releases would otherwise keep the old one around and
|
||||||
|
* the game would load it.
|
||||||
|
*/
|
||||||
|
export class PayloadInstaller {
|
||||||
|
public constructor (
|
||||||
|
private readonly catalog: CatalogClient,
|
||||||
|
private readonly files: StoreFileSystem,
|
||||||
|
private readonly log: (line: string) => void
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Download and unpack into `destination`. Returns bytes downloaded. */
|
||||||
|
public async unpack (game: SelectedGame, destination: string): Promise<number> {
|
||||||
|
const parent = path.dirname(destination)
|
||||||
|
fs.mkdirSync(parent, { recursive: true })
|
||||||
|
const archivePath = this.files.temporaryPath(parent, game.name, '.zip')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const size = await this.catalog.downloadAsset(game.asset, archivePath)
|
||||||
|
fs.rmSync(destination, { recursive: true, force: true })
|
||||||
|
fs.mkdirSync(destination, { recursive: true })
|
||||||
|
ZipArchive.open(archivePath).extractAll(destination)
|
||||||
|
return size
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(archivePath, { force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The thing to launch inside an unpacked payload.
|
||||||
|
*
|
||||||
|
* A `bundle` is a macOS `.app` the archive already contained — a LÖVE or Godot
|
||||||
|
* build ships one — and then it *is* the launcher rather than something to wrap.
|
||||||
|
* Otherwise the answer is a single executable, and "single" is the whole
|
||||||
|
* difficulty: an archive holding one candidate is unambiguous, and where there are
|
||||||
|
* several the one named after the game wins. Anything else is reported as not
|
||||||
|
* found, because launching the wrong binary is worse than failing the install.
|
||||||
|
*/
|
||||||
|
public findProgram (root: string, name: string, operatingSystem: string): FoundProgram | null {
|
||||||
|
if (operatingSystem === 'darwin') {
|
||||||
|
const bundle = this.findBundle(root)
|
||||||
|
if (bundle !== null) return { kind: 'bundle', executablePath: bundle }
|
||||||
|
}
|
||||||
|
|
||||||
|
const executables: string[] = []
|
||||||
|
const namedAfterTheGame: string[] = []
|
||||||
|
|
||||||
|
this.walk(root, (filePath: string): void => {
|
||||||
|
const fileName = path.basename(filePath)
|
||||||
|
const lowered = fileName.toLowerCase()
|
||||||
|
if (NEVER_A_PROGRAM.some((extension: string): boolean => lowered.endsWith(extension))) return
|
||||||
|
|
||||||
|
if (operatingSystem === 'windows') {
|
||||||
|
if (lowered.endsWith('.exe')) executables.push(filePath)
|
||||||
|
} else if (isExecutable(filePath)) {
|
||||||
|
executables.push(filePath)
|
||||||
|
}
|
||||||
|
if (stem(fileName) === name) namedAfterTheGame.push(filePath)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const candidates of [executables, namedAfterTheGame]) {
|
||||||
|
if (candidates.length === 1 && candidates[0] !== undefined) {
|
||||||
|
return { kind: 'exe', executablePath: candidates[0] }
|
||||||
|
}
|
||||||
|
const exact = candidates.filter((candidate: string): boolean =>
|
||||||
|
stem(path.basename(candidate)) === name)
|
||||||
|
if (exact.length === 1 && exact[0] !== undefined) {
|
||||||
|
return { kind: 'exe', executablePath: exact[0] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.log(`found no single program to launch inside ${root}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shallowest `.app`, without descending into one we have already found. */
|
||||||
|
private findBundle (root: string): string | null {
|
||||||
|
let level = [root]
|
||||||
|
while (level.length > 0) {
|
||||||
|
const next: string[] = []
|
||||||
|
for (const directory of level) {
|
||||||
|
const bundles = readDirectories(directory)
|
||||||
|
.filter((entry: string): boolean => entry.endsWith('.app')).sort()
|
||||||
|
const first = bundles[0]
|
||||||
|
if (first !== undefined) return path.join(directory, first)
|
||||||
|
for (const entry of readDirectories(directory)) next.push(path.join(directory, entry))
|
||||||
|
}
|
||||||
|
level = next
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every file under `root`, never entering a `.app` bundle. */
|
||||||
|
private walk (root: string, visit: (filePath: string) => void): void {
|
||||||
|
const pending = [root]
|
||||||
|
while (pending.length > 0) {
|
||||||
|
const directory = pending.pop()
|
||||||
|
if (directory === undefined) continue
|
||||||
|
let entries: fs.Dirent[]
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(directory, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = path.join(directory, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (!entry.name.endsWith('.app')) pending.push(full)
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
visit(full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDirectories (directory: string): readonly string[] {
|
||||||
|
try {
|
||||||
|
return fs.readdirSync(directory, { withFileTypes: true })
|
||||||
|
.filter((entry: fs.Dirent): boolean => entry.isDirectory())
|
||||||
|
.map((entry: fs.Dirent): string => entry.name)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExecutable (filePath: string): boolean {
|
||||||
|
try {
|
||||||
|
fs.accessSync(filePath, fs.constants.X_OK)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stem (fileName: string): string {
|
||||||
|
return path.basename(fileName, path.extname(fileName))
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import type { CatalogRelease } from './dialects/CatalogDialect'
|
||||||
|
|
||||||
|
export interface PickedRelease {
|
||||||
|
readonly version: string
|
||||||
|
readonly createdAt: string | null
|
||||||
|
readonly assetName: string
|
||||||
|
readonly kind: string
|
||||||
|
readonly assetPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/file/blessingofra-2.0.0.prg` -> `blessingofra-2.0.0.prg`. */
|
||||||
|
export function assetBasename (assetPath: string): string {
|
||||||
|
return path.posix.basename(assetPath.replace(/\/+$/, ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The newest non-dev release that carries an asset kind we can use.
|
||||||
|
*
|
||||||
|
* The candidates arrive newest-first from the dialect, which is where knowing that the
|
||||||
|
* API sorts them lives. Two rules decide the rest:
|
||||||
|
*
|
||||||
|
* **Release order wins over kind order.** The newest release that has *any* acceptable
|
||||||
|
* kind is taken, and within it the most preferred kind. That is what a desktop host
|
||||||
|
* wants: on Apple Silicon `mac_universal` beats `mac_x64`, which would need Rosetta,
|
||||||
|
* but not at the price of installing an older release.
|
||||||
|
*
|
||||||
|
* **A `dev-` build is never taken.** It is a moving target, and installing one would
|
||||||
|
* leave a menu entry pointing at an archive that is replaced without a version change.
|
||||||
|
*/
|
||||||
|
export function pickRelease (
|
||||||
|
candidates: readonly CatalogRelease[],
|
||||||
|
kinds: readonly string[],
|
||||||
|
extension: string
|
||||||
|
): PickedRelease | null {
|
||||||
|
for (const release of candidates) {
|
||||||
|
if (release.version.startsWith('dev-')) continue
|
||||||
|
for (const kind of kinds) {
|
||||||
|
for (const asset of release.assets) {
|
||||||
|
if (asset.kind !== kind) continue
|
||||||
|
const assetName = assetBasename(asset.path)
|
||||||
|
if (assetName.length === 0) continue
|
||||||
|
if (extension.length > 0 && !assetName.toLowerCase().endsWith(extension.toLowerCase())) continue
|
||||||
|
return {
|
||||||
|
version: release.version,
|
||||||
|
createdAt: release.createdAt,
|
||||||
|
assetName,
|
||||||
|
kind,
|
||||||
|
assetPath: asset.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_SERVICE_DESCRIPTOR, type AuthDescriptor, type DeviceAuthDescriptor,
|
||||||
|
type ServiceDescriptor
|
||||||
|
} from '../../domain/models/ServiceDescriptor'
|
||||||
|
import { HttpStatusError } from '../http/HttpTextClient'
|
||||||
|
import type { StoreHttpClient } from '../http/StoreHttpClient'
|
||||||
|
import { asRecord, readBoolean, readNumber, readOptionalString, readRecord } from '../json/JsonRecord'
|
||||||
|
|
||||||
|
const SERVICE_PATH = '/api/service'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /api/service`: what this catalog's server is, asked before anything else.
|
||||||
|
*
|
||||||
|
* A missing descriptor is an answer, not a failure. Every WarpEngine before 0.5 has no
|
||||||
|
* such endpoint, so a 404 means "an older engine" — a plain catalog with nothing gated
|
||||||
|
* and nobody to sign in as, which is exactly what this client assumed for its whole
|
||||||
|
* life before now. Same for a network that is simply down: the store still works
|
||||||
|
* offline from its cached catalog, and refusing to open because we could not ask the
|
||||||
|
* server about itself would be a worse client than the one we had.
|
||||||
|
*/
|
||||||
|
export class ServiceDescriptorClient {
|
||||||
|
public constructor (
|
||||||
|
private readonly http: StoreHttpClient,
|
||||||
|
private readonly baseUrl: string,
|
||||||
|
private readonly log: (line: string) => void
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async fetchDescriptor (): Promise<ServiceDescriptor> {
|
||||||
|
const url = `${this.baseUrl}${SERVICE_PATH}`
|
||||||
|
try {
|
||||||
|
const { json } = await this.http.requestJson(url)
|
||||||
|
const record = asRecord(json)
|
||||||
|
if (record === null) return DEFAULT_SERVICE_DESCRIPTOR
|
||||||
|
|
||||||
|
const descriptor: ServiceDescriptor = {
|
||||||
|
engineVersion: readOptionalString(record, 'version'),
|
||||||
|
catalogGated: readBoolean(readRecord(record, 'catalog') ?? {}, 'gated', false),
|
||||||
|
auth: readAuth(record, this.baseUrl)
|
||||||
|
}
|
||||||
|
this.log(describe(descriptor))
|
||||||
|
return descriptor
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof HttpStatusError && error.statusCode === 404) {
|
||||||
|
this.log('the catalog has no service descriptor — an engine older than 0.5')
|
||||||
|
} else {
|
||||||
|
this.log(`warning: could not read ${url} — carrying on as a plain catalog`)
|
||||||
|
}
|
||||||
|
return DEFAULT_SERVICE_DESCRIPTOR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAuth (record: Readonly<Record<string, unknown>>, baseUrl: string): AuthDescriptor | null {
|
||||||
|
const auth = readRecord(record, 'auth')
|
||||||
|
if (auth === null) return null
|
||||||
|
|
||||||
|
const device = readRecord(auth, 'device')
|
||||||
|
if (device === null) return null
|
||||||
|
|
||||||
|
const authorizeUrl = absolute(readOptionalString(device, 'authorizeUrl'), baseUrl)
|
||||||
|
const tokenUrl = absolute(readOptionalString(device, 'tokenUrl'), baseUrl)
|
||||||
|
const verificationUrl = absolute(readOptionalString(device, 'verificationUrl'), baseUrl)
|
||||||
|
// Two of the three are the flow itself and the third is where a person goes. Without
|
||||||
|
// all three there is no sign-in to offer, and half a flow is worse than none.
|
||||||
|
if (authorizeUrl === null || tokenUrl === null || verificationUrl === null) return null
|
||||||
|
|
||||||
|
const descriptor: DeviceAuthDescriptor = {
|
||||||
|
authorizeUrl,
|
||||||
|
tokenUrl,
|
||||||
|
revokeUrl: absolute(readOptionalString(device, 'revokeUrl'), baseUrl),
|
||||||
|
verificationUrl,
|
||||||
|
interval: Math.max(1, readNumber(device, 'interval', 5))
|
||||||
|
}
|
||||||
|
return { device: descriptor }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A server may answer with a path; it knows its own address better than we do. */
|
||||||
|
function absolute (value: string | null, baseUrl: string): string | null {
|
||||||
|
if (value === null || value.length === 0) return null
|
||||||
|
if (value.startsWith('http://') || value.startsWith('https://')) return value
|
||||||
|
return `${baseUrl.replace(/\/+$/, '')}/${value.replace(/^\/+/, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe (descriptor: ServiceDescriptor): string {
|
||||||
|
const version = descriptor.engineVersion ?? 'an unnamed version'
|
||||||
|
const gated = descriptor.catalogGated ? 'some titles need an entitlement' : 'nothing is gated'
|
||||||
|
const auth = descriptor.auth === null ? 'no sign-in' : 'sign-in available'
|
||||||
|
return `catalog served by WarpEngine ${version} — ${gated}, ${auth}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import type { HostSpecific } from '../../domain/models/HostMachine'
|
||||||
|
import {
|
||||||
|
APP_MODE, DEFAULT_STORE_CONFIGURATION, WEB_MODE,
|
||||||
|
type AssetSpecification, type BehaviorConfiguration, type CatalogConfiguration,
|
||||||
|
type InstallConfiguration, type PathsConfiguration, type PlatformConfiguration,
|
||||||
|
type StoreConfiguration, type StoreDescriptor
|
||||||
|
} from '../../domain/models/StoreConfiguration'
|
||||||
|
import {
|
||||||
|
asRecord, readBoolean, readNumber, readOptionalString, readRecord, readString,
|
||||||
|
readStringArray, type JsonRecord
|
||||||
|
} from '../json/JsonRecord'
|
||||||
|
import type { StoreFileSystem } from '../files/StoreFileSystem'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A store's `config.json`, read onto the defaults.
|
||||||
|
*
|
||||||
|
* The file is **snake_case** and this is the only place that knows it: that is the
|
||||||
|
* format the store repositories publish, and it stays the format on disk so a store
|
||||||
|
* config written for the shell engine is still a valid config here. Rename a field
|
||||||
|
* there and this reader is the single file that follows.
|
||||||
|
*
|
||||||
|
* Reading replaces the deep merge the shell engine did: every field states its own
|
||||||
|
* default, so a config that omits a section gets the whole section rather than a
|
||||||
|
* half-populated one.
|
||||||
|
*/
|
||||||
|
export class StoreConfigurationReader {
|
||||||
|
public constructor (private readonly files: StoreFileSystem) {}
|
||||||
|
|
||||||
|
public readConfiguration (configPath: string): StoreConfiguration {
|
||||||
|
const record = asRecord(this.files.readJson(configPath)) ?? {}
|
||||||
|
return {
|
||||||
|
store: readStore(readRecord(record, 'store') ?? {}),
|
||||||
|
paths: readPaths(readRecord(record, 'paths') ?? {}),
|
||||||
|
install: readInstall(readRecord(record, 'install') ?? {}),
|
||||||
|
catalog: readCatalog(readRecord(record, 'catalog') ?? {}),
|
||||||
|
platforms: readPlatforms(readRecord(record, 'platforms')),
|
||||||
|
behavior: readBehavior(readRecord(record, 'behavior') ?? {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStore (record: JsonRecord): StoreDescriptor {
|
||||||
|
const defaults = DEFAULT_STORE_CONFIGURATION.store
|
||||||
|
const api = readRecord(record, 'api') ?? {}
|
||||||
|
return {
|
||||||
|
id: readString(record, 'id', defaults.id),
|
||||||
|
name: readString(record, 'name', defaults.name),
|
||||||
|
// Trailing slashes are stripped once, here, so every URL built from it joins
|
||||||
|
// with exactly one separator.
|
||||||
|
baseUrl: readString(record, 'base_url', defaults.baseUrl).replace(/\/+$/, ''),
|
||||||
|
api: {
|
||||||
|
catalog: readString(api, 'catalog', defaults.api.catalog),
|
||||||
|
download: readString(api, 'download', defaults.api.download)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPaths (record: JsonRecord): PathsConfiguration {
|
||||||
|
const defaults = DEFAULT_STORE_CONFIGURATION.paths
|
||||||
|
return {
|
||||||
|
installRoot: readOptionalString(record, 'install_root'),
|
||||||
|
menuDirectory: readOptionalString(record, 'menu_dir'),
|
||||||
|
iconDirectory: readOptionalString(record, 'icon_dir'),
|
||||||
|
subfolder: readString(record, 'subfolder', defaults.subfolder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInstall (record: JsonRecord): InstallConfiguration {
|
||||||
|
const defaults = DEFAULT_STORE_CONFIGURATION.install
|
||||||
|
const modes = readStringArray(record, 'modes')
|
||||||
|
const specifications: Record<string, AssetSpecification> = {}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(record)) {
|
||||||
|
if (key === 'modes') continue
|
||||||
|
const specification = asRecord(value)
|
||||||
|
if (specification === null) continue
|
||||||
|
specifications[key] = {
|
||||||
|
kind: readAssetKind(specification['kind']),
|
||||||
|
extension: readHostSpecificString(specification['ext'])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const mode of [APP_MODE, WEB_MODE]) {
|
||||||
|
specifications[mode] ??= defaults.specifications[mode] ?? { kind: null, extension: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { modes: modes.length > 0 ? modes : defaults.modes, specifications }
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCatalog (record: JsonRecord): CatalogConfiguration {
|
||||||
|
const defaults = DEFAULT_STORE_CONFIGURATION.catalog
|
||||||
|
const statuses = readStringArray(record, 'statuses')
|
||||||
|
const ownerId = record['owner_id']
|
||||||
|
return {
|
||||||
|
// An explicit empty list means "every status", so only a missing key falls back.
|
||||||
|
statuses: record['statuses'] === undefined ? defaults.statuses : statuses,
|
||||||
|
ownerId: typeof ownerId === 'number' && Number.isFinite(ownerId) ? ownerId : null,
|
||||||
|
only: readStringArray(record, 'only'),
|
||||||
|
exclude: readStringArray(record, 'exclude')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPlatforms (record: JsonRecord | null): Readonly<Record<string, PlatformConfiguration>> {
|
||||||
|
if (record === null) return DEFAULT_STORE_CONFIGURATION.platforms
|
||||||
|
const platforms: Record<string, PlatformConfiguration> = {}
|
||||||
|
for (const [name, value] of Object.entries(record)) {
|
||||||
|
const entry = asRecord(value)
|
||||||
|
platforms[name] = { enabled: entry === null ? true : readBoolean(entry, 'enabled', true) }
|
||||||
|
}
|
||||||
|
return platforms
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBehavior (record: JsonRecord): BehaviorConfiguration {
|
||||||
|
const defaults = DEFAULT_STORE_CONFIGURATION.behavior
|
||||||
|
return {
|
||||||
|
prune: readBoolean(record, 'prune', defaults.prune),
|
||||||
|
timeout: readNumber(record, 'timeout', defaults.timeout),
|
||||||
|
insecure: readBoolean(record, 'insecure', defaults.insecure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `"linux_x64"`, `["win_x64", "win_x86"]`, or either of those keyed by host. */
|
||||||
|
function readAssetKind (value: unknown): HostSpecific<string | readonly string[]> | null {
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
if (Array.isArray(value)) return readStrings(value)
|
||||||
|
const record = asRecord(value)
|
||||||
|
if (record === null) return null
|
||||||
|
const map: Record<string, string | readonly string[]> = {}
|
||||||
|
for (const [key, entry] of Object.entries(record)) {
|
||||||
|
if (typeof entry === 'string') map[key] = entry
|
||||||
|
else if (Array.isArray(entry)) map[key] = readStrings(entry)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHostSpecificString (value: unknown): HostSpecific<string> | null {
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
const record = asRecord(value)
|
||||||
|
if (record === null) return null
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
for (const [key, entry] of Object.entries(record)) {
|
||||||
|
if (typeof entry === 'string') map[key] = entry
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStrings (values: readonly unknown[]): readonly string[] {
|
||||||
|
return values.filter((item: unknown): item is string => typeof item === 'string')
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import {
|
||||||
|
gameKey, recordScope, type InstalledRecord
|
||||||
|
} from '../../domain/models/InstalledRecord'
|
||||||
|
import {
|
||||||
|
asRecord, readOptionalString, readString, type JsonRecord
|
||||||
|
} from '../json/JsonRecord'
|
||||||
|
import type { StoreFileSystem } from '../files/StoreFileSystem'
|
||||||
|
|
||||||
|
/** Bumped when the on-disk shape changes. v1 keyed `installed` by bare software name. */
|
||||||
|
const STATE_VERSION = 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `state.json`: what this store put on this machine, and where.
|
||||||
|
*
|
||||||
|
* The file is **snake_case**, and deliberately so: it is the same file the shell
|
||||||
|
* store engine wrote, so a machine that installed games through the CLI keeps its
|
||||||
|
* library when the client takes over. This class is the only place that knows the
|
||||||
|
* on-disk field names — everything above it sees `InstalledRecord`.
|
||||||
|
*
|
||||||
|
* A `version: 1` file, keyed by bare software name, is re-keyed on first read.
|
||||||
|
*/
|
||||||
|
export class StoreStateRepository {
|
||||||
|
public constructor (
|
||||||
|
private readonly files: StoreFileSystem,
|
||||||
|
private readonly statePath: string,
|
||||||
|
private readonly log: (line: string) => void
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public readState (): Map<string, InstalledRecord> {
|
||||||
|
const state = asRecord(this.files.readJson(this.statePath))
|
||||||
|
if (state === null) return new Map<string, InstalledRecord>()
|
||||||
|
const installed = asRecord(state['installed'])
|
||||||
|
if (installed === null) return new Map<string, InstalledRecord>()
|
||||||
|
|
||||||
|
const version = state['version']
|
||||||
|
if (version === 1) return this.migrateFromVersionOne(installed)
|
||||||
|
if (version !== STATE_VERSION) return new Map<string, InstalledRecord>()
|
||||||
|
|
||||||
|
const records = new Map<string, InstalledRecord>()
|
||||||
|
for (const [key, value] of Object.entries(installed)) {
|
||||||
|
const record = asRecord(value)
|
||||||
|
if (record !== null) records.set(key, toRecord(record))
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
public writeState (installed: ReadonlyMap<string, InstalledRecord>): void {
|
||||||
|
const serialised: Record<string, JsonRecord> = {}
|
||||||
|
for (const [key, record] of installed) serialised[key] = fromRecord(record)
|
||||||
|
this.files.writeJson(this.statePath, { version: STATE_VERSION, installed: serialised })
|
||||||
|
}
|
||||||
|
|
||||||
|
private migrateFromVersionOne (installed: JsonRecord): Map<string, InstalledRecord> {
|
||||||
|
const records = new Map<string, InstalledRecord>()
|
||||||
|
for (const value of Object.values(installed)) {
|
||||||
|
const raw = asRecord(value)
|
||||||
|
if (raw === null) continue
|
||||||
|
const record = toRecord(raw)
|
||||||
|
if (record.name.length > 0 && record.scope.length > 0) records.set(gameKey(record), record)
|
||||||
|
}
|
||||||
|
this.log(`migrated ${String(records.size)} state entries to the per-scope key format`)
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRecord (raw: JsonRecord): InstalledRecord {
|
||||||
|
return {
|
||||||
|
name: readString(raw, 'name'),
|
||||||
|
// `system` is what the Batocera store wrote before the shared core existed, and
|
||||||
|
// there the two were the same string — so an installed machine needs no migration.
|
||||||
|
scope: recordScope(readOptionalString(raw, 'scope'), readOptionalString(raw, 'system')),
|
||||||
|
platform: readString(raw, 'platform'),
|
||||||
|
kind: readString(raw, 'kind'),
|
||||||
|
version: readString(raw, 'version'),
|
||||||
|
asset: readString(raw, 'asset'),
|
||||||
|
assetPath: readString(raw, 'asset_path'),
|
||||||
|
title: readString(raw, 'title'),
|
||||||
|
description: readString(raw, 'desc'),
|
||||||
|
author: readString(raw, 'author'),
|
||||||
|
imageUrl: readOptionalString(raw, 'image_url'),
|
||||||
|
createdAt: readOptionalString(raw, 'created_at'),
|
||||||
|
mode: readString(raw, 'mode'),
|
||||||
|
payload: readOptionalString(raw, 'payload'),
|
||||||
|
executable: readOptionalString(raw, 'exe'),
|
||||||
|
executableKind: readOptionalString(raw, 'exe_kind'),
|
||||||
|
icon: readOptionalString(raw, 'icon'),
|
||||||
|
menuEntry: readOptionalString(raw, 'menu'),
|
||||||
|
url: readOptionalString(raw, 'url')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromRecord (record: InstalledRecord): JsonRecord {
|
||||||
|
return {
|
||||||
|
name: record.name,
|
||||||
|
scope: record.scope,
|
||||||
|
platform: record.platform,
|
||||||
|
kind: record.kind,
|
||||||
|
version: record.version,
|
||||||
|
asset: record.asset,
|
||||||
|
asset_path: record.assetPath,
|
||||||
|
title: record.title,
|
||||||
|
desc: record.description,
|
||||||
|
author: record.author,
|
||||||
|
image_url: record.imageUrl,
|
||||||
|
created_at: record.createdAt,
|
||||||
|
mode: record.mode,
|
||||||
|
payload: record.payload,
|
||||||
|
exe: record.executable,
|
||||||
|
exe_kind: record.executableKind,
|
||||||
|
icon: record.icon,
|
||||||
|
menu: record.menuEntry,
|
||||||
|
url: record.url
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
readBoolean, readNumber, readOptionalString, readRecord, readString, type JsonRecord
|
||||||
|
} from '../../json/JsonRecord'
|
||||||
|
import type { CatalogAccess, CatalogPrice } from '../../../domain/models/CatalogAccess'
|
||||||
|
import { SoftwareListCatalogDialect } from './SoftwareListCatalogDialect'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalog as WarpEngine 0.5 serves it: the same entries, plus what they cost.
|
||||||
|
*
|
||||||
|
* 0.5 is the first engine that can say a title is not yours. Every entry carries an
|
||||||
|
* `access` block — even in a catalog that gates nothing, so that "this store is open"
|
||||||
|
* and "this store did not say" stay tellable apart. Everything else about the shape is
|
||||||
|
* unchanged, which is why this is the older dialect with one field added rather than a
|
||||||
|
* parser of its own.
|
||||||
|
*
|
||||||
|
* The words are the engine's, not any store's. A client reads more than one catalog,
|
||||||
|
* and a field named after what one shop calls its wares is a field that only works
|
||||||
|
* there.
|
||||||
|
*/
|
||||||
|
export class AccessAwareCatalogDialect extends SoftwareListCatalogDialect {
|
||||||
|
protected override readAccess (entry: JsonRecord): CatalogAccess | null {
|
||||||
|
const access = readRecord(entry, 'access')
|
||||||
|
// An entry with no block at all: possible from a 0.5 engine whose policy failed to
|
||||||
|
// answer. Reading it as "open" would be inventing the friendlier of two answers.
|
||||||
|
if (access === null) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
gated: readBoolean(access, 'gated', false),
|
||||||
|
entitled: readNullableBoolean(access, 'entitled'),
|
||||||
|
price: readPrice(access),
|
||||||
|
purchaseUrl: readOptionalString(access, 'purchaseUrl'),
|
||||||
|
webUrl: readOptionalString(access, 'webUrl')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three states, not two: yes, no, and nobody asked.
|
||||||
|
*
|
||||||
|
* A client that is not signed in gets null, and that is the case worth keeping
|
||||||
|
* separate — it is the difference between "you do not own this" and "there is no you",
|
||||||
|
* and only the second is a reason to offer signing in.
|
||||||
|
*/
|
||||||
|
function readNullableBoolean (record: JsonRecord, key: string): boolean | null {
|
||||||
|
const value = record[key]
|
||||||
|
return typeof value === 'boolean' ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A price with no currency is not a price anybody can be shown. */
|
||||||
|
function readPrice (access: JsonRecord): CatalogPrice | null {
|
||||||
|
const price = readRecord(access, 'price')
|
||||||
|
if (price === null) return null
|
||||||
|
|
||||||
|
const currency = readString(price, 'currency')
|
||||||
|
if (currency.length === 0) return null
|
||||||
|
return { amountCents: readNumber(price, 'amountCents'), currency }
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { CatalogAccess } from '../../../domain/models/CatalogAccess'
|
||||||
|
import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngineVersion'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How one WarpEngine version's catalog is shaped.
|
||||||
|
*
|
||||||
|
* The catalog is the one thing this client reads that it does not own the shape of, so
|
||||||
|
* it is the one thing an engine version can change under it. A dialect turns that
|
||||||
|
* foreign JSON into the typed records below, and everything downstream — the survey,
|
||||||
|
* the release choice — sees only those. When a future engine renames a field or nests a
|
||||||
|
* release differently, a new dialect is the whole change.
|
||||||
|
*/
|
||||||
|
export interface CatalogDialect {
|
||||||
|
readonly version: SupportedWarpEngineVersion
|
||||||
|
/** One entry per title in the catalog. */
|
||||||
|
listEntries: (catalog: unknown) => readonly CatalogEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogEntry {
|
||||||
|
readonly software: CatalogSoftware
|
||||||
|
/**
|
||||||
|
* What the catalog says about getting this title, or null where it says nothing.
|
||||||
|
*
|
||||||
|
* Null is not "free": it is an engine too old to have an opinion, and a store that
|
||||||
|
* never gated anything reads the same as one that could not say. Both mean the same
|
||||||
|
* thing in practice — try the download — but only one of them is worth offering a
|
||||||
|
* sign-in for.
|
||||||
|
*/
|
||||||
|
readonly access: CatalogAccess | null
|
||||||
|
/**
|
||||||
|
* The release the catalog itself calls newest-and-stable, or null when it names none.
|
||||||
|
*
|
||||||
|
* Kept separate from the candidates because it is also what an unavailable title's
|
||||||
|
* card shows a version from — and a catalog with releases but no `latestRelease` has
|
||||||
|
* nothing to show there.
|
||||||
|
*/
|
||||||
|
readonly latestRelease: CatalogRelease | null
|
||||||
|
/**
|
||||||
|
* Every release worth trying, newest first and deduplicated.
|
||||||
|
*
|
||||||
|
* `latestRelease` comes first where there is one, then the rest, so that a game whose
|
||||||
|
* newest build is missing an asset still installs from an older one.
|
||||||
|
*/
|
||||||
|
readonly releaseCandidates: readonly CatalogRelease[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogSoftware {
|
||||||
|
readonly name: string
|
||||||
|
readonly title: string
|
||||||
|
readonly platform: string
|
||||||
|
readonly status: string
|
||||||
|
readonly description: string
|
||||||
|
readonly author: string
|
||||||
|
readonly imageUrl: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogRelease {
|
||||||
|
readonly version: string
|
||||||
|
readonly createdAt: string | null
|
||||||
|
readonly assets: readonly CatalogAsset[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogAsset {
|
||||||
|
readonly kind: string
|
||||||
|
readonly path: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngineVersion'
|
||||||
|
import { AccessAwareCatalogDialect } from './AccessAwareCatalogDialect'
|
||||||
|
import type { CatalogDialect } from './CatalogDialect'
|
||||||
|
import { SoftwareListCatalogDialect } from './SoftwareListCatalogDialect'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which dialect reads a catalog served by which engine version.
|
||||||
|
*
|
||||||
|
* The switch is exhaustive over `SUPPORTED_WARP_ENGINE_VERSIONS`, which is the whole
|
||||||
|
* mechanism: adding a version to that list stops compiling here until somebody decides
|
||||||
|
* what it reads like. Three versions share one dialect because the catalog's shape did
|
||||||
|
* not change across them — and one class serving three versions is the honest way to
|
||||||
|
* say that, rather than three identical ones pretending otherwise.
|
||||||
|
*
|
||||||
|
* 0.5 gets its own, because that is the engine that started saying what a title costs.
|
||||||
|
*/
|
||||||
|
export function selectCatalogDialect (version: SupportedWarpEngineVersion): CatalogDialect {
|
||||||
|
switch (version) {
|
||||||
|
case '0.2':
|
||||||
|
case '0.3':
|
||||||
|
case '0.4':
|
||||||
|
return new SoftwareListCatalogDialect(version)
|
||||||
|
case '0.5':
|
||||||
|
return new AccessAwareCatalogDialect(version)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngineVersion'
|
||||||
|
import {
|
||||||
|
asRecord, readOptionalString, readRecord, readString, type JsonRecord
|
||||||
|
} from '../../json/JsonRecord'
|
||||||
|
import type { CatalogAccess } from '../../../domain/models/CatalogAccess'
|
||||||
|
import type {
|
||||||
|
CatalogAsset, CatalogDialect, CatalogEntry, CatalogRelease, CatalogSoftware
|
||||||
|
} from './CatalogDialect'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalog as every WarpEngine from 0.2 to 0.4 serves it.
|
||||||
|
*
|
||||||
|
* `{ softwares: [ { software: {…}, latestRelease: {…}, releases: [ { assets: [] } ] } ] }`,
|
||||||
|
* camelCase throughout. The three versions differ in what they *contain* — 0.3 added
|
||||||
|
* the `linux_arm64` asset kinds, so an older engine simply has fewer kinds to offer,
|
||||||
|
* and that needs no code: an absent kind is a title reported as unavailable on this
|
||||||
|
* machine, which is already the honest answer.
|
||||||
|
*
|
||||||
|
* The version is carried rather than assumed, so a log line can name which dialect read
|
||||||
|
* a catalog even while one class serves several.
|
||||||
|
*/
|
||||||
|
export class SoftwareListCatalogDialect implements CatalogDialect {
|
||||||
|
public constructor (public readonly version: SupportedWarpEngineVersion) {}
|
||||||
|
|
||||||
|
public listEntries (catalog: unknown): readonly CatalogEntry[] {
|
||||||
|
const record = asRecord(catalog)
|
||||||
|
if (record === null) return []
|
||||||
|
const entries = record['softwares']
|
||||||
|
if (!Array.isArray(entries)) return []
|
||||||
|
|
||||||
|
const found: CatalogEntry[] = []
|
||||||
|
for (const item of entries) {
|
||||||
|
const entry = asRecord(item)
|
||||||
|
if (entry === null) continue
|
||||||
|
const software = this.readSoftware(entry)
|
||||||
|
if (software === null) continue
|
||||||
|
found.push({
|
||||||
|
software,
|
||||||
|
access: this.readAccess(entry),
|
||||||
|
latestRelease: this.readLatestRelease(entry),
|
||||||
|
releaseCandidates: this.readCandidates(entry)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the catalog says about getting this title. Nothing, at these versions.
|
||||||
|
*
|
||||||
|
* An engine older than 0.5 has no opinion to report, and inventing one here would be
|
||||||
|
* worse than admitting it: "not gated" and "could not say" are different answers, and
|
||||||
|
* only the first is safe to act on. The subclass that can read it overrides this.
|
||||||
|
*/
|
||||||
|
protected readAccess (entry: JsonRecord): CatalogAccess | null {
|
||||||
|
void entry
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A title with no name is not a title: nothing could be keyed by it. */
|
||||||
|
protected readSoftware (entry: JsonRecord): CatalogSoftware | null {
|
||||||
|
const software = readRecord(entry, 'software')
|
||||||
|
if (software === null) return null
|
||||||
|
const name = readOptionalString(software, 'name')
|
||||||
|
if (name === null) return null
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
title: readOptionalString(software, 'title') ?? name,
|
||||||
|
platform: readString(software, 'platform'),
|
||||||
|
status: readString(software, 'status'),
|
||||||
|
description: readString(software, 'desc').trim(),
|
||||||
|
author: readString(software, 'author').trim(),
|
||||||
|
imageUrl: readOptionalString(software, 'imageUrl')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected readLatestRelease (entry: JsonRecord): CatalogRelease | null {
|
||||||
|
const latest = readRecord(entry, 'latestRelease')
|
||||||
|
return latest === null ? null : this.readRelease(latest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The releases to try, newest first and without repeats.
|
||||||
|
*
|
||||||
|
* `releases` arrives newest-first from the API and `latestRelease` is usually its
|
||||||
|
* first element, so identity is settled on the release's own id where it has one.
|
||||||
|
*/
|
||||||
|
protected readCandidates (entry: JsonRecord): readonly CatalogRelease[] {
|
||||||
|
const records: JsonRecord[] = []
|
||||||
|
const latest = readRecord(entry, 'latestRelease')
|
||||||
|
if (latest !== null) records.push(latest)
|
||||||
|
const releases = entry['releases']
|
||||||
|
if (Array.isArray(releases)) {
|
||||||
|
for (const item of releases) {
|
||||||
|
const record = asRecord(item)
|
||||||
|
if (record !== null) records.push(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const candidates: CatalogRelease[] = []
|
||||||
|
for (const record of records) {
|
||||||
|
const identity = JSON.stringify(record['id'] ?? null)
|
||||||
|
if (seen.has(identity)) continue
|
||||||
|
seen.add(identity)
|
||||||
|
candidates.push(this.readRelease(record))
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
protected readRelease (release: JsonRecord): CatalogRelease {
|
||||||
|
const assets: CatalogAsset[] = []
|
||||||
|
const listed = release['assets']
|
||||||
|
if (Array.isArray(listed)) {
|
||||||
|
for (const item of listed) {
|
||||||
|
const asset = asRecord(item)
|
||||||
|
if (asset === null) continue
|
||||||
|
assets.push({ kind: readString(asset, 'kind'), path: readString(asset, 'path') })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: readString(release, 'version'),
|
||||||
|
createdAt: readOptionalString(release, 'createdAt'),
|
||||||
|
assets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import type { InstalledRecord } from '../../../domain/models/InstalledRecord'
|
||||||
|
import { WEB_MODE } from '../../../domain/models/StoreConfiguration'
|
||||||
|
import type { StoreFileSystem } from '../../files/StoreFileSystem'
|
||||||
|
import { quoteForShell } from './ShellQuoting'
|
||||||
|
|
||||||
|
const LAUNCHER_MODE = 0o755
|
||||||
|
const COMMENT_LIMIT = 120
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linux: an XDG desktop entry.
|
||||||
|
*
|
||||||
|
* `Path=` is what gives the game its working directory — a Godot or LÖVE build
|
||||||
|
* looks for its `.pck` next to the binary, and started from anywhere else it exits
|
||||||
|
* without a window and without a message.
|
||||||
|
*/
|
||||||
|
export class DesktopEntryWriter {
|
||||||
|
public constructor (
|
||||||
|
private readonly storeId: string,
|
||||||
|
private readonly files: StoreFileSystem
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public write (record: InstalledRecord, entryPath: string, webUrl: string): string {
|
||||||
|
const lines: string[] = ['[Desktop Entry]', 'Type=Application', 'Version=1.0', `Name=${record.title}`]
|
||||||
|
|
||||||
|
if (record.description.length > 0) {
|
||||||
|
// One line only, and short: the menu shows it as a tooltip.
|
||||||
|
const firstLine = record.description.split('\n')[0] ?? ''
|
||||||
|
lines.push(`Comment=${firstLine.slice(0, COMMENT_LIMIT)}`)
|
||||||
|
}
|
||||||
|
if (record.mode === WEB_MODE) {
|
||||||
|
lines.push(`Exec=xdg-open ${quoteForShell(webUrl)}`)
|
||||||
|
} else if (record.executable !== null) {
|
||||||
|
lines.push(`Exec=${quoteForShell(record.executable)}`)
|
||||||
|
lines.push(`Path=${quoteForShell(path.dirname(record.executable))}`)
|
||||||
|
}
|
||||||
|
if (record.icon !== null) lines.push(`Icon=${record.icon}`)
|
||||||
|
lines.push('Terminal=false', 'Categories=Game;', `X-WarpStore=${this.storeId}`, '')
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(entryPath), { recursive: true })
|
||||||
|
this.files.writeAtomic(entryPath, Buffer.from(lines.join('\n'), 'utf8'), LAUNCHER_MODE)
|
||||||
|
return entryPath
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import path from 'node:path'
|
||||||
|
import type { DesktopLayout } from '../../../domain/models/DesktopLayout'
|
||||||
|
import { toSafeFileName } from '../../../domain/models/DesktopLayout'
|
||||||
|
import type { InstalledRecord } from '../../../domain/models/InstalledRecord'
|
||||||
|
import type { SelectedGame } from '../../../domain/models/SelectedGame'
|
||||||
|
import type { StoreConfiguration } from '../../../domain/models/StoreConfiguration'
|
||||||
|
import type { StoreFileSystem } from '../../files/StoreFileSystem'
|
||||||
|
import type { DesktopLayoutResolver } from '../DesktopLayoutResolver'
|
||||||
|
import { DesktopEntryWriter } from './DesktopEntryWriter'
|
||||||
|
import { MacBundleWriter } from './MacBundleWriter'
|
||||||
|
import { WindowsShortcutWriter } from './WindowsShortcutWriter'
|
||||||
|
|
||||||
|
const REFRESH_TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The menu entry, in whichever form this machine's desktop understands.
|
||||||
|
*
|
||||||
|
* This is the whole point of a desktop store, and the one place where the three
|
||||||
|
* hosts genuinely differ rather than merely differing in paths.
|
||||||
|
*/
|
||||||
|
export class LauncherWriter {
|
||||||
|
private readonly desktopEntries: DesktopEntryWriter
|
||||||
|
private readonly macBundles: MacBundleWriter
|
||||||
|
private readonly windowsShortcuts: WindowsShortcutWriter
|
||||||
|
|
||||||
|
public constructor (
|
||||||
|
private readonly configuration: StoreConfiguration,
|
||||||
|
private readonly layouts: DesktopLayoutResolver,
|
||||||
|
files: StoreFileSystem,
|
||||||
|
private readonly log: (line: string) => void
|
||||||
|
) {
|
||||||
|
this.desktopEntries = new DesktopEntryWriter(configuration.store.id, files)
|
||||||
|
this.macBundles = new MacBundleWriter(configuration.store.id, files, log)
|
||||||
|
this.windowsShortcuts = new WindowsShortcutWriter(files, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The published page of a browser build.
|
||||||
|
*
|
||||||
|
* The catalog serves these as a directory rather than an archive, so the entry is
|
||||||
|
* a link to it — which also means a web title needs the network.
|
||||||
|
*/
|
||||||
|
public webUrl (game: SelectedGame | InstalledRecord): string {
|
||||||
|
const assetPath = game.assetPath.length > 0 ? game.assetPath : `/file/${game.asset}`
|
||||||
|
return `${this.configuration.store.baseUrl}/${assetPath.replace(/^\/+|\/+$/g, '')}/`
|
||||||
|
}
|
||||||
|
|
||||||
|
public launcherPath (layout: DesktopLayout, game: SelectedGame | InstalledRecord): string {
|
||||||
|
const group = this.layouts.menuGroup(layout)
|
||||||
|
if (layout.operatingSystem === 'linux') {
|
||||||
|
return path.join(group, `${this.configuration.store.id}-${game.name}.desktop`)
|
||||||
|
}
|
||||||
|
if (layout.operatingSystem === 'darwin') {
|
||||||
|
return path.join(group, `${toSafeFileName(game.title)}.app`)
|
||||||
|
}
|
||||||
|
return path.join(group, `${toSafeFileName(game.title)}.lnk`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the menu entry. Returns the path actually written.
|
||||||
|
*
|
||||||
|
* Not always the path we intended: on Windows a `.lnk` can fall back to a `.cmd`,
|
||||||
|
* and the state has to record what really exists or an uninstall would leave it
|
||||||
|
* behind.
|
||||||
|
*/
|
||||||
|
public writeLauncher (layout: DesktopLayout, record: InstalledRecord): string {
|
||||||
|
const entryPath = this.launcherPath(layout, record)
|
||||||
|
const url = this.webUrl(record)
|
||||||
|
if (layout.operatingSystem === 'linux') return this.desktopEntries.write(record, entryPath, url)
|
||||||
|
if (layout.operatingSystem === 'darwin') return this.macBundles.write(record, entryPath, url)
|
||||||
|
return this.windowsShortcuts.write(record, entryPath, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ask the desktop to notice the change, where that is a thing we can do. */
|
||||||
|
public refreshMenu (layout: DesktopLayout): void {
|
||||||
|
if (layout.operatingSystem !== 'linux') return
|
||||||
|
try {
|
||||||
|
spawnSync('update-desktop-database', [layout.menuDirectory], { timeout: REFRESH_TIMEOUT_MS })
|
||||||
|
} catch {
|
||||||
|
// Not every Linux has it, and a menu that updates on next login is fine.
|
||||||
|
this.log('update-desktop-database is not available — the menu may need a re-login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import type { InstalledRecord } from '../../../domain/models/InstalledRecord'
|
||||||
|
import { WEB_MODE } from '../../../domain/models/StoreConfiguration'
|
||||||
|
import type { StoreFileSystem } from '../../files/StoreFileSystem'
|
||||||
|
import { escapeForXml, quoteForShell } from './ShellQuoting'
|
||||||
|
|
||||||
|
const RUNNER_MODE = 0o755
|
||||||
|
const PLIST_MODE = 0o644
|
||||||
|
const ICON_SIZE = '512'
|
||||||
|
const SIPS_TIMEOUT_MS = 60_000
|
||||||
|
const NAME_LIMIT = 255
|
||||||
|
|
||||||
|
/**
|
||||||
|
* macOS: link the archive's own bundle, or wrap a bare binary in one.
|
||||||
|
*
|
||||||
|
* A LÖVE or Godot build already ships a signed `.app`; copying it again would double
|
||||||
|
* the disk use and lose nothing but the icon, so it is symlinked instead. A TIC-80
|
||||||
|
* export is a bare executable, and for that a four-file bundle is what makes it
|
||||||
|
* double-clickable and Dock-able.
|
||||||
|
*/
|
||||||
|
export class MacBundleWriter {
|
||||||
|
public constructor (
|
||||||
|
private readonly storeId: string,
|
||||||
|
private readonly files: StoreFileSystem,
|
||||||
|
private readonly log: (line: string) => void
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public write (record: InstalledRecord, bundlePath: string, webUrl: string): string {
|
||||||
|
replaceExisting(bundlePath)
|
||||||
|
|
||||||
|
if (record.mode !== WEB_MODE && record.executableKind === 'bundle' && record.executable !== null) {
|
||||||
|
fs.mkdirSync(path.dirname(bundlePath), { recursive: true })
|
||||||
|
fs.symlinkSync(record.executable, bundlePath)
|
||||||
|
return bundlePath
|
||||||
|
}
|
||||||
|
|
||||||
|
const contents = path.join(bundlePath, 'Contents')
|
||||||
|
const macOsDirectory = path.join(contents, 'MacOS')
|
||||||
|
const resources = path.join(contents, 'Resources')
|
||||||
|
fs.mkdirSync(macOsDirectory, { recursive: true })
|
||||||
|
fs.mkdirSync(resources, { recursive: true })
|
||||||
|
|
||||||
|
this.files.writeAtomic(
|
||||||
|
path.join(macOsDirectory, 'run'),
|
||||||
|
Buffer.from(this.runnerScript(record, webUrl), 'utf8'),
|
||||||
|
RUNNER_MODE
|
||||||
|
)
|
||||||
|
|
||||||
|
const iconWritten = this.writeIcon(record.icon, path.join(resources, 'icon.icns'))
|
||||||
|
this.files.writeAtomic(
|
||||||
|
path.join(contents, 'Info.plist'),
|
||||||
|
Buffer.from(this.infoPlist(record, iconWritten), 'utf8'),
|
||||||
|
PLIST_MODE
|
||||||
|
)
|
||||||
|
return bundlePath
|
||||||
|
}
|
||||||
|
|
||||||
|
private runnerScript (record: InstalledRecord, webUrl: string): string {
|
||||||
|
if (record.mode === WEB_MODE || record.executable === null) {
|
||||||
|
return `#!/bin/sh\nexec open ${quoteForShell(webUrl)}\n`
|
||||||
|
}
|
||||||
|
const directory = path.dirname(record.executable)
|
||||||
|
const program = `./${path.basename(record.executable)}`
|
||||||
|
return `#!/bin/sh\ncd ${quoteForShell(directory)} || exit 1\nexec ${quoteForShell(program)} "$@"\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bundle's `Info.plist`.
|
||||||
|
*
|
||||||
|
* Keys are emitted in alphabetical order because that is what wrote these files
|
||||||
|
* before — `plistlib` sorts a dict — and a plist dict is unordered, so sorting costs
|
||||||
|
* nothing and makes a bundle regenerated by either engine the same file.
|
||||||
|
*/
|
||||||
|
private infoPlist (record: InstalledRecord, iconWritten: boolean): string {
|
||||||
|
const version = record.version.length > 0 ? record.version : '1.0'
|
||||||
|
const name = record.title.slice(0, NAME_LIMIT)
|
||||||
|
const entries: readonly (readonly [string, string])[] = [
|
||||||
|
['CFBundleName', `<string>${escapeForXml(name)}</string>`],
|
||||||
|
['CFBundleDisplayName', `<string>${escapeForXml(name)}</string>`],
|
||||||
|
['CFBundleExecutable', '<string>run</string>'],
|
||||||
|
['CFBundleIdentifier', `<string>org.${this.storeId}.store.${record.name}</string>`],
|
||||||
|
['CFBundleInfoDictionaryVersion', '<string>6.0</string>'],
|
||||||
|
['CFBundlePackageType', '<string>APPL</string>'],
|
||||||
|
['CFBundleShortVersionString', `<string>${escapeForXml(version)}</string>`],
|
||||||
|
['CFBundleVersion', `<string>${escapeForXml(version)}</string>`],
|
||||||
|
['NSHighResolutionCapable', '<true/>'],
|
||||||
|
...(iconWritten ? [['CFBundleIconFile', '<string>icon</string>'] as const] : [])
|
||||||
|
]
|
||||||
|
const body = [...entries]
|
||||||
|
.sort((left: readonly [string, string], right: readonly [string, string]): number =>
|
||||||
|
left[0] < right[0] ? -1 : 1)
|
||||||
|
.map(([key, value]: readonly [string, string]): string => `\t<key>${key}</key>\n\t${value}`)
|
||||||
|
.join('\n')
|
||||||
|
return [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
||||||
|
'<plist version="1.0">',
|
||||||
|
'<dict>',
|
||||||
|
body,
|
||||||
|
'</dict>',
|
||||||
|
'</plist>',
|
||||||
|
''
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Box art → `.icns` with `sips`, which needs a square source first.
|
||||||
|
*
|
||||||
|
* Without this a generated bundle gets the generic application icon. `sips` is part
|
||||||
|
* of macOS, so there is nothing to install; if it fails we simply go without.
|
||||||
|
*/
|
||||||
|
private writeIcon (iconPath: string | null, destination: string): boolean {
|
||||||
|
if (iconPath === null || !fs.existsSync(iconPath)) return false
|
||||||
|
const square = `${destination}.square.png`
|
||||||
|
try {
|
||||||
|
const steps: readonly (readonly string[])[] = [
|
||||||
|
['-z', ICON_SIZE, ICON_SIZE, iconPath, '--out', square],
|
||||||
|
['-s', 'format', 'icns', square, '--out', destination]
|
||||||
|
]
|
||||||
|
for (const step of steps) {
|
||||||
|
const result = spawnSync('sips', [...step], { timeout: SIPS_TIMEOUT_MS })
|
||||||
|
if (result.status !== 0) {
|
||||||
|
this.log('sips could not convert the box art — the bundle gets the generic icon')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fs.existsSync(destination)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(square, { force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bundle is a directory, so replacing one is not a plain overwrite. */
|
||||||
|
function replaceExisting (bundlePath: string): void {
|
||||||
|
let stats: fs.Stats | null = null
|
||||||
|
try {
|
||||||
|
stats = fs.lstatSync(bundlePath)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (stats.isDirectory() && !stats.isSymbolicLink()) fs.rmSync(bundlePath, { recursive: true, force: true })
|
||||||
|
else fs.rmSync(bundlePath, { force: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Quoting for the two shells a desktop launcher goes through.
|
||||||
|
*
|
||||||
|
* A game title is user data that ends up inside an `Exec=` line and a `/bin/sh`
|
||||||
|
* script, and titles contain apostrophes. These are the same rules Python's
|
||||||
|
* `shlex.quote` and a PowerShell single-quoted string follow.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SAFE_UNQUOTED = /^[A-Za-z0-9_@%+=:,./-]+$/
|
||||||
|
|
||||||
|
export function quoteForShell (value: string): string {
|
||||||
|
if (value.length === 0) return "''"
|
||||||
|
if (SAFE_UNQUOTED.test(value)) return value
|
||||||
|
return `'${value.replace(/'/g, "'\"'\"'")}'`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quoteForPowerShell (value: string): string {
|
||||||
|
return `'${value.replace(/'/g, "''")}'`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeForXml (value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import type { InstalledRecord } from '../../../domain/models/InstalledRecord'
|
||||||
|
import { WEB_MODE } from '../../../domain/models/StoreConfiguration'
|
||||||
|
import type { StoreFileSystem } from '../../files/StoreFileSystem'
|
||||||
|
import { quoteForPowerShell } from './ShellQuoting'
|
||||||
|
|
||||||
|
const POWERSHELL_TIMEOUT_MS = 60_000
|
||||||
|
const DESCRIPTION_LIMIT = 250
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows: a real `.lnk` through PowerShell, or a `.cmd` if that is missing.
|
||||||
|
*
|
||||||
|
* A `.lnk` is the only artifact that carries a working directory *and* shows up the
|
||||||
|
* way users expect, but it is a binary format with no writer in the standard library
|
||||||
|
* of any language here — PowerShell's `WScript.Shell` is the one tool every Windows
|
||||||
|
* has. When even that is unavailable a `.cmd` still appears in the Start menu, which
|
||||||
|
* is worth more than a correct file nobody can see.
|
||||||
|
*/
|
||||||
|
export class WindowsShortcutWriter {
|
||||||
|
public constructor (
|
||||||
|
private readonly files: StoreFileSystem,
|
||||||
|
private readonly log: (line: string) => void
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public write (record: InstalledRecord, shortcutPath: string, webUrl: string): string {
|
||||||
|
const target = record.mode === WEB_MODE ? webUrl : record.executable ?? ''
|
||||||
|
const workingDirectory = record.mode === WEB_MODE || record.executable === null
|
||||||
|
? ''
|
||||||
|
: path.dirname(record.executable)
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(shortcutPath), { recursive: true })
|
||||||
|
if (this.writeShortcut(record, shortcutPath, target, workingDirectory)) return shortcutPath
|
||||||
|
return this.writeCommandFile(record, shortcutPath, target, workingDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeShortcut (
|
||||||
|
record: InstalledRecord,
|
||||||
|
shortcutPath: string,
|
||||||
|
target: string,
|
||||||
|
workingDirectory: string
|
||||||
|
): boolean {
|
||||||
|
const script = [
|
||||||
|
`$s = (New-Object -ComObject WScript.Shell).CreateShortcut(${quoteForPowerShell(shortcutPath)});`,
|
||||||
|
`$s.TargetPath = ${quoteForPowerShell(target)};`,
|
||||||
|
workingDirectory.length > 0 ? `$s.WorkingDirectory = ${quoteForPowerShell(workingDirectory)};` : '',
|
||||||
|
`$s.Description = ${quoteForPowerShell(record.title.slice(0, DESCRIPTION_LIMIT))};`,
|
||||||
|
'$s.Save()'
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
||||||
|
timeout: POWERSHELL_TIMEOUT_MS
|
||||||
|
})
|
||||||
|
if (result.status === 0 && fs.existsSync(shortcutPath)) return true
|
||||||
|
this.log(`powershell could not write ${shortcutPath}`)
|
||||||
|
} catch {
|
||||||
|
this.log('powershell is not available — falling back to a .cmd launcher')
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeCommandFile (
|
||||||
|
record: InstalledRecord,
|
||||||
|
shortcutPath: string,
|
||||||
|
target: string,
|
||||||
|
workingDirectory: string
|
||||||
|
): string {
|
||||||
|
const commandPath = `${shortcutPath.slice(0, shortcutPath.length - path.extname(shortcutPath).length)}.cmd`
|
||||||
|
const body = record.mode === WEB_MODE || workingDirectory.length === 0
|
||||||
|
? `@echo off\r\nstart "" "${target}"\r\n`
|
||||||
|
: `@echo off\r\ncd /d "${workingDirectory}"\r\nstart "" "${target}"\r\n`
|
||||||
|
this.files.writeAtomic(commandPath, Buffer.from(body, 'utf8'))
|
||||||
|
this.log(`wrote a .cmd launcher instead of a .lnk: ${commandPath}`)
|
||||||
|
return commandPath
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mode an atomic write lands on when the caller names none.
|
||||||
|
*
|
||||||
|
* The shell engine's writes went through `mkstemp`, which creates at 0600, and its
|
||||||
|
* `state.json` and box art carry that mode on every machine this store has ever run
|
||||||
|
* on. Matching it keeps "the same files, in the same shape" literally true — and for
|
||||||
|
* a state file that records what is installed under someone's home directory, the
|
||||||
|
* more private of the two defaults is the better one anyway.
|
||||||
|
*/
|
||||||
|
const PRIVATE_FILE_MODE = 0o600
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The filesystem rules a store writes by.
|
||||||
|
*
|
||||||
|
* Three of them are safety rather than taste, and they are the reason this is one
|
||||||
|
* class instead of scattered `fs` calls:
|
||||||
|
*
|
||||||
|
* - **nothing is written in place.** A store interrupted mid-sync would otherwise
|
||||||
|
* leave a half-written menu entry, which is worse than an old one;
|
||||||
|
* - **every delete is guarded by `within`.** A store may only remove files from
|
||||||
|
* the subtree it owns, never from the user's own library;
|
||||||
|
* - **only empty directories are pruned.** One surprise file is enough to keep a
|
||||||
|
* directory.
|
||||||
|
*/
|
||||||
|
export class StoreFileSystem {
|
||||||
|
public constructor (
|
||||||
|
private readonly tag: string,
|
||||||
|
private readonly log: (line: string) => void
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public readJson (filePath: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (isMissingFile(error)) return null
|
||||||
|
this.log(`warning: cannot read ${filePath}: ${describe(error)}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public writeJson (filePath: string, data: unknown): void {
|
||||||
|
this.writeAtomic(filePath, Buffer.from(`${JSON.stringify(data, null, 2)}\n`, 'utf8'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write bytes via a temp file in the same directory, then rename over the target. */
|
||||||
|
public writeAtomic (filePath: string, blob: Buffer, mode: number = PRIVATE_FILE_MODE): string {
|
||||||
|
const directory = path.dirname(filePath)
|
||||||
|
fs.mkdirSync(directory, { recursive: true })
|
||||||
|
const temporary = path.join(directory, `.${this.tag}-${process.pid.toString(36)}-${counter()}.tmp`)
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(temporary, blob, { mode })
|
||||||
|
// The mode is set again: `writeFileSync` applies the umask to it, and a launcher
|
||||||
|
// that is not executable is not a launcher.
|
||||||
|
fs.chmodSync(temporary, mode)
|
||||||
|
fs.renameSync(temporary, filePath)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
fs.rmSync(temporary, { force: true })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a file, a symlink or a directory tree — but only inside `root`.
|
||||||
|
*
|
||||||
|
* Returns whether anything went. A symlink is unlinked rather than followed: on
|
||||||
|
* macOS a menu entry may be a link to the archive's own `.app`, and removing the
|
||||||
|
* store's entry must not touch what it points at.
|
||||||
|
*/
|
||||||
|
public removeWithin (target: string, root: string): boolean {
|
||||||
|
if (!within(target, root)) {
|
||||||
|
this.log(`warning: refusing to delete ${target} (outside ${root})`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const stats = statOrNull(target)
|
||||||
|
if (stats === null) return false
|
||||||
|
if (stats.isDirectory() && !stats.isSymbolicLink()) {
|
||||||
|
fs.rmSync(target, { recursive: true, force: true })
|
||||||
|
} else {
|
||||||
|
fs.rmSync(target, { force: true })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove those of `directories` that are now empty, deepest first.
|
||||||
|
*
|
||||||
|
* A store that has uninstalled everything should not leave its folders behind.
|
||||||
|
*/
|
||||||
|
public pruneEmptyDirectories (directories: readonly string[], root: string): void {
|
||||||
|
const ordered = [...new Set(directories.filter((entry: string): boolean => entry.length > 0)
|
||||||
|
.map((entry: string): string => path.resolve(entry)))]
|
||||||
|
.sort((left: string, right: string): number => depth(right) - depth(left))
|
||||||
|
|
||||||
|
for (const directory of ordered) {
|
||||||
|
if (!within(directory, root)) {
|
||||||
|
this.log(`warning: refusing to remove ${directory} (outside ${root})`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const stats = statOrNull(directory)
|
||||||
|
if (stats?.isDirectory() !== true) continue
|
||||||
|
if (fs.readdirSync(directory).length > 0) continue
|
||||||
|
try {
|
||||||
|
fs.rmdirSync(directory)
|
||||||
|
} catch {
|
||||||
|
// A directory that will not go is not a failure worth reporting: the next
|
||||||
|
// sync finds it again, and an uninstall has already done its real work.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public temporaryPath (directory: string, label: string, suffix: string): string {
|
||||||
|
return path.join(directory, `.${this.tag}-${label}${suffix}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `target` is `root` or sits inside it.
|
||||||
|
*
|
||||||
|
* Every delete a store performs is guarded by this.
|
||||||
|
*/
|
||||||
|
export function within (target: string, root: string): boolean {
|
||||||
|
if (target.length === 0 || root.length === 0) return false
|
||||||
|
const resolvedTarget = path.resolve(target)
|
||||||
|
const resolvedRoot = path.resolve(root)
|
||||||
|
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(resolvedRoot + path.sep)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `$XDG_DATA_HOME|~/.local/share/applications` or a plain `~`-path, resolved. */
|
||||||
|
export function expandPathSpecification (specification: string | null): string | null {
|
||||||
|
if (specification === null || specification.length === 0) return null
|
||||||
|
let remaining = specification
|
||||||
|
if (remaining.startsWith('$')) {
|
||||||
|
const [variable, fallback] = splitOnce(remaining.slice(1), '|')
|
||||||
|
// `$XDG_DATA_HOME|~/.local/share/applications` — the tail after the fallback's
|
||||||
|
// own prefix is what gets appended to the variable.
|
||||||
|
const [name, tail] = splitOnce(variable, '/')
|
||||||
|
const value = process.env[name]
|
||||||
|
if (value !== undefined && value.length > 0) {
|
||||||
|
const base = expandHome(value)
|
||||||
|
return path.resolve(tail.length > 0 ? path.join(base, tail) : base)
|
||||||
|
}
|
||||||
|
remaining = fallback
|
||||||
|
}
|
||||||
|
if (remaining.length === 0) return null
|
||||||
|
return path.resolve(expandVariables(expandHome(remaining)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandHome (value: string): string {
|
||||||
|
return value === '~' || value.startsWith(`~${path.sep}`) || value.startsWith('~/')
|
||||||
|
? path.join(os.homedir(), value.slice(2))
|
||||||
|
: value
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandVariables (value: string): string {
|
||||||
|
return value.replace(/\$(\w+)|\$\{(\w+)\}/g, (match: string, bare: string | undefined, braced: string | undefined): string =>
|
||||||
|
process.env[bare ?? braced ?? ''] ?? match)
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitOnce (value: string, separator: string): readonly [string, string] {
|
||||||
|
const index = value.indexOf(separator)
|
||||||
|
return index < 0 ? [value, ''] : [value.slice(0, index), value.slice(index + separator.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function statOrNull (target: string): fs.Stats | null {
|
||||||
|
try {
|
||||||
|
return fs.lstatSync(target)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function depth (value: string): number {
|
||||||
|
return value.split(path.sep).length
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingFile (error: unknown): boolean {
|
||||||
|
return typeof error === 'object' && error !== null &&
|
||||||
|
(error as { readonly code?: unknown }).code === 'ENOENT'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describe (error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
let sequence = 0
|
||||||
|
function counter (): string {
|
||||||
|
sequence += 1
|
||||||
|
return sequence.toString(36)
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import http from 'node:http'
|
||||||
|
import https from 'node:https'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { pipeline } from 'node:stream/promises'
|
||||||
|
import { HttpStatusError } from './HttpTextClient'
|
||||||
|
|
||||||
|
const MAX_REDIRECTS = 5
|
||||||
|
|
||||||
|
export interface HttpResponseBody {
|
||||||
|
readonly body: Buffer
|
||||||
|
readonly contentType: string
|
||||||
|
/** Lower-cased names, as Node delivers them. The engine version arrives in one. */
|
||||||
|
readonly headers: Readonly<Record<string, string>>
|
||||||
|
readonly statusCode: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreHttpOptions {
|
||||||
|
readonly userAgent: string
|
||||||
|
/** Seconds, as the store config states it. */
|
||||||
|
readonly timeout: number
|
||||||
|
readonly insecure: boolean
|
||||||
|
/**
|
||||||
|
* The bearer token to send, asked for per request.
|
||||||
|
*
|
||||||
|
* A function rather than a value because the token changes under a long-lived
|
||||||
|
* client — signing in and out do not rebuild it — and because there is no reason
|
||||||
|
* to hold the secret in a field that outlives the request that needs it.
|
||||||
|
*/
|
||||||
|
readonly bearerToken?: () => string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOptions {
|
||||||
|
readonly method?: string
|
||||||
|
readonly body?: string
|
||||||
|
readonly contentType?: string
|
||||||
|
/** Statuses to hand back rather than throw on. */
|
||||||
|
readonly accept?: readonly number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The store's own HTTP: bytes rather than text, and a streaming download.
|
||||||
|
*
|
||||||
|
* Separate from `HttpTextClient` because the two have different jobs — that one
|
||||||
|
* fetches a config file and wants a string, this one fetches a catalog and a
|
||||||
|
* multi-megabyte archive and must not hold the archive in memory. It also honours
|
||||||
|
* the store config's `timeout` and `insecure`, which are per-store settings rather
|
||||||
|
* than properties of this application.
|
||||||
|
*/
|
||||||
|
export class StoreHttpClient {
|
||||||
|
public constructor (private readonly options: StoreHttpOptions) {}
|
||||||
|
|
||||||
|
public async readBytes (url: string, request: RequestOptions = {}): Promise<HttpResponseBody> {
|
||||||
|
return await this.request(url, MAX_REDIRECTS, request, async (
|
||||||
|
response: http.IncomingMessage
|
||||||
|
): Promise<HttpResponseBody> => {
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for await (const chunk of response) chunks.push(Buffer.from(chunk as Buffer))
|
||||||
|
return {
|
||||||
|
body: Buffer.concat(chunks),
|
||||||
|
contentType: response.headers['content-type'] ?? '',
|
||||||
|
headers: readHeaders(response),
|
||||||
|
statusCode: response.statusCode ?? 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A JSON request and a JSON answer — the shape every auth endpoint speaks. */
|
||||||
|
public async requestJson (
|
||||||
|
url: string,
|
||||||
|
request: RequestOptions & { readonly payload?: unknown } = {}
|
||||||
|
): Promise<{ readonly json: unknown, readonly statusCode: number }> {
|
||||||
|
const { payload, ...rest } = request
|
||||||
|
const response = await this.readBytes(url, {
|
||||||
|
...rest,
|
||||||
|
...(payload === undefined
|
||||||
|
? {}
|
||||||
|
: { body: JSON.stringify(payload), contentType: 'application/json' })
|
||||||
|
})
|
||||||
|
const text = response.body.toString('utf8')
|
||||||
|
return {
|
||||||
|
json: text.trim().length === 0 ? null : JSON.parse(text),
|
||||||
|
statusCode: response.statusCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream `url` into `destination` atomically. Returns bytes written.
|
||||||
|
*
|
||||||
|
* A part file next to the target, then a rename: a download interrupted halfway
|
||||||
|
* must not look like a complete archive to the next sync.
|
||||||
|
*/
|
||||||
|
public async download (url: string, destination: string): Promise<number> {
|
||||||
|
const directory = path.dirname(destination)
|
||||||
|
fs.mkdirSync(directory, { recursive: true })
|
||||||
|
const partial = `${destination}.part`
|
||||||
|
let written = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.request(url, MAX_REDIRECTS, {}, async (response: http.IncomingMessage): Promise<void> => {
|
||||||
|
response.on('data', (chunk: Buffer): void => { written += chunk.length })
|
||||||
|
await pipeline(response, fs.createWriteStream(partial))
|
||||||
|
})
|
||||||
|
if (written === 0) throw new Error(`${url} returned an empty response`)
|
||||||
|
fs.renameSync(partial, destination)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
fs.rmSync(partial, { force: true })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return written
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<TResult> (
|
||||||
|
url: string,
|
||||||
|
redirectsLeft: number,
|
||||||
|
request: RequestOptions,
|
||||||
|
consume: (response: http.IncomingMessage) => Promise<TResult>,
|
||||||
|
origin: string = originOf(url)
|
||||||
|
): Promise<TResult> {
|
||||||
|
const response = await this.open(url, request, origin)
|
||||||
|
const status = response.statusCode ?? 0
|
||||||
|
const location = response.headers.location
|
||||||
|
|
||||||
|
if (status >= 300 && status < 400 && location !== undefined) {
|
||||||
|
response.resume()
|
||||||
|
if (redirectsLeft <= 0) throw new Error(`too many redirects for ${url}`)
|
||||||
|
const next = new URL(location, url).toString()
|
||||||
|
// The origin travels with the redirect chain, not with each hop: a gated
|
||||||
|
// download answers 302 to a signed storage URL, and *that* host must not be
|
||||||
|
// sent our bearer token. It is somebody else's server, and a presigned URL is
|
||||||
|
// refused outright by some object stores when an Authorization header rides
|
||||||
|
// along with the signature. A redirect back to the catalog keeps the token,
|
||||||
|
// because that is the server that issued it.
|
||||||
|
return await this.request(next, redirectsLeft - 1, redirectedRequest(request), consume, origin)
|
||||||
|
}
|
||||||
|
if (status !== 200 && !(request.accept ?? []).includes(status)) {
|
||||||
|
response.resume()
|
||||||
|
throw new HttpStatusError(url, status)
|
||||||
|
}
|
||||||
|
return await consume(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async open (
|
||||||
|
url: string,
|
||||||
|
request: RequestOptions,
|
||||||
|
origin: string
|
||||||
|
): Promise<http.IncomingMessage> {
|
||||||
|
return new Promise<http.IncomingMessage>((
|
||||||
|
resolve: (response: http.IncomingMessage) => void,
|
||||||
|
reject: (error: Error) => void
|
||||||
|
): void => {
|
||||||
|
const secure = !url.startsWith('http://')
|
||||||
|
const client = secure ? https : http
|
||||||
|
const outgoing = client.request(url, {
|
||||||
|
method: request.method ?? 'GET',
|
||||||
|
headers: this.buildHeaders(url, request, origin),
|
||||||
|
...(secure && this.options.insecure ? { rejectUnauthorized: false } : {})
|
||||||
|
}, resolve)
|
||||||
|
|
||||||
|
outgoing.setTimeout(Math.max(1, this.options.timeout) * 1000, (): void => {
|
||||||
|
outgoing.destroy(new Error(`${url} timed out`))
|
||||||
|
})
|
||||||
|
outgoing.on('error', reject)
|
||||||
|
if (request.body !== undefined) outgoing.write(request.body)
|
||||||
|
outgoing.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildHeaders (
|
||||||
|
url: string,
|
||||||
|
request: RequestOptions,
|
||||||
|
origin: string
|
||||||
|
): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = { 'User-Agent': this.options.userAgent }
|
||||||
|
if (request.contentType !== undefined) headers['Content-Type'] = request.contentType
|
||||||
|
if (request.body !== undefined) {
|
||||||
|
headers['Content-Length'] = String(Buffer.byteLength(request.body))
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = originOf(url) === origin ? this.options.bearerToken?.() ?? null : null
|
||||||
|
if (token !== null && token.length > 0) headers['Authorization'] = `Bearer ${token}`
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A redirect is followed as a GET without the body.
|
||||||
|
*
|
||||||
|
* That is what every client does with 301/302 after a POST, and what the servers
|
||||||
|
* answering them expect. `accept` travels on, because it describes what the caller
|
||||||
|
* is willing to read rather than anything about one hop.
|
||||||
|
*/
|
||||||
|
function redirectedRequest (request: RequestOptions): RequestOptions {
|
||||||
|
return request.accept === undefined ? {} : { accept: request.accept }
|
||||||
|
}
|
||||||
|
|
||||||
|
function originOf (url: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(url).origin
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The response headers as plain strings.
|
||||||
|
*
|
||||||
|
* Node gives a repeated header as an array; the ones this client reads are single-valued,
|
||||||
|
* and joining is a truer answer than picking the first.
|
||||||
|
*/
|
||||||
|
function readHeaders (response: http.IncomingMessage): Readonly<Record<string, string>> {
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
for (const [name, value] of Object.entries(response.headers)) {
|
||||||
|
if (value === undefined) continue
|
||||||
|
headers[name.toLowerCase()] = Array.isArray(value) ? value.join(', ') : value
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import type { Game, GameMode } from '../../domain/models/Game'
|
|
||||||
import {
|
|
||||||
readBoolean, readOptionalString, readString, type JsonRecord
|
|
||||||
} from '../json/JsonRecord'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One engine JSON entry to one domain model.
|
|
||||||
*
|
|
||||||
* The engine speaks snake_case and this is the only place that knows it: rename a
|
|
||||||
* field there and this mapper is the single file that follows.
|
|
||||||
*/
|
|
||||||
export class EngineGameMapper {
|
|
||||||
public toModel (record: JsonRecord): Game {
|
|
||||||
return {
|
|
||||||
name: readString(record, 'name'),
|
|
||||||
title: readString(record, 'title'),
|
|
||||||
platform: readString(record, 'platform'),
|
|
||||||
version: readString(record, 'version'),
|
|
||||||
mode: this.toMode(readString(record, 'mode')),
|
|
||||||
kind: readString(record, 'kind'),
|
|
||||||
description: readString(record, 'desc'),
|
|
||||||
author: readString(record, 'author'),
|
|
||||||
imagePath: readOptionalString(record, 'image_url'),
|
|
||||||
installed: readBoolean(record, 'installed'),
|
|
||||||
updateAvailable: readBoolean(record, 'update_available'),
|
|
||||||
installedVersion: readOptionalString(record, 'installed_version'),
|
|
||||||
menuEntryPath: readOptionalString(record, 'menu_entry'),
|
|
||||||
executablePath: readOptionalString(record, 'exe'),
|
|
||||||
hostedUrl: readOptionalString(record, 'url')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private toMode (value: string): GameMode {
|
|
||||||
return value === 'web' ? 'web' : 'app'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import type { StorePaths } from '../../domain/models/StorePaths'
|
|
||||||
import { readRecord, readString, type JsonRecord } from '../json/JsonRecord'
|
|
||||||
|
|
||||||
/** The engine's `paths` answer to one domain model. */
|
|
||||||
export class EngineStorePathsMapper {
|
|
||||||
public toModel (record: JsonRecord): StorePaths {
|
|
||||||
const store = readRecord(record, 'store')
|
|
||||||
return {
|
|
||||||
operatingSystem: readString(record, 'os'),
|
|
||||||
architecture: readString(record, 'arch'),
|
|
||||||
installRoot: readString(record, 'install_root'),
|
|
||||||
menuDirectory: readString(record, 'menu_dir'),
|
|
||||||
storeFolder: readString(record, 'store_folder'),
|
|
||||||
menuGroup: readString(record, 'menu_group'),
|
|
||||||
catalogBaseUrl: store === null ? '' : readString(store, 'base_url'),
|
|
||||||
storeName: store === null ? '' : readString(store, 'name'),
|
|
||||||
storeId: store === null ? '' : readString(store, 'id')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import { spawn, spawnSync } from 'node:child_process'
|
|
||||||
import { EngineInvocationError } from '../../domain/errors/EngineInvocationError'
|
|
||||||
import { PythonMissingError } from '../../domain/errors/PythonMissingError'
|
|
||||||
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
|
|
||||||
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
|
||||||
import type { PythonRuntimeLocator } from '../../domain/ports/PythonRuntimeLocator'
|
|
||||||
import type { SyncEventDto } from '../../shared/contracts/dto/SyncEventDto'
|
|
||||||
import { asRecord, type JsonRecord } from '../json/JsonRecord'
|
|
||||||
|
|
||||||
const VERSION_PROBE_TIMEOUT_MS = 15_000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs one engine command and reads its two streams.
|
|
||||||
*
|
|
||||||
* The engine's contract with any client is `--json`: data on stdout, one JSON
|
|
||||||
* object per line, and the human-readable log on stderr. So nothing here parses a
|
|
||||||
* sentence meant for a person, and a line that is not JSON is handed to the log
|
|
||||||
* rather than crashing the call.
|
|
||||||
*/
|
|
||||||
export class PythonEngineProcessRunner {
|
|
||||||
public constructor (private readonly runtimeLocator: PythonRuntimeLocator) {}
|
|
||||||
|
|
||||||
public async runCommand (
|
|
||||||
store: InstalledStore,
|
|
||||||
commandArguments: readonly string[],
|
|
||||||
progress: EngineProgressListener = {}
|
|
||||||
): Promise<readonly JsonRecord[]> {
|
|
||||||
const runtime = this.runtimeLocator.findRuntime()
|
|
||||||
if (runtime === null) throw new PythonMissingError()
|
|
||||||
|
|
||||||
const argv = [
|
|
||||||
...runtime.arguments,
|
|
||||||
store.scriptPath,
|
|
||||||
'--config', store.configPath,
|
|
||||||
'--json',
|
|
||||||
...commandArguments
|
|
||||||
]
|
|
||||||
|
|
||||||
return new Promise<readonly JsonRecord[]>((
|
|
||||||
resolve: (records: readonly JsonRecord[]) => void,
|
|
||||||
reject: (error: Error) => void
|
|
||||||
): void => {
|
|
||||||
const child = spawn(runtime.command, argv, {
|
|
||||||
env: { ...process.env, DESKTOP_STORE_HOME: store.home }
|
|
||||||
})
|
|
||||||
const records: JsonRecord[] = []
|
|
||||||
let stdoutRest = ''
|
|
||||||
let stderrRest = ''
|
|
||||||
|
|
||||||
const takeStdout = (chunk: string): void => {
|
|
||||||
stdoutRest += chunk
|
|
||||||
const parts = stdoutRest.split('\n')
|
|
||||||
stdoutRest = parts.pop() ?? ''
|
|
||||||
for (const part of parts) this.consumeStdoutLine(part, records, progress)
|
|
||||||
}
|
|
||||||
|
|
||||||
const takeStderr = (chunk: string): void => {
|
|
||||||
stderrRest += chunk
|
|
||||||
const parts = stderrRest.split('\n')
|
|
||||||
stderrRest = parts.pop() ?? ''
|
|
||||||
for (const part of parts) {
|
|
||||||
if (part.trim().length > 0) progress.onLog?.(part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
child.stdout.setEncoding('utf8')
|
|
||||||
child.stderr.setEncoding('utf8')
|
|
||||||
child.stdout.on('data', takeStdout)
|
|
||||||
child.stderr.on('data', takeStderr)
|
|
||||||
child.on('error', (error: Error): void => {
|
|
||||||
reject(new EngineInvocationError(error.message))
|
|
||||||
})
|
|
||||||
child.on('close', (code: number | null): void => {
|
|
||||||
takeStdout('\n')
|
|
||||||
takeStderr('\n')
|
|
||||||
if (code === 0) resolve(records)
|
|
||||||
else reject(new EngineInvocationError(`the store exited with code ${String(code)}`, code))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The engine's `--version`, read synchronously because it gates the first paint. */
|
|
||||||
public readVersionText (store: InstalledStore): string | null {
|
|
||||||
const runtime = this.runtimeLocator.findRuntime()
|
|
||||||
if (runtime === null) return null
|
|
||||||
try {
|
|
||||||
const probe = spawnSync(runtime.command, [...runtime.arguments, store.scriptPath, '--version'], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
timeout: VERSION_PROBE_TIMEOUT_MS
|
|
||||||
})
|
|
||||||
const text = `${probe.stdout}${probe.stderr}`.trim()
|
|
||||||
return probe.status === 0 && text.length > 0 ? text : null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private consumeStdoutLine (
|
|
||||||
line: string,
|
|
||||||
records: JsonRecord[],
|
|
||||||
progress: EngineProgressListener
|
|
||||||
): void {
|
|
||||||
if (line.trim().length === 0) return
|
|
||||||
let parsed: unknown
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(line)
|
|
||||||
} catch {
|
|
||||||
// Not ours to interpret — hand it on as a log line rather than fail the call.
|
|
||||||
progress.onLog?.(line)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const record = asRecord(parsed)
|
|
||||||
if (record === null) return
|
|
||||||
records.push(record)
|
|
||||||
const event = this.asSyncEvent(record)
|
|
||||||
if (event !== null) progress.onEvent?.(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A progress line, or null for the final result object.
|
|
||||||
*
|
|
||||||
* The engine tags its stream events with `event`; the listing and paths answers
|
|
||||||
* carry no such field, which is exactly the difference.
|
|
||||||
*/
|
|
||||||
private asSyncEvent (record: JsonRecord): SyncEventDto | null {
|
|
||||||
return typeof record['event'] === 'string' ? (record as unknown as SyncEventDto) : null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { spawnSync } from 'node:child_process'
|
|
||||||
import type { PythonRuntime } from '../../domain/models/PythonRuntime'
|
|
||||||
import type { PythonRuntimeLocator } from '../../domain/ports/PythonRuntimeLocator'
|
|
||||||
|
|
||||||
interface RuntimeCandidate {
|
|
||||||
readonly command: string
|
|
||||||
readonly arguments: readonly string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `py -3` is the Windows launcher, and often the only Python on PATH there. */
|
|
||||||
const WINDOWS_CANDIDATES: readonly RuntimeCandidate[] = [
|
|
||||||
{ command: 'py', arguments: ['-3'] },
|
|
||||||
{ command: 'python', arguments: [] },
|
|
||||||
{ command: 'python3', arguments: [] }
|
|
||||||
]
|
|
||||||
|
|
||||||
const POSIX_CANDIDATES: readonly RuntimeCandidate[] = [
|
|
||||||
{ command: 'python3', arguments: [] },
|
|
||||||
{ command: 'python', arguments: [] }
|
|
||||||
]
|
|
||||||
|
|
||||||
const PROBE_TIMEOUT_MS = 10_000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds the Python 3 the store needs.
|
|
||||||
*
|
|
||||||
* The answer is cached: the probe spawns processes, and the window asks for it on
|
|
||||||
* every state read.
|
|
||||||
*/
|
|
||||||
export class SystemPythonRuntimeLocator implements PythonRuntimeLocator {
|
|
||||||
private cached: PythonRuntime | null = null
|
|
||||||
private probed = false
|
|
||||||
|
|
||||||
public findRuntime (): PythonRuntime | null {
|
|
||||||
if (this.probed) return this.cached
|
|
||||||
this.probed = true
|
|
||||||
const candidates = process.platform === 'win32' ? WINDOWS_CANDIDATES : POSIX_CANDIDATES
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
const runtime = this.probeCandidate(candidate)
|
|
||||||
if (runtime !== null) {
|
|
||||||
this.cached = runtime
|
|
||||||
return runtime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private probeCandidate (candidate: RuntimeCandidate): PythonRuntime | null {
|
|
||||||
try {
|
|
||||||
const probe = spawnSync(candidate.command, [...candidate.arguments, '--version'], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
timeout: PROBE_TIMEOUT_MS
|
|
||||||
})
|
|
||||||
const output = `${probe.stdout}${probe.stderr}`
|
|
||||||
if (probe.status === 0 && output.includes('Python 3.')) {
|
|
||||||
return { command: candidate.command, arguments: candidate.arguments, version: output.trim() }
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// An absent interpreter is the normal case, not an error worth reporting.
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,11 +10,11 @@ const STORE_DIRECTORY_NAME = 'warp-engine-store'
|
|||||||
const CONFIG_FILE_NAME = 'config.json'
|
const CONFIG_FILE_NAME = 'config.json'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds stores where the shell installers put them.
|
* Finds stores where they were put.
|
||||||
*
|
*
|
||||||
* The roots are searched in the installers' own order, and `STORE_ROOT` comes
|
* The roots are searched in the shell installers' own order — those homes are still
|
||||||
* first so a sandbox can be driven without touching a working installation — which
|
* valid stores — and `STORE_ROOT` comes first so a sandbox can be driven without
|
||||||
* is how this repository is tested.
|
* touching a working installation, which is how this repository is tested.
|
||||||
*/
|
*/
|
||||||
export class FileSystemInstalledStoreRepository implements InstalledStoreRepository {
|
export class FileSystemInstalledStoreRepository implements InstalledStoreRepository {
|
||||||
public findAll (): readonly InstalledStore[] {
|
public findAll (): readonly InstalledStore[] {
|
||||||
@@ -69,20 +69,20 @@ export class FileSystemInstalledStoreRepository implements InstalledStoreReposit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A store home, recognised by its config and its directory name.
|
||||||
|
*
|
||||||
|
* The config is the only file that has to be there. It used to be the config *and*
|
||||||
|
* the engine script, and dropping that condition is what lets a home provisioned by
|
||||||
|
* an older client keep working after its scripts are cleared out.
|
||||||
|
*/
|
||||||
private readStoreAt (home: string, directoryName: string): InstalledStore | null {
|
private readStoreAt (home: string, directoryName: string): InstalledStore | null {
|
||||||
for (const engine of STORE_ENGINES) {
|
|
||||||
const scriptPath = path.join(home, engine.scriptFileName)
|
|
||||||
const configPath = path.join(home, CONFIG_FILE_NAME)
|
const configPath = path.join(home, CONFIG_FILE_NAME)
|
||||||
if (!fs.existsSync(scriptPath) || !fs.existsSync(configPath)) continue
|
if (!fs.existsSync(configPath)) return null
|
||||||
const id = directoryName.replace(engine.homeSuffix, '')
|
for (const engine of STORE_ENGINES) {
|
||||||
return {
|
if (!directoryName.endsWith(engine.homeSuffix)) continue
|
||||||
id,
|
const id = directoryName.slice(0, directoryName.length - engine.homeSuffix.length)
|
||||||
name: this.readStoreName(configPath, id),
|
return { id, name: this.readStoreName(configPath, id), home, configPath, engine: engine.id }
|
||||||
home,
|
|
||||||
scriptPath,
|
|
||||||
configPath,
|
|
||||||
engine: engine.id
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -90,9 +90,8 @@ export class FileSystemInstalledStoreRepository implements InstalledStoreReposit
|
|||||||
/**
|
/**
|
||||||
* The store's own name, from the config the installer wrote.
|
* The store's own name, from the config the installer wrote.
|
||||||
*
|
*
|
||||||
* Read here rather than asked of the engine: the switcher lists every store on
|
* Read here rather than asked of the engine: the switcher lists every store on the
|
||||||
* the machine, and starting a Python process per entry to learn its name would
|
* machine, and assembling an engine per entry to learn its name would be absurd.
|
||||||
* be absurd.
|
|
||||||
*/
|
*/
|
||||||
private readStoreName (configPath: string, fallback: string): string {
|
private readStoreName (configPath: string, fallback: string): string {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
import fs from 'node:fs'
|
|
||||||
import path from 'node:path'
|
|
||||||
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
|
|
||||||
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
|
||||||
import type { RegistryStore } from '../../domain/models/RegistryStore'
|
|
||||||
import { DESKTOP_STORE_ENGINE } from '../../domain/models/StoreEngine'
|
|
||||||
import { deriveStoreId } from '../../domain/models/StoreIdentity'
|
|
||||||
import type { StoreEngineInstaller } from '../../domain/ports/StoreEngineInstaller'
|
|
||||||
import { asRecord, readString } from '../json/JsonRecord'
|
|
||||||
import { HttpStatusError, type HttpTextClient } from '../http/HttpTextClient'
|
|
||||||
|
|
||||||
const CONFIG_FILE_NAME = 'config.json'
|
|
||||||
const SCRIPT_MODE = 0o755
|
|
||||||
const PYTHON_SHEBANG = '#!/usr/bin/env python3'
|
|
||||||
const DEFAULT_FORGE_BASE = 'https://git.teletypegames.org'
|
|
||||||
const DEFAULT_BRANCH = 'master'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads the engine, the shared core and a store config into a store home.
|
|
||||||
*
|
|
||||||
* The same three files, in the same folder, the shell installer would place — so
|
|
||||||
* the CLI and this client stay one installation, and running install.sh afterwards
|
|
||||||
* only adds the launcher script. No launcher is written here: the window is it.
|
|
||||||
*/
|
|
||||||
export class HttpStoreEngineInstaller implements StoreEngineInstaller {
|
|
||||||
private readonly forgeBase: string
|
|
||||||
|
|
||||||
public constructor (private readonly httpClient: HttpTextClient, forgeBase?: string) {
|
|
||||||
const configured = process.env['FORGE_BASE']
|
|
||||||
this.forgeBase = forgeBase ?? (configured !== undefined && configured.length > 0
|
|
||||||
? configured
|
|
||||||
: DEFAULT_FORGE_BASE)
|
|
||||||
}
|
|
||||||
|
|
||||||
public async installEngine (
|
|
||||||
home: string,
|
|
||||||
store: RegistryStore,
|
|
||||||
progress: EngineProgressListener = {}
|
|
||||||
): Promise<InstalledStore> {
|
|
||||||
fs.mkdirSync(home, { recursive: true })
|
|
||||||
|
|
||||||
for (const [fileName, url] of Object.entries(this.engineSources())) {
|
|
||||||
progress.onLog?.(`downloading ${fileName}`)
|
|
||||||
const body = await this.httpClient.readText(url)
|
|
||||||
if (!body.startsWith(PYTHON_SHEBANG)) {
|
|
||||||
throw new Error(`${fileName} does not look like the store engine — refusing to install it`)
|
|
||||||
}
|
|
||||||
fs.writeFileSync(path.join(home, fileName), body, { mode: SCRIPT_MODE })
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = await this.readStoreConfig(store, progress)
|
|
||||||
const configPath = path.join(home, CONFIG_FILE_NAME)
|
|
||||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
|
||||||
|
|
||||||
progress.onLog?.(`${store.name} is set up in ${home}`)
|
|
||||||
const configStore = asRecord(config['store'])
|
|
||||||
return {
|
|
||||||
id: configStore === null ? deriveStoreId(store) : readString(configStore, 'id', deriveStoreId(store)),
|
|
||||||
name: store.name,
|
|
||||||
home,
|
|
||||||
scriptPath: path.join(home, DESKTOP_STORE_ENGINE.scriptFileName),
|
|
||||||
configPath,
|
|
||||||
engine: DESKTOP_STORE_ENGINE.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Where the engine itself comes from: this client's own machinery, not the registry's. */
|
|
||||||
private engineSources (): Readonly<Record<string, string>> {
|
|
||||||
return {
|
|
||||||
[DESKTOP_STORE_ENGINE.scriptFileName]:
|
|
||||||
`${this.forgeBase}/stores/warp-engine-desktop-store/raw/branch/${DEFAULT_BRANCH}/${DESKTOP_STORE_ENGINE.scriptFileName}`,
|
|
||||||
'warpstore.py': `${this.forgeBase}/engines/warpstore/raw/branch/${DEFAULT_BRANCH}/warpstore.py`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The store's configuration.
|
|
||||||
*
|
|
||||||
* Three cases, and all of them install:
|
|
||||||
*
|
|
||||||
* - **a repository with a config.json** — that file is the authority on how the
|
|
||||||
* store behaves: which platforms it offers, which statuses it shows, where
|
|
||||||
* things land;
|
|
||||||
* - **a repository without one** (404) — the engine's defaults, as below;
|
|
||||||
* - **no repository at all** — the same defaults, without the round trip.
|
|
||||||
*
|
|
||||||
* The engine's built-in defaults already cover the host-to-asset mapping, the
|
|
||||||
* modes, the platforms and the behaviour, so what a store actually has to supply
|
|
||||||
* is identity: a slug, a name and a catalog. That is exactly what a registry
|
|
||||||
* record carries, which is why a store needs no repository of its own. The
|
|
||||||
* registry always wins on those three, whatever a config file says.
|
|
||||||
*/
|
|
||||||
private async readStoreConfig (
|
|
||||||
store: RegistryStore,
|
|
||||||
progress: EngineProgressListener
|
|
||||||
): Promise<Record<string, unknown>> {
|
|
||||||
const storeId = deriveStoreId(store)
|
|
||||||
const config = await this.readPublishedConfig(store, storeId, progress)
|
|
||||||
|
|
||||||
const existing = asRecord(config['store']) ?? {}
|
|
||||||
config['store'] = {
|
|
||||||
...existing,
|
|
||||||
id: readString(existing, 'id', storeId),
|
|
||||||
name: store.name,
|
|
||||||
base_url: store.catalogUrl
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
private async readPublishedConfig (
|
|
||||||
store: RegistryStore,
|
|
||||||
storeId: string,
|
|
||||||
progress: EngineProgressListener
|
|
||||||
): Promise<Record<string, unknown>> {
|
|
||||||
const repositoryUrl = store.storeRepositoryUrl
|
|
||||||
if (repositoryUrl === null) {
|
|
||||||
progress.onLog?.(`${store.name} has no store repository — using the engine defaults`)
|
|
||||||
return this.defaultConfig(storeId)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
progress.onLog?.(`reading the store config from ${repositoryUrl}`)
|
|
||||||
const body = await this.httpClient.readText(this.configUrl(repositoryUrl))
|
|
||||||
return { ...(asRecord(JSON.parse(body)) ?? {}) }
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!(error instanceof HttpStatusError) || error.statusCode !== 404) throw error
|
|
||||||
progress.onLog?.('no config.json in the store repository — using the engine defaults')
|
|
||||||
return this.defaultConfig(storeId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a store gets when nothing else says otherwise.
|
|
||||||
*
|
|
||||||
* Two fields, on top of the identity added by the caller. The subfolder keeps two
|
|
||||||
* stores on one machine out of each other's files, and it is the prune boundary,
|
|
||||||
* so it must be the store's own. Demo titles are listed because a catalog that
|
|
||||||
* publishes them means them to be played — the engine defaults to released and
|
|
||||||
* archived only, which is the safer default for a store nobody configured.
|
|
||||||
*/
|
|
||||||
private defaultConfig (storeId: string): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
paths: { subfolder: storeId },
|
|
||||||
catalog: { statuses: ['released', 'archived', 'demo'] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private configUrl (repositoryUrl: string, branch: string = DEFAULT_BRANCH): string {
|
|
||||||
return `${repositoryUrl.replace(/\/+$/, '')}/raw/branch/${branch}/${CONFIG_FILE_NAME}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ import { RegistryUnavailableError } from '../../domain/errors/RegistryUnavailabl
|
|||||||
import type { RegistryStore } from '../../domain/models/RegistryStore'
|
import type { RegistryStore } from '../../domain/models/RegistryStore'
|
||||||
import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository'
|
import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository'
|
||||||
import { asRecord, readString, type JsonRecord } from '../json/JsonRecord'
|
import { asRecord, readString, type JsonRecord } from '../json/JsonRecord'
|
||||||
|
import { BuildConfiguration } from '../config/BuildConfiguration'
|
||||||
import type { HttpTextClient } from '../http/HttpTextClient'
|
import type { HttpTextClient } from '../http/HttpTextClient'
|
||||||
|
|
||||||
const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores'
|
const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores'
|
||||||
@@ -9,22 +10,29 @@ const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores'
|
|||||||
/**
|
/**
|
||||||
* The registry: `GET /api/stores` on the site.
|
* The registry: `GET /api/stores` on the site.
|
||||||
*
|
*
|
||||||
* The one address this client knows, and even that is overridable — `STORES_API`
|
* The one address this client knows, and it is decided in three places, most specific
|
||||||
* points it at another site or at a local endpoint.
|
* first: a runtime `STORES_API` (for trying something out), the `warpEngine.registryUrl`
|
||||||
|
* field a build was packaged with (for shipping a client for another site), and finally
|
||||||
|
* the address of ours.
|
||||||
*
|
*
|
||||||
* A record needs a name and a catalog URL; those two make a store. The repository
|
* A name and a catalog URL make a store, and are all a record carries. Anything else it
|
||||||
* is optional and arrives as null when absent — a store configured by nothing but
|
* happens to say is ignored: how a store behaves is this client's own business, decided
|
||||||
* this record installs on the engine's defaults. Records missing either of the two
|
* by the engine it ships with. Records missing either field are dropped rather than
|
||||||
* required fields are dropped rather than half-used.
|
* half-used.
|
||||||
*/
|
*/
|
||||||
export class HttpStoreRegistryRepository implements StoreRegistryRepository {
|
export class HttpStoreRegistryRepository implements StoreRegistryRepository {
|
||||||
public readonly sourceUrl: string
|
public readonly sourceUrl: string
|
||||||
|
|
||||||
public constructor (private readonly httpClient: HttpTextClient, sourceUrl?: string) {
|
public constructor (
|
||||||
const configured = process.env['STORES_API']
|
private readonly httpClient: HttpTextClient,
|
||||||
this.sourceUrl = sourceUrl ?? (configured !== undefined && configured.length > 0
|
sourceUrl?: string,
|
||||||
? configured
|
buildConfiguration: BuildConfiguration = new BuildConfiguration()
|
||||||
: DEFAULT_REGISTRY_URL)
|
) {
|
||||||
|
const fromEnvironment = process.env['STORES_API']
|
||||||
|
this.sourceUrl = sourceUrl
|
||||||
|
?? (fromEnvironment !== undefined && fromEnvironment.length > 0 ? fromEnvironment : null)
|
||||||
|
?? buildConfiguration.readRegistryUrl()
|
||||||
|
?? DEFAULT_REGISTRY_URL
|
||||||
}
|
}
|
||||||
|
|
||||||
public async listStores (): Promise<readonly RegistryStore[]> {
|
public async listStores (): Promise<readonly RegistryStore[]> {
|
||||||
@@ -36,18 +44,12 @@ export class HttpStoreRegistryRepository implements StoreRegistryRepository {
|
|||||||
return parsed
|
return parsed
|
||||||
.map((row: unknown): JsonRecord | null => asRecord(row))
|
.map((row: unknown): JsonRecord | null => asRecord(row))
|
||||||
.filter((row: JsonRecord | null): row is JsonRecord => row !== null)
|
.filter((row: JsonRecord | null): row is JsonRecord => row !== null)
|
||||||
.map((row: JsonRecord): RegistryStore => {
|
|
||||||
// Both spellings, because a registry is someone else's API: ours answers
|
// Both spellings, because a registry is someone else's API: ours answers
|
||||||
// camelCase, and a hand-rolled one may not.
|
// camelCase, and a hand-rolled one may not.
|
||||||
const repository = (
|
.map((row: JsonRecord): RegistryStore => ({
|
||||||
readString(row, 'storeRepositoryUrl') || readString(row, 'store_repository_url')
|
|
||||||
).trim()
|
|
||||||
return {
|
|
||||||
name: readString(row, 'name').trim(),
|
name: readString(row, 'name').trim(),
|
||||||
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim(),
|
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim()
|
||||||
storeRepositoryUrl: repository.length > 0 ? repository : null
|
}))
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((store: RegistryStore): boolean =>
|
.filter((store: RegistryStore): boolean =>
|
||||||
store.name.length > 0 && store.catalogUrl.length > 0)
|
store.name.length > 0 && store.catalogUrl.length > 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
|
||||||
|
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
||||||
|
import type { RegistryStore } from '../../domain/models/RegistryStore'
|
||||||
|
import { DESKTOP_STORE_ENGINE } from '../../domain/models/StoreEngine'
|
||||||
|
import { deriveStoreId } from '../../domain/models/StoreIdentity'
|
||||||
|
import type { StoreEngineInstaller } from '../../domain/ports/StoreEngineInstaller'
|
||||||
|
|
||||||
|
const CONFIG_FILE_NAME = 'config.json'
|
||||||
|
|
||||||
|
/** What an install used to leave in a store home, back when the engine was a script. */
|
||||||
|
const RETIRED_ENGINE_FILES: readonly string[] = ['desktop_store.py', 'warpstore.py']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setting up a store where there is none.
|
||||||
|
*
|
||||||
|
* Nothing is downloaded and nothing is asked of a server. The engine ships in this
|
||||||
|
* application and its defaults already cover the host-to-asset mapping, the install
|
||||||
|
* modes, the platforms and the behaviour; what a registry record adds is identity — a
|
||||||
|
* name, a catalog and a slug — and that is what gets written.
|
||||||
|
*
|
||||||
|
* The config is written to disk rather than kept in memory because it is the store's
|
||||||
|
* own record of itself: `StoreConfigurationReader` reads it on every operation, an
|
||||||
|
* existing store home is recognised by it, and a person can look at it.
|
||||||
|
*/
|
||||||
|
export class NativeStoreEngineInstaller implements StoreEngineInstaller {
|
||||||
|
public installEngine (
|
||||||
|
home: string,
|
||||||
|
store: RegistryStore,
|
||||||
|
progress: EngineProgressListener = {}
|
||||||
|
): Promise<InstalledStore> {
|
||||||
|
fs.mkdirSync(home, { recursive: true })
|
||||||
|
|
||||||
|
const storeId = deriveStoreId(store)
|
||||||
|
const configPath = path.join(home, CONFIG_FILE_NAME)
|
||||||
|
fs.writeFileSync(configPath, `${JSON.stringify(this.buildConfig(store, storeId), null, 2)}\n`)
|
||||||
|
this.removeRetiredEngine(home, progress)
|
||||||
|
|
||||||
|
progress.onLog?.(`${store.name} is set up in ${home}`)
|
||||||
|
return Promise.resolve({
|
||||||
|
id: storeId,
|
||||||
|
name: store.name,
|
||||||
|
home,
|
||||||
|
configPath,
|
||||||
|
engine: DESKTOP_STORE_ENGINE.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The store's configuration: its identity, and the two things worth stating.
|
||||||
|
*
|
||||||
|
* Everything absent from this falls to the engine's defaults, which is most of it. The
|
||||||
|
* subfolder is named after the store so two stores on one machine cannot reach into
|
||||||
|
* each other's files — it is the prune boundary, so it has to be the store's own.
|
||||||
|
* Demo titles are listed because a catalog that publishes them means them to be
|
||||||
|
* played; the engine defaults to released and archived only, which is the safer
|
||||||
|
* default for a store nobody configured.
|
||||||
|
*/
|
||||||
|
private buildConfig (store: RegistryStore, storeId: string): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
store: { id: storeId, name: store.name, base_url: store.catalogUrl },
|
||||||
|
paths: { subfolder: storeId },
|
||||||
|
catalog: { statuses: ['released', 'archived', 'demo'] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear out the scripts an older client downloaded here.
|
||||||
|
*
|
||||||
|
* A store home provisioned by 1.5.0 or by a shell installer holds two Python files
|
||||||
|
* that nothing reads any more. They are harmless, but a directory that still looks
|
||||||
|
* like it holds the engine invites someone to run it against a state file this
|
||||||
|
* application is also writing.
|
||||||
|
*/
|
||||||
|
private removeRetiredEngine (home: string, progress: EngineProgressListener): void {
|
||||||
|
for (const fileName of RETIRED_ENGINE_FILES) {
|
||||||
|
const filePath = path.join(home, fileName)
|
||||||
|
if (!fs.existsSync(filePath)) continue
|
||||||
|
fs.rmSync(filePath, { force: true })
|
||||||
|
progress.onLog?.(`removed the retired ${fileName}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import type { CatalogListing } from '../../domain/models/CatalogListing'
|
|
||||||
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
|
|
||||||
import {
|
|
||||||
MINIMUM_ENGINE_VERSION, isAtLeast, parseVersionNumbers, type EngineVersion
|
|
||||||
} from '../../domain/models/EngineVersion'
|
|
||||||
import type { Game } from '../../domain/models/Game'
|
|
||||||
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
|
||||||
import type { StorePaths } from '../../domain/models/StorePaths'
|
|
||||||
import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
|
|
||||||
import { readRecord, readRecordArray, readStringArray, type JsonRecord } from '../json/JsonRecord'
|
|
||||||
import { EngineGameMapper } from '../mappers/EngineGameMapper'
|
|
||||||
import { EngineStorePathsMapper } from '../mappers/EngineStorePathsMapper'
|
|
||||||
import type { PythonEngineProcessRunner } from '../process/PythonEngineProcessRunner'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The store engine, driven as a child process.
|
|
||||||
*
|
|
||||||
* The only adapter that knows the CLI exists. Everything above it sees the port.
|
|
||||||
*/
|
|
||||||
export class PythonStoreCatalogGateway implements StoreCatalogGateway {
|
|
||||||
public constructor (
|
|
||||||
private readonly runner: PythonEngineProcessRunner,
|
|
||||||
private readonly gameMapper: EngineGameMapper = new EngineGameMapper(),
|
|
||||||
private readonly pathsMapper: EngineStorePathsMapper = new EngineStorePathsMapper()
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public async listGames (
|
|
||||||
store: InstalledStore,
|
|
||||||
progress?: EngineProgressListener
|
|
||||||
): Promise<CatalogListing> {
|
|
||||||
const records = await this.runner.runCommand(store, ['list'], progress)
|
|
||||||
const answer = this.lastRecord(records)
|
|
||||||
if (answer === null) return { games: [], skipped: [], paths: null }
|
|
||||||
const games: readonly Game[] = readRecordArray(answer, 'games')
|
|
||||||
.map((record: JsonRecord): Game => this.gameMapper.toModel(record))
|
|
||||||
const paths = readRecord(answer, 'paths')
|
|
||||||
return {
|
|
||||||
games,
|
|
||||||
skipped: readStringArray(answer, 'skipped'),
|
|
||||||
paths: paths === null ? null : this.pathsMapper.toModel(paths)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async readPaths (
|
|
||||||
store: InstalledStore,
|
|
||||||
progress?: EngineProgressListener
|
|
||||||
): Promise<StorePaths> {
|
|
||||||
const records = await this.runner.runCommand(store, ['paths'], progress)
|
|
||||||
const answer = this.lastRecord(records)
|
|
||||||
return this.pathsMapper.toModel(answer ?? {})
|
|
||||||
}
|
|
||||||
|
|
||||||
public async syncGames (
|
|
||||||
store: InstalledStore,
|
|
||||||
names: readonly string[],
|
|
||||||
progress?: EngineProgressListener
|
|
||||||
): Promise<void> {
|
|
||||||
await this.runner.runCommand(store, ['sync', ...names], progress)
|
|
||||||
}
|
|
||||||
|
|
||||||
public async removeGame (
|
|
||||||
store: InstalledStore,
|
|
||||||
name: string,
|
|
||||||
progress?: EngineProgressListener
|
|
||||||
): Promise<void> {
|
|
||||||
await this.runner.runCommand(store, ['remove', name], progress)
|
|
||||||
}
|
|
||||||
|
|
||||||
public readEngineVersion (store: InstalledStore): EngineVersion | null {
|
|
||||||
const text = this.runner.readVersionText(store)
|
|
||||||
if (text === null) return null
|
|
||||||
const numbers = parseVersionNumbers(text)
|
|
||||||
return { text, numbers, supported: isAtLeast(numbers, MINIMUM_ENGINE_VERSION) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The result object is the last line: the stream events come first, and both
|
|
||||||
* arrive on the same pipe.
|
|
||||||
*/
|
|
||||||
private lastRecord (records: readonly JsonRecord[]): JsonRecord | null {
|
|
||||||
for (let index = records.length - 1; index >= 0; index -= 1) {
|
|
||||||
const record = records[index]
|
|
||||||
if (record !== undefined && typeof record['event'] !== 'string') return record
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import { SelfTestRunner } from './diagnostics/SelfTestRunner'
|
|||||||
|
|
||||||
const SELFTEST_FLAG = '--selftest'
|
const SELFTEST_FLAG = '--selftest'
|
||||||
const SELFTEST_USER_DATA_DIRECTORY = 'warpstore-gui-selftest'
|
const SELFTEST_USER_DATA_DIRECTORY = 'warpstore-gui-selftest'
|
||||||
const PRODUCT_NAME = 'WarpEngine Store'
|
const PRODUCT_NAME = 'WarpEngine Client'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The application's lifecycle.
|
* The application's lifecycle.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const WINDOW_OPTIONS: BrowserWindowConstructorOptions = {
|
|||||||
minWidth: 760,
|
minWidth: 760,
|
||||||
minHeight: 520,
|
minHeight: 520,
|
||||||
backgroundColor: '#11151c',
|
backgroundColor: '#11151c',
|
||||||
title: 'WarpEngine Store'
|
title: 'WarpEngine Client'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { App, IpcMain, Shell } from 'electron'
|
import type { App, IpcMain, Shell } from 'electron'
|
||||||
|
import { AccountService } from '../../application/services/AccountService'
|
||||||
import { ApplicationStateService } from '../../application/services/ApplicationStateService'
|
import { ApplicationStateService } from '../../application/services/ApplicationStateService'
|
||||||
import { CatalogService } from '../../application/services/CatalogService'
|
import { CatalogService } from '../../application/services/CatalogService'
|
||||||
import { GameLaunchService } from '../../application/services/GameLaunchService'
|
import { GameLaunchService } from '../../application/services/GameLaunchService'
|
||||||
@@ -7,14 +8,14 @@ import { StoreProvisioningService } from '../../application/services/StoreProvis
|
|||||||
import { StoreSelectionService } from '../../application/services/StoreSelectionService'
|
import { StoreSelectionService } from '../../application/services/StoreSelectionService'
|
||||||
import { ElectronApplicationEnvironment } from '../../infrastructure/electron/ElectronApplicationEnvironment'
|
import { ElectronApplicationEnvironment } from '../../infrastructure/electron/ElectronApplicationEnvironment'
|
||||||
import { ElectronGameLauncher } from '../../infrastructure/electron/ElectronGameLauncher'
|
import { ElectronGameLauncher } from '../../infrastructure/electron/ElectronGameLauncher'
|
||||||
|
import { SafeStorageCredentialRepository } from '../../infrastructure/electron/SafeStorageCredentialRepository'
|
||||||
|
import { NativeStoreCatalogGateway } from '../../infrastructure/engine/NativeStoreCatalogGateway'
|
||||||
import { HttpTextClient } from '../../infrastructure/http/HttpTextClient'
|
import { HttpTextClient } from '../../infrastructure/http/HttpTextClient'
|
||||||
import { PythonEngineProcessRunner } from '../../infrastructure/process/PythonEngineProcessRunner'
|
|
||||||
import { SystemPythonRuntimeLocator } from '../../infrastructure/process/SystemPythonRuntimeLocator'
|
|
||||||
import { FileSystemInstalledStoreRepository } from '../../infrastructure/repositories/FileSystemInstalledStoreRepository'
|
import { FileSystemInstalledStoreRepository } from '../../infrastructure/repositories/FileSystemInstalledStoreRepository'
|
||||||
import { HttpStoreEngineInstaller } from '../../infrastructure/repositories/HttpStoreEngineInstaller'
|
import { NativeStoreEngineInstaller } from '../../infrastructure/repositories/NativeStoreEngineInstaller'
|
||||||
import { HttpStoreRegistryRepository } from '../../infrastructure/repositories/HttpStoreRegistryRepository'
|
import { HttpStoreRegistryRepository } from '../../infrastructure/repositories/HttpStoreRegistryRepository'
|
||||||
import { JsonFilePreferencesRepository } from '../../infrastructure/repositories/JsonFilePreferencesRepository'
|
import { JsonFilePreferencesRepository } from '../../infrastructure/repositories/JsonFilePreferencesRepository'
|
||||||
import { PythonStoreCatalogGateway } from '../../infrastructure/repositories/PythonStoreCatalogGateway'
|
import { AccountIpcController } from '../ipc/AccountIpcController'
|
||||||
import { AppIpcController } from '../ipc/AppIpcController'
|
import { AppIpcController } from '../ipc/AppIpcController'
|
||||||
import { CatalogIpcController } from '../ipc/CatalogIpcController'
|
import { CatalogIpcController } from '../ipc/CatalogIpcController'
|
||||||
import { IpcRouter } from '../ipc/IpcRouter'
|
import { IpcRouter } from '../ipc/IpcRouter'
|
||||||
@@ -37,6 +38,7 @@ export class ServiceContainer {
|
|||||||
public readonly provisioning: StoreProvisioningService
|
public readonly provisioning: StoreProvisioningService
|
||||||
public readonly state: ApplicationStateService
|
public readonly state: ApplicationStateService
|
||||||
public readonly launching: GameLaunchService
|
public readonly launching: GameLaunchService
|
||||||
|
public readonly accounts: AccountService
|
||||||
|
|
||||||
private readonly controllers: readonly { register: (router: IpcRouter) => void }[]
|
private readonly controllers: readonly { register: (router: IpcRouter) => void }[]
|
||||||
|
|
||||||
@@ -46,28 +48,29 @@ export class ServiceContainer {
|
|||||||
|
|
||||||
const environment = new ElectronApplicationEnvironment(app)
|
const environment = new ElectronApplicationEnvironment(app)
|
||||||
const httpClient = new HttpTextClient()
|
const httpClient = new HttpTextClient()
|
||||||
const pythonLocator = new SystemPythonRuntimeLocator()
|
|
||||||
const engineRunner = new PythonEngineProcessRunner(pythonLocator)
|
|
||||||
|
|
||||||
const stores = new FileSystemInstalledStoreRepository()
|
const stores = new FileSystemInstalledStoreRepository()
|
||||||
const catalogGateway = new PythonStoreCatalogGateway(engineRunner)
|
const credentials = new SafeStorageCredentialRepository(environment)
|
||||||
|
const catalogGateway = new NativeStoreCatalogGateway(credentials)
|
||||||
const registry = new HttpStoreRegistryRepository(httpClient)
|
const registry = new HttpStoreRegistryRepository(httpClient)
|
||||||
const installer = new HttpStoreEngineInstaller(httpClient)
|
const installer = new NativeStoreEngineInstaller()
|
||||||
const preferencesRepository = new JsonFilePreferencesRepository(environment)
|
const preferencesRepository = new JsonFilePreferencesRepository(environment)
|
||||||
|
|
||||||
const preferences = new PreferencesService(preferencesRepository, environment)
|
const preferences = new PreferencesService(preferencesRepository, environment)
|
||||||
this.selection = new StoreSelectionService(stores, catalogGateway, preferences)
|
this.selection = new StoreSelectionService(stores, preferences)
|
||||||
this.catalog = new CatalogService(catalogGateway, this.selection)
|
this.catalog = new CatalogService(catalogGateway, this.selection)
|
||||||
|
this.accounts = new AccountService(catalogGateway, this.selection)
|
||||||
this.provisioning = new StoreProvisioningService(registry, installer, stores, this.selection)
|
this.provisioning = new StoreProvisioningService(registry, installer, stores, this.selection)
|
||||||
this.launching = new GameLaunchService(new ElectronGameLauncher(shell), this.catalog)
|
this.launching = new GameLaunchService(new ElectronGameLauncher(shell), this.catalog)
|
||||||
this.state = new ApplicationStateService(
|
this.state = new ApplicationStateService(
|
||||||
preferences, this.selection, this.provisioning, pythonLocator, environment
|
preferences, this.selection, this.provisioning, environment
|
||||||
)
|
)
|
||||||
|
|
||||||
this.controllers = [
|
this.controllers = [
|
||||||
new AppIpcController(this.state, preferences, this.launching),
|
new AppIpcController(this.state, preferences, this.launching),
|
||||||
new CatalogIpcController(this.catalog, this.launching, this.guard, this.streams),
|
new CatalogIpcController(this.catalog, this.launching, this.guard, this.streams),
|
||||||
new StoreIpcController(this.provisioning, this.selection, this.guard, this.streams)
|
new StoreIpcController(this.provisioning, this.selection, this.guard, this.streams),
|
||||||
|
new AccountIpcController(this.accounts, this.streams)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ interface SelfTestReport {
|
|||||||
readonly paths: string
|
readonly paths: string
|
||||||
readonly logLines: number
|
readonly logLines: number
|
||||||
readonly locales: readonly string[]
|
readonly locales: readonly string[]
|
||||||
|
/** `<accessible name>:<glyph count>` per icon-only control in the footer. */
|
||||||
|
readonly iconControls: readonly string[]
|
||||||
|
/** `<title> [current|newer] Upgrade:on|off Uninstall:on|off` per installed card. */
|
||||||
|
readonly cardMenus: readonly string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What changed after clicking a store that was not open. */
|
/** What changed after clicking a store that was not open. */
|
||||||
@@ -68,7 +72,15 @@ export class SelfTestRunner {
|
|||||||
// A gate passes on having something to do, not on having a picker: the picker
|
// 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
|
// 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.
|
// the ordinary case. Requiring choices here failed a perfectly good window.
|
||||||
const rendered = report.locales.length > 1 && (
|
// Both footer icons must be present, named and drawn: refresh and the language
|
||||||
|
// picker are the only way to reach those two actions now that neither has a label.
|
||||||
|
const iconsNamed = report.iconControls.length === 2 &&
|
||||||
|
report.iconControls.every((control: string): boolean => /^.+:1$/.test(control))
|
||||||
|
// Every installed card offers both actions, and Upgrade is enabled exactly when the
|
||||||
|
// version line says there is something newer. Uninstall is always available.
|
||||||
|
const menusAgree = report.cardMenus.every((entry: string): boolean =>
|
||||||
|
/\[newer\] \S+:on \S+:on$/.test(entry) || /\[current\] \S+:off \S+:on$/.test(entry))
|
||||||
|
const rendered = report.locales.length > 1 && iconsNamed && menusAgree && (
|
||||||
(report.cards > 0 && !report.gateVisible && report.stores.length > 0 &&
|
(report.cards > 0 && !report.gateVisible && report.stores.length > 0 &&
|
||||||
report.categories.length > 0 && report.activeCategory !== null) ||
|
report.categories.length > 0 && report.activeCategory !== null) ||
|
||||||
(report.gateVisible && report.gateAction.length > 0))
|
(report.gateVisible && report.gateAction.length > 0))
|
||||||
@@ -98,7 +110,25 @@ export class SelfTestRunner {
|
|||||||
activeCategory: (document.querySelector('#cats .cat.is-active') || {}).textContent || null,
|
activeCategory: (document.querySelector('#cats .cat.is-active') || {}).textContent || null,
|
||||||
paths: document.getElementById('log-paths').textContent.slice(0, 120),
|
paths: document.getElementById('log-paths').textContent.slice(0, 120),
|
||||||
logLines: document.querySelectorAll('.log-line').length,
|
logLines: document.querySelectorAll('.log-line').length,
|
||||||
locales: [...document.getElementById('locale').options].map((option) => option.value)
|
locales: [...document.getElementById('locale').options].map((option) => option.value),
|
||||||
|
// The two icon-only controls: a glyph with no accessible name is a button nobody
|
||||||
|
// can identify, and the failure is silent because the icon still draws.
|
||||||
|
// One entry per installed card: its title, whether the version line shows an
|
||||||
|
// upgrade, and the menu's two items with their disabled state. This is the only
|
||||||
|
// way to see that Upgrade is offered exactly when there is something newer —
|
||||||
|
// a screenshot shows a closed menu.
|
||||||
|
cardMenus: [...document.querySelectorAll('.card.is-installed')].map((card) => {
|
||||||
|
const items = [...card.querySelectorAll('.menu-item')]
|
||||||
|
.map((item) => item.textContent + (item.disabled ? ':off' : ':on'))
|
||||||
|
const arrow = card.querySelector('.version.has-update') === null ? 'current' : 'newer'
|
||||||
|
return (card.querySelector('h2') || {}).textContent + ' [' + arrow + '] ' + items.join(' ')
|
||||||
|
}),
|
||||||
|
iconControls: [...document.querySelectorAll('.side-tools .icon-btn')]
|
||||||
|
.map((control) => {
|
||||||
|
const named = control.getAttribute('aria-label') || control.getAttribute('title') ||
|
||||||
|
(control.querySelector('[aria-label]') || {}).ariaLabel || ''
|
||||||
|
return named + ':' + control.querySelectorAll('svg.icon').length
|
||||||
|
})
|
||||||
})`))) ?? {}
|
})`))) ?? {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -117,7 +147,9 @@ export class SelfTestRunner {
|
|||||||
activeCategory: readOptionalString(record, 'activeCategory'),
|
activeCategory: readOptionalString(record, 'activeCategory'),
|
||||||
paths: readString(record, 'paths'),
|
paths: readString(record, 'paths'),
|
||||||
logLines: readNumber(record, 'logLines'),
|
logLines: readNumber(record, 'logLines'),
|
||||||
locales: readStringArray(record, 'locales')
|
locales: readStringArray(record, 'locales'),
|
||||||
|
iconControls: readStringArray(record, 'iconControls'),
|
||||||
|
cardMenus: readStringArray(record, 'cardMenus')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import os from 'node:os'
|
||||||
|
import type { AccountService, SignInResult } from '../../application/services/AccountService'
|
||||||
|
import type { StoreAccount } from '../../domain/models/StoreAccount'
|
||||||
|
import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels'
|
||||||
|
import type { AccountDto, SignInPromptDto } from '../../shared/contracts/dto/AccountDto'
|
||||||
|
import type { WindowStreamBroadcaster } from '../streams/WindowStreamBroadcaster'
|
||||||
|
import type { IpcRouter } from './IpcRouter'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signing in and out.
|
||||||
|
*
|
||||||
|
* Deliberately outside the single-flight guard: signing in takes as long as somebody
|
||||||
|
* takes to find their browser, and holding the store busy for that would stop them
|
||||||
|
* doing anything else meanwhile. Nothing here writes to the library.
|
||||||
|
*
|
||||||
|
* `beginSignIn` answers with the code as soon as there is one and lets the waiting run
|
||||||
|
* on; the end arrives on the sign-in stream. A reply that only came back minutes later
|
||||||
|
* would be a request the window had to keep alive for no reason.
|
||||||
|
*/
|
||||||
|
export class AccountIpcController {
|
||||||
|
public constructor (
|
||||||
|
private readonly accounts: AccountService,
|
||||||
|
private readonly streams: WindowStreamBroadcaster
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public register (router: IpcRouter): void {
|
||||||
|
router.handle(IPC_CHANNELS.accountRead, async (): Promise<AccountDto> =>
|
||||||
|
toDto(await this.accounts.readAccount()))
|
||||||
|
router.handle(IPC_CHANNELS.accountBeginSignIn, async (): Promise<SignInPromptDto> =>
|
||||||
|
this.handleBeginSignIn())
|
||||||
|
router.handle(IPC_CHANNELS.accountCancelSignIn, (): Promise<void> => {
|
||||||
|
this.accounts.cancelSignIn()
|
||||||
|
return Promise.resolve()
|
||||||
|
})
|
||||||
|
router.handle(IPC_CHANNELS.accountSignOut, async (): Promise<AccountDto> =>
|
||||||
|
toDto(await this.accounts.signOut()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleBeginSignIn (): Promise<SignInPromptDto> {
|
||||||
|
const session = await this.accounts.beginSignIn(clientName())
|
||||||
|
|
||||||
|
session.finished.then((result: SignInResult): void => {
|
||||||
|
this.streams.publishSignInFinished({
|
||||||
|
outcome: result.outcome, account: toDto(result.account)
|
||||||
|
})
|
||||||
|
}, (error: unknown): void => {
|
||||||
|
// A sign-in that fell over is a sign-in that did not happen; the window needs to
|
||||||
|
// stop showing a code either way.
|
||||||
|
this.streams.publishSignInFinished({
|
||||||
|
outcome: 'expired', account: { signInAvailable: true, signedIn: false }
|
||||||
|
})
|
||||||
|
this.streams.publishLog(`sign-in failed: ${describe(error)}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
userCode: session.prompt.userCode,
|
||||||
|
verificationUrl: session.prompt.verificationUrl,
|
||||||
|
expiresInSeconds: session.prompt.expiresInSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDto (account: StoreAccount): AccountDto {
|
||||||
|
return { signInAvailable: account.signInAvailable, signedIn: account.signedIn }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this device calls itself on the person's account page.
|
||||||
|
*
|
||||||
|
* The machine's own name, because that is what somebody looking at a list of signed-in
|
||||||
|
* devices needs in order to recognise which one to remove.
|
||||||
|
*/
|
||||||
|
function clientName (): string {
|
||||||
|
const hostname = os.hostname()
|
||||||
|
return hostname.length > 0 ? `WarpEngine Client (${hostname})` : 'WarpEngine Client'
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe (error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
@@ -46,7 +46,11 @@ export class CatalogIpcController {
|
|||||||
return {
|
return {
|
||||||
games: this.gameMapper.toDtoList(listing.games, baseUrl),
|
games: this.gameMapper.toDtoList(listing.games, baseUrl),
|
||||||
skipped: listing.skipped,
|
skipped: listing.skipped,
|
||||||
paths: listing.paths === null ? null : this.pathsMapper.toDto(listing.paths)
|
paths: listing.paths === null ? null : this.pathsMapper.toDto(listing.paths),
|
||||||
|
account: {
|
||||||
|
signInAvailable: listing.account.signInAvailable,
|
||||||
|
signedIn: listing.account.signedIn
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,12 +27,9 @@ export function requireStringArray (value: unknown, name: string): readonly stri
|
|||||||
export function requireRegistryStore (value: unknown): RegistryStoreDto {
|
export function requireRegistryStore (value: unknown): RegistryStoreDto {
|
||||||
const record = asRecord(value)
|
const record = asRecord(value)
|
||||||
if (record === null) throw new TypeError('a store record is required')
|
if (record === null) throw new TypeError('a store record is required')
|
||||||
const repository = readString(record, 'storeRepositoryUrl')
|
|
||||||
const store: RegistryStoreDto = {
|
const store: RegistryStoreDto = {
|
||||||
name: readString(record, 'name'),
|
name: readString(record, 'name'),
|
||||||
catalogUrl: readString(record, 'catalogUrl'),
|
catalogUrl: readString(record, 'catalogUrl'),
|
||||||
// Optional: a store with no repository installs on the engine's defaults.
|
|
||||||
storeRepositoryUrl: repository.length > 0 ? repository : null,
|
|
||||||
storeId: readString(record, 'storeId')
|
storeId: readString(record, 'storeId')
|
||||||
}
|
}
|
||||||
if (store.name.length === 0 || store.catalogUrl.length === 0) {
|
if (store.name.length === 0 || store.catalogUrl.length === 0) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { InstalledStoreDtoMapper } from '../../application/mappers/InstalledStoreDtoMapper'
|
import { InstalledStoreDtoMapper } from '../../application/mappers/InstalledStoreDtoMapper'
|
||||||
import { EngineVersionDtoMapper } from '../../application/mappers/EngineVersionDtoMapper'
|
|
||||||
import { RegistryStoreDtoMapper } from '../../application/mappers/RegistryStoreDtoMapper'
|
import { RegistryStoreDtoMapper } from '../../application/mappers/RegistryStoreDtoMapper'
|
||||||
import type { StoreProvisioningService } from '../../application/services/StoreProvisioningService'
|
import type { StoreProvisioningService } from '../../application/services/StoreProvisioningService'
|
||||||
import type { StoreSelectionService } from '../../application/services/StoreSelectionService'
|
import type { StoreSelectionService } from '../../application/services/StoreSelectionService'
|
||||||
@@ -20,8 +19,7 @@ export class StoreIpcController {
|
|||||||
private readonly guard: SingleFlightGuard,
|
private readonly guard: SingleFlightGuard,
|
||||||
private readonly streams: WindowStreamBroadcaster,
|
private readonly streams: WindowStreamBroadcaster,
|
||||||
private readonly registryMapper: RegistryStoreDtoMapper = new RegistryStoreDtoMapper(),
|
private readonly registryMapper: RegistryStoreDtoMapper = new RegistryStoreDtoMapper(),
|
||||||
private readonly storeMapper: InstalledStoreDtoMapper = new InstalledStoreDtoMapper(),
|
private readonly storeMapper: InstalledStoreDtoMapper = new InstalledStoreDtoMapper()
|
||||||
private readonly engineMapper: EngineVersionDtoMapper = new EngineVersionDtoMapper()
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public register (router: IpcRouter): void {
|
public register (router: IpcRouter): void {
|
||||||
@@ -63,11 +61,6 @@ export class StoreIpcController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleSelectStore (home: string): StoreSelectionDto {
|
private handleSelectStore (home: string): StoreSelectionDto {
|
||||||
const store = this.selection.selectStore(home)
|
return { store: this.storeMapper.toDto(this.selection.selectStore(home)) }
|
||||||
const engine = this.selection.findEngineVersion(store)
|
|
||||||
return {
|
|
||||||
store: this.storeMapper.toDto(store),
|
|
||||||
engine: engine === null ? null : this.engineMapper.toDto(engine)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { BrowserWindow } from 'electron'
|
import type { BrowserWindow } from 'electron'
|
||||||
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
|
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
|
||||||
import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels'
|
import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels'
|
||||||
|
import type { SignInFinishedDto } from '../../shared/contracts/dto/AccountDto'
|
||||||
import type { SyncEventDto } from '../../shared/contracts/dto/SyncEventDto'
|
import type { SyncEventDto } from '../../shared/contracts/dto/SyncEventDto'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The three one-way streams to the window: log lines, progress events, busy state.
|
* The one-way streams to the window: log lines, progress events, busy state, and how a
|
||||||
|
* sign-in ended.
|
||||||
*
|
*
|
||||||
* Holds no window of its own — the reference is handed in when one exists and
|
* Holds no window of its own — the reference is handed in when one exists and
|
||||||
* cleared when it does not, so a stream that outlives the window is a no-op rather
|
* cleared when it does not, so a stream that outlives the window is a no-op rather
|
||||||
@@ -33,6 +35,14 @@ export class WindowStreamBroadcaster {
|
|||||||
this.send(IPC_CHANNELS.streamBusyChanged, busy)
|
this.send(IPC_CHANNELS.streamBusyChanged, busy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sign-in finishes minutes after the call that started it returned, and in another
|
||||||
|
* window entirely — so it arrives as an event rather than as a reply.
|
||||||
|
*/
|
||||||
|
public publishSignInFinished (result: SignInFinishedDto): void {
|
||||||
|
this.send(IPC_CHANNELS.streamSignInFinished, result)
|
||||||
|
}
|
||||||
|
|
||||||
/** A progress listener wired to these streams, for handing to the engine. */
|
/** A progress listener wired to these streams, for handing to the engine. */
|
||||||
public asProgressListener (): EngineProgressListener {
|
public asProgressListener (): EngineProgressListener {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import {
|
|||||||
BRIDGE_GLOBAL_NAME, type BridgeApi, type StreamListener
|
BRIDGE_GLOBAL_NAME, type BridgeApi, type StreamListener
|
||||||
} from '../shared/contracts/BridgeApi'
|
} from '../shared/contracts/BridgeApi'
|
||||||
import { IPC_CHANNELS } from '../shared/contracts/IpcChannels'
|
import { IPC_CHANNELS } from '../shared/contracts/IpcChannels'
|
||||||
|
import type {
|
||||||
|
AccountDto, SignInFinishedDto, SignInPromptDto
|
||||||
|
} from '../shared/contracts/dto/AccountDto'
|
||||||
import type { AppStateDto } from '../shared/contracts/dto/AppStateDto'
|
import type { AppStateDto } from '../shared/contracts/dto/AppStateDto'
|
||||||
import type { CatalogListingDto } from '../shared/contracts/dto/CatalogListingDto'
|
import type { CatalogListingDto } from '../shared/contracts/dto/CatalogListingDto'
|
||||||
import type { InstalledStoreDto } from '../shared/contracts/dto/InstalledStoreDto'
|
import type { InstalledStoreDto } from '../shared/contracts/dto/InstalledStoreDto'
|
||||||
@@ -40,6 +43,15 @@ const bridge: BridgeApi = {
|
|||||||
launchGame: async (name: string): Promise<boolean> =>
|
launchGame: async (name: string): Promise<boolean> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.catalogLaunchGame, name) as Promise<boolean>,
|
ipcRenderer.invoke(IPC_CHANNELS.catalogLaunchGame, name) as Promise<boolean>,
|
||||||
|
|
||||||
|
readAccount: async (): Promise<AccountDto> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.accountRead) as Promise<AccountDto>,
|
||||||
|
beginSignIn: async (): Promise<SignInPromptDto> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.accountBeginSignIn) as Promise<SignInPromptDto>,
|
||||||
|
cancelSignIn: async (): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.accountCancelSignIn) as Promise<void>,
|
||||||
|
signOut: async (): Promise<AccountDto> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.accountSignOut) as Promise<AccountDto>,
|
||||||
|
|
||||||
listRegistryStores: async (): Promise<RegistryResultDto> =>
|
listRegistryStores: async (): Promise<RegistryResultDto> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.storeListRegistry) as Promise<RegistryResultDto>,
|
ipcRenderer.invoke(IPC_CHANNELS.storeListRegistry) as Promise<RegistryResultDto>,
|
||||||
installStore: async (store: RegistryStoreDto): Promise<InstalledStoreDto> =>
|
installStore: async (store: RegistryStoreDto): Promise<InstalledStoreDto> =>
|
||||||
@@ -66,6 +78,12 @@ const bridge: BridgeApi = {
|
|||||||
ipcRenderer.on(IPC_CHANNELS.streamBusyChanged, (_event: IpcRendererEvent, busy: boolean): void => {
|
ipcRenderer.on(IPC_CHANNELS.streamBusyChanged, (_event: IpcRendererEvent, busy: boolean): void => {
|
||||||
listener(busy)
|
listener(busy)
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
onSignInFinished: (listener: StreamListener<SignInFinishedDto>): void => {
|
||||||
|
ipcRenderer.on(
|
||||||
|
IPC_CHANNELS.streamSignInFinished,
|
||||||
|
(_event: IpcRendererEvent, payload: SignInFinishedDto): void => { listener(payload) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { BridgeApi } from '../shared/contracts/BridgeApi'
|
import type { BridgeApi } from '../shared/contracts/BridgeApi'
|
||||||
import { requireBridge } from './BridgeAccess'
|
import { requireBridge } from './BridgeAccess'
|
||||||
|
import { AccountController } from './controllers/AccountController'
|
||||||
import { CatalogController } from './controllers/CatalogController'
|
import { CatalogController } from './controllers/CatalogController'
|
||||||
import { EngineStreamController } from './controllers/EngineStreamController'
|
import { EngineStreamController } from './controllers/EngineStreamController'
|
||||||
import { PreferencesController } from './controllers/PreferencesController'
|
import { PreferencesController } from './controllers/PreferencesController'
|
||||||
@@ -11,6 +12,7 @@ import { GameCardView } from './views/GameCardView'
|
|||||||
import { GateView } from './views/GateView'
|
import { GateView } from './views/GateView'
|
||||||
import { LogDrawerView } from './views/LogDrawerView'
|
import { LogDrawerView } from './views/LogDrawerView'
|
||||||
import { SideMenuView } from './views/SideMenuView'
|
import { SideMenuView } from './views/SideMenuView'
|
||||||
|
import { SignInView } from './views/SignInView'
|
||||||
import { TopBarView } from './views/TopBarView'
|
import { TopBarView } from './views/TopBarView'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,10 +30,12 @@ export class RendererApplication {
|
|||||||
private readonly grid: CatalogGridView
|
private readonly grid: CatalogGridView
|
||||||
private readonly topBar: TopBarView
|
private readonly topBar: TopBarView
|
||||||
private readonly sideMenu: SideMenuView
|
private readonly sideMenu: SideMenuView
|
||||||
|
private readonly signInPanel: SignInView
|
||||||
private readonly catalog: CatalogController
|
private readonly catalog: CatalogController
|
||||||
private readonly stores: StoreController
|
private readonly stores: StoreController
|
||||||
private readonly preferences: PreferencesController
|
private readonly preferences: PreferencesController
|
||||||
private readonly streams: EngineStreamController
|
private readonly streams: EngineStreamController
|
||||||
|
private readonly accounts: AccountController
|
||||||
|
|
||||||
public constructor (bridge: BridgeApi = requireBridge()) {
|
public constructor (bridge: BridgeApi = requireBridge()) {
|
||||||
this.bridge = bridge
|
this.bridge = bridge
|
||||||
@@ -44,12 +48,25 @@ export class RendererApplication {
|
|||||||
this.stores = new StoreController(this.bridge, this.store, 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.preferences = new PreferencesController(this.bridge, this.store)
|
||||||
this.streams = new EngineStreamController(this.bridge, this.store, this.log)
|
this.streams = new EngineStreamController(this.bridge, this.store, this.log)
|
||||||
|
this.accounts = new AccountController(
|
||||||
|
this.bridge, this.store, this.log,
|
||||||
|
async (): Promise<void> => { await this.catalog.refresh() }
|
||||||
|
)
|
||||||
|
|
||||||
this.grid = new CatalogGridView(new GameCardView({
|
this.grid = new CatalogGridView(new GameCardView({
|
||||||
onInstall: (name: string): void => { void this.catalog.syncGames([name]) },
|
onInstall: (name: string): void => { void this.catalog.syncGames([name]) },
|
||||||
|
// The same call as an install: a sync of one name fetches whatever the catalog
|
||||||
|
// now has for it, and the engine replaces the old payload and menu entry.
|
||||||
|
onUpgrade: (name: string): void => { void this.catalog.syncGames([name]) },
|
||||||
onLaunch: (name: string): void => { void this.catalog.launchGame(name) },
|
onLaunch: (name: string): void => { void this.catalog.launchGame(name) },
|
||||||
onRemove: (name: string): void => { void this.catalog.removeGame(name) }
|
onRemove: (name: string): void => { void this.catalog.removeGame(name) },
|
||||||
|
onPurchase: (name: string): void => { void this.purchase(name) },
|
||||||
|
onSignIn: (): void => { void this.accounts.signIn() }
|
||||||
}))
|
}))
|
||||||
|
this.signInPanel = new SignInView({
|
||||||
|
onOpenPage: (): void => { void this.accounts.openVerificationPage() },
|
||||||
|
onCancel: (): void => { void this.accounts.cancelSignIn() }
|
||||||
|
})
|
||||||
this.topBar = new TopBarView({
|
this.topBar = new TopBarView({
|
||||||
onToggleNavigation: (): void => { void this.preferences.toggleNavigation() }
|
onToggleNavigation: (): void => { void this.preferences.toggleNavigation() }
|
||||||
})
|
})
|
||||||
@@ -58,11 +75,29 @@ export class RendererApplication {
|
|||||||
onAddStore: (): void => { void this.stores.offerStores() },
|
onAddStore: (): void => { void this.stores.offerStores() },
|
||||||
onRefresh: (): void => { void this.catalog.refresh() },
|
onRefresh: (): void => { void this.catalog.refresh() },
|
||||||
onSelectCategory: (filter: CategoryFilter): void => { this.store.applyFilter(filter) },
|
onSelectCategory: (filter: CategoryFilter): void => { this.store.applyFilter(filter) },
|
||||||
onSelectLocale: (locale: string): void => { void this.preferences.selectLocale(locale) }
|
onSelectLocale: (locale: string): void => { void this.preferences.selectLocale(locale) },
|
||||||
|
onSignIn: (): void => { void this.accounts.signIn() },
|
||||||
|
onSignOut: (): void => { void this.accounts.signOut() }
|
||||||
})
|
})
|
||||||
|
|
||||||
this.store.subscribe((state: AppState): void => { this.render(state) })
|
this.store.subscribe((state: AppState): void => { this.render(state) })
|
||||||
this.streams.subscribe()
|
this.streams.subscribe()
|
||||||
|
this.accounts.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buying happens in a browser.
|
||||||
|
*
|
||||||
|
* A checkout rebuilt in this window would be a second place to get card handling
|
||||||
|
* wrong, and the store's own pages already do it. What this side owes afterwards is
|
||||||
|
* a refresh, which the Refresh button is for.
|
||||||
|
*/
|
||||||
|
private async purchase (name: string): Promise<void> {
|
||||||
|
const url = this.store.readState().games
|
||||||
|
.find((candidate): boolean => candidate.name === name)?.purchaseUrl ?? null
|
||||||
|
if (url === null) return
|
||||||
|
|
||||||
|
await this.bridge.openUrl(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Decides what the window is showing, then hands over to the views. */
|
/** Decides what the window is showing, then hands over to the views. */
|
||||||
@@ -70,18 +105,10 @@ export class RendererApplication {
|
|||||||
this.store.applyAppState(await this.bridge.readState())
|
this.store.applyAppState(await this.bridge.readState())
|
||||||
const state = this.store.readState()
|
const state = this.store.readState()
|
||||||
|
|
||||||
if (state.pythonVersion === null) {
|
|
||||||
this.stores.showMissingPythonGate()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (state.currentStore === null) {
|
if (state.currentStore === null) {
|
||||||
await this.stores.offerStores()
|
await this.stores.offerStores()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (state.engine !== null && !state.engine.supported) {
|
|
||||||
this.stores.showOutdatedEngineGate()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.store.applyGate(null)
|
this.store.applyGate(null)
|
||||||
await this.catalog.refresh()
|
await this.catalog.refresh()
|
||||||
}
|
}
|
||||||
@@ -96,6 +123,7 @@ export class RendererApplication {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.body.classList.toggle('nav-closed', !state.navigationOpen)
|
document.body.classList.toggle('nav-closed', !state.navigationOpen)
|
||||||
|
this.signInPanel.render(state)
|
||||||
this.topBar.render(state)
|
this.topBar.render(state)
|
||||||
this.sideMenu.render(state)
|
this.sideMenu.render(state)
|
||||||
this.log.render(state)
|
this.log.render(state)
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { BridgeApi } from '../../shared/contracts/BridgeApi'
|
||||||
|
import type { SignInFinishedDto } from '../../shared/contracts/dto/AccountDto'
|
||||||
|
import type { AppStore } from '../state/AppStore'
|
||||||
|
import type { LogDrawerView } from '../views/LogDrawerView'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signing in and out, from the window's side.
|
||||||
|
*
|
||||||
|
* Two halves that do not meet: `signIn` puts a code on screen and returns, and the
|
||||||
|
* answer arrives later on the sign-in stream — because the person is not here while it
|
||||||
|
* happens, they are in a browser. Nothing waits on anything.
|
||||||
|
*
|
||||||
|
* A sign-in that succeeds refreshes the catalog rather than patching the cards, since
|
||||||
|
* every entitlement in the listing was read without a credential and is now stale.
|
||||||
|
*/
|
||||||
|
export class AccountController {
|
||||||
|
public constructor (
|
||||||
|
private readonly bridge: BridgeApi,
|
||||||
|
private readonly store: AppStore,
|
||||||
|
private readonly log: LogDrawerView,
|
||||||
|
private readonly onSignedIn: () => Promise<void>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public subscribe (): void {
|
||||||
|
this.bridge.onSignInFinished((result: SignInFinishedDto): void => {
|
||||||
|
this.store.applySignIn(null)
|
||||||
|
this.store.applyAccount(result.account)
|
||||||
|
this.log.appendLine(this.describeOutcome(result))
|
||||||
|
// Only a successful sign-in changes what the catalog would say. The other three
|
||||||
|
// leave it exactly as it was, and re-reading it would be a pointless wait.
|
||||||
|
if (result.outcome === 'signedIn') void this.onSignedIn()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public async signIn (): Promise<void> {
|
||||||
|
try {
|
||||||
|
const prompt = await this.bridge.beginSignIn()
|
||||||
|
this.store.applySignIn(prompt)
|
||||||
|
// Opened for them rather than waiting to be clicked: the browser is where the
|
||||||
|
// rest of this happens, and the code on screen is no use until it is open.
|
||||||
|
await this.bridge.openUrl(prompt.verificationUrl)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.store.applySignIn(null)
|
||||||
|
this.log.appendLine(`${this.store.readState().messages.signInFailed} ${describe(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-open the page for somebody who closed the tab before typing the code. */
|
||||||
|
public async openVerificationPage (): Promise<void> {
|
||||||
|
const prompt = this.store.readState().signIn
|
||||||
|
if (prompt === null) return
|
||||||
|
|
||||||
|
await this.bridge.openUrl(prompt.verificationUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async cancelSignIn (): Promise<void> {
|
||||||
|
this.store.applySignIn(null)
|
||||||
|
await this.bridge.cancelSignIn()
|
||||||
|
}
|
||||||
|
|
||||||
|
public async signOut (): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.store.applyAccount(await this.bridge.signOut())
|
||||||
|
// The listing was read as somebody; it has to be read again as nobody, or every
|
||||||
|
// owned title keeps its Install button until the next refresh.
|
||||||
|
await this.onSignedIn()
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.log.appendLine(describe(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private describeOutcome (result: SignInFinishedDto): string {
|
||||||
|
const messages = this.store.readState().messages
|
||||||
|
switch (result.outcome) {
|
||||||
|
case 'signedIn': return messages.signInDone
|
||||||
|
case 'denied': return messages.signInDenied
|
||||||
|
case 'expired': return messages.signInExpired
|
||||||
|
case 'cancelled': return messages.signInCancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe (error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ export class CatalogController {
|
|||||||
public async refresh (): Promise<void> {
|
public async refresh (): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const listing: CatalogListingDto = await this.bridge.listGames()
|
const listing: CatalogListingDto = await this.bridge.listGames()
|
||||||
this.store.applyCatalog(listing.games, listing.paths)
|
this.store.applyCatalog(listing.games, listing.paths, listing.account)
|
||||||
for (const reason of listing.skipped) this.log.appendLine(`skipped ${reason}`)
|
for (const reason of listing.skipped) this.log.appendLine(`skipped ${reason}`)
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.reportFailure(error)
|
this.reportFailure(error)
|
||||||
|
|||||||
@@ -24,11 +24,7 @@ export class StoreController {
|
|||||||
const messages = this.store.readState().messages
|
const messages = this.store.readState().messages
|
||||||
try {
|
try {
|
||||||
const selection = await this.bridge.selectStore(home)
|
const selection = await this.bridge.selectStore(home)
|
||||||
this.store.applySelectedStore(selection.store, selection.engine)
|
this.store.applySelectedStore(selection.store)
|
||||||
if (selection.engine !== null && !selection.engine.supported) {
|
|
||||||
this.showOutdatedEngineGate()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.store.applyGate(null)
|
this.store.applyGate(null)
|
||||||
await this.catalog.refresh()
|
await this.catalog.refresh()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -75,25 +71,6 @@ export class StoreController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public showOutdatedEngineGate (): void {
|
|
||||||
const state = this.store.readState()
|
|
||||||
const engineText = state.engine === null ? '' : state.engine.text
|
|
||||||
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() } }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public showMissingPythonGate (): void {
|
|
||||||
const messages = this.store.readState().messages
|
|
||||||
this.store.applyGate({
|
|
||||||
title: messages.noPythonTitle,
|
|
||||||
body: messages.noPythonBody,
|
|
||||||
link: { label: messages.pythonLink, url: 'https://www.python.org/downloads/' }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private async installStore (chosen: RegistryStoreDto): Promise<void> {
|
private async installStore (chosen: RegistryStoreDto): Promise<void> {
|
||||||
const messages = this.store.readState().messages
|
const messages = this.store.readState().messages
|
||||||
this.store.applyProgress({ total: 0, done: 0, label: messages.setupWorking })
|
this.store.applyProgress({ total: 0, done: 0, label: messages.setupWorking })
|
||||||
|
|||||||
+56
-9
@@ -6,7 +6,7 @@
|
|||||||
runs: the app ships its own script and stylesheet. -->
|
runs: the app ships its own script and stylesheet. -->
|
||||||
<meta http-equiv="Content-Security-Policy"
|
<meta http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' https: data:; font-src 'self'; connect-src 'none'">
|
content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' https: data:; font-src 'self'; connect-src 'none'">
|
||||||
<title>WarpEngine Store</title>
|
<title>WarpEngine Client</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -16,8 +16,10 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="bar-title">
|
<div class="bar-title">
|
||||||
<span class="logo" aria-hidden="true">▚</span>
|
<span class="logo" aria-hidden="true">▚</span>
|
||||||
<span id="app-name">WarpEngine Store</span>
|
<span id="app-name">WarpEngine Client</span>
|
||||||
<span class="store-id" id="store-id"></span>
|
<!-- Kept for the window check, which reads it to see which store is open; the
|
||||||
|
side menu is where a person reads that. -->
|
||||||
|
<span class="store-id" id="store-id" hidden></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bar-actions">
|
<div class="bar-actions">
|
||||||
<span class="progress" id="progress" hidden></span>
|
<span class="progress" id="progress" hidden></span>
|
||||||
@@ -34,9 +36,15 @@
|
|||||||
<button id="add-store" class="btn btn-ghost btn-wide"></button>
|
<button id="add-store" class="btn btn-ghost btn-wide"></button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="side-block">
|
<!--
|
||||||
<h2 class="side-head" id="head-actions"></h2>
|
Signing in, and who is signed in. The whole block is hidden where the catalog
|
||||||
<button id="refresh" class="btn btn-wide" disabled></button>
|
offers no sign-in at all, which is most of them: a greyed-out button is telling
|
||||||
|
somebody about a door that does not exist.
|
||||||
|
-->
|
||||||
|
<section class="side-block" id="account-block" hidden>
|
||||||
|
<h2 class="side-head" id="head-account"></h2>
|
||||||
|
<p class="side-quiet-text" id="account-state"></p>
|
||||||
|
<button id="account-action" class="btn btn-ghost btn-wide"></button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="side-block side-cats">
|
<section class="side-block side-cats">
|
||||||
@@ -46,14 +54,53 @@
|
|||||||
|
|
||||||
<section class="side-block side-foot">
|
<section class="side-block side-foot">
|
||||||
<button id="log-toggle" class="side-quiet" aria-expanded="false" aria-controls="log-panel"></button>
|
<button id="log-toggle" class="side-quiet" aria-expanded="false" aria-controls="log-panel"></button>
|
||||||
<label class="side-lang">
|
<!--
|
||||||
<span id="head-lang"></span>
|
Two icons and nothing else. Both carry their name in `title` and `aria-label`,
|
||||||
<select id="locale" class="select" aria-label="Language"></select>
|
set from the message bundle, so the tooltip and the screen reader stay
|
||||||
|
translated while the button stays the size of its glyph.
|
||||||
|
-->
|
||||||
|
<div class="side-tools">
|
||||||
|
<button id="refresh" class="icon-btn" disabled>
|
||||||
|
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M13.5 8a5.5 5.5 0 1 1-1.61-3.89" />
|
||||||
|
<path d="M13.5 2v3h-3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!--
|
||||||
|
The select is still a real `<select>`, stretched over the icon and invisible:
|
||||||
|
the native dropdown knows how to open upward in a cramped window and is
|
||||||
|
already keyboard- and screen-reader-navigable, which a hand-rolled menu would
|
||||||
|
have to earn back.
|
||||||
|
-->
|
||||||
|
<label class="icon-btn side-lang" id="locale-control">
|
||||||
|
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||||
|
<circle cx="8" cy="8" r="6" />
|
||||||
|
<path d="M2 8h12" />
|
||||||
|
<path d="M8 2c1.8 1.6 2.8 3.8 2.8 6S9.8 12.4 8 14C6.2 12.4 5.2 10.2 5.2 8S6.2 3.6 8 2Z" />
|
||||||
|
</svg>
|
||||||
|
<select id="locale" class="locale-select"></select>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
<!--
|
||||||
|
The code to type into a browser. A panel over the grid rather than a screen of
|
||||||
|
its own: the catalog is still there and still readable, and the sign-in is
|
||||||
|
something happening elsewhere that this window is only reporting on.
|
||||||
|
-->
|
||||||
|
<section id="signin" class="signin" hidden>
|
||||||
|
<h2 id="signin-title"></h2>
|
||||||
|
<p id="signin-body"></p>
|
||||||
|
<p class="signin-code" id="signin-code"></p>
|
||||||
|
<p class="signin-waiting" id="signin-waiting"></p>
|
||||||
|
<div class="signin-actions">
|
||||||
|
<button id="signin-open" class="btn btn-secondary"></button>
|
||||||
|
<button id="signin-cancel" class="btn btn-ghost"></button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Shown instead of the grid when there is nothing to drive yet. -->
|
<!-- Shown instead of the grid when there is nothing to drive yet. -->
|
||||||
<section id="gate" class="gate" hidden>
|
<section id="gate" class="gate" hidden>
|
||||||
<h1 id="gate-title"></h1>
|
<h1 id="gate-title"></h1>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import type { AccountDto, SignInPromptDto } from '../../shared/contracts/dto/AccountDto'
|
||||||
import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto'
|
import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto'
|
||||||
import type { EngineVersionDto } from '../../shared/contracts/dto/EngineVersionDto'
|
|
||||||
import type { GameDto } from '../../shared/contracts/dto/GameDto'
|
import type { GameDto } from '../../shared/contracts/dto/GameDto'
|
||||||
import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto'
|
import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto'
|
||||||
import type { StorePathsDto } from '../../shared/contracts/dto/StorePathsDto'
|
import type { StorePathsDto } from '../../shared/contracts/dto/StorePathsDto'
|
||||||
@@ -21,11 +21,8 @@ export interface AppState {
|
|||||||
readonly locales: readonly Locale[]
|
readonly locales: readonly Locale[]
|
||||||
readonly messages: MessageBundle
|
readonly messages: MessageBundle
|
||||||
readonly navigationOpen: boolean
|
readonly navigationOpen: boolean
|
||||||
readonly pythonVersion: string | null
|
|
||||||
readonly stores: readonly InstalledStoreDto[]
|
readonly stores: readonly InstalledStoreDto[]
|
||||||
readonly currentStore: InstalledStoreDto | null
|
readonly currentStore: InstalledStoreDto | null
|
||||||
readonly engine: EngineVersionDto | null
|
|
||||||
readonly minimumEngineVersion: string
|
|
||||||
readonly registryUrl: string
|
readonly registryUrl: string
|
||||||
readonly defaultStoreRoot: string
|
readonly defaultStoreRoot: string
|
||||||
readonly games: readonly GameDto[]
|
readonly games: readonly GameDto[]
|
||||||
@@ -35,6 +32,10 @@ export interface AppState {
|
|||||||
readonly progress: SyncProgress | null
|
readonly progress: SyncProgress | null
|
||||||
/** Non-null while the setup screen is up, which is also what hides the grid. */
|
/** Non-null while the setup screen is up, which is also what hides the grid. */
|
||||||
readonly gate: GatePresentation | null
|
readonly gate: GatePresentation | null
|
||||||
|
/** Where this machine stands with the open store. */
|
||||||
|
readonly account: AccountDto
|
||||||
|
/** Non-null while a code is on screen waiting to be typed into a browser. */
|
||||||
|
readonly signIn: SignInPromptDto | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL_STATE: AppState = {
|
const INITIAL_STATE: AppState = {
|
||||||
@@ -42,11 +43,8 @@ const INITIAL_STATE: AppState = {
|
|||||||
locales: ['en'],
|
locales: ['en'],
|
||||||
messages: ENGLISH_MESSAGES,
|
messages: ENGLISH_MESSAGES,
|
||||||
navigationOpen: true,
|
navigationOpen: true,
|
||||||
pythonVersion: null,
|
|
||||||
stores: [],
|
stores: [],
|
||||||
currentStore: null,
|
currentStore: null,
|
||||||
engine: null,
|
|
||||||
minimumEngineVersion: '',
|
|
||||||
registryUrl: '',
|
registryUrl: '',
|
||||||
defaultStoreRoot: '',
|
defaultStoreRoot: '',
|
||||||
games: [],
|
games: [],
|
||||||
@@ -54,7 +52,9 @@ const INITIAL_STATE: AppState = {
|
|||||||
filter: ALL_CATEGORIES,
|
filter: ALL_CATEGORIES,
|
||||||
busy: false,
|
busy: false,
|
||||||
progress: null,
|
progress: null,
|
||||||
gate: null
|
gate: null,
|
||||||
|
account: { signInAvailable: false, signedIn: false },
|
||||||
|
signIn: null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppStateListener = (state: AppState) => void
|
export type AppStateListener = (state: AppState) => void
|
||||||
@@ -85,11 +85,8 @@ export class AppStore {
|
|||||||
locales: dto.locales,
|
locales: dto.locales,
|
||||||
messages: dto.messages,
|
messages: dto.messages,
|
||||||
navigationOpen: dto.navigationOpen,
|
navigationOpen: dto.navigationOpen,
|
||||||
pythonVersion: dto.pythonVersion,
|
|
||||||
stores: dto.stores,
|
stores: dto.stores,
|
||||||
currentStore: dto.currentStore,
|
currentStore: dto.currentStore,
|
||||||
engine: dto.engine,
|
|
||||||
minimumEngineVersion: dto.minimumEngineVersion,
|
|
||||||
registryUrl: dto.registryUrl,
|
registryUrl: dto.registryUrl,
|
||||||
defaultStoreRoot: dto.defaultStoreRoot
|
defaultStoreRoot: dto.defaultStoreRoot
|
||||||
}
|
}
|
||||||
@@ -106,13 +103,38 @@ export class AppStore {
|
|||||||
this.notify()
|
this.notify()
|
||||||
}
|
}
|
||||||
|
|
||||||
public applyCatalog (games: readonly GameDto[], paths: StorePathsDto | null): void {
|
public applyCatalog (
|
||||||
this.state = { ...this.state, games, paths: paths ?? this.state.paths }
|
games: readonly GameDto[],
|
||||||
|
paths: StorePathsDto | null,
|
||||||
|
account: AccountDto
|
||||||
|
): void {
|
||||||
|
this.state = { ...this.state, games, paths: paths ?? this.state.paths, account }
|
||||||
this.notify()
|
this.notify()
|
||||||
}
|
}
|
||||||
|
|
||||||
public applySelectedStore (store: InstalledStoreDto, engine: EngineVersionDto | null): void {
|
public applyAccount (account: AccountDto): void {
|
||||||
this.state = { ...this.state, currentStore: store, engine, games: [], paths: null, filter: ALL_CATEGORIES }
|
this.state = { ...this.state, account }
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
public applySignIn (prompt: SignInPromptDto | null): void {
|
||||||
|
this.state = { ...this.state, signIn: prompt }
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
public applySelectedStore (store: InstalledStoreDto): void {
|
||||||
|
// A new store means a new account: whether we are signed in is per store, and
|
||||||
|
// carrying the old answer over would show somebody as signed in to a shop they
|
||||||
|
// have never visited.
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
currentStore: store,
|
||||||
|
games: [],
|
||||||
|
paths: null,
|
||||||
|
filter: ALL_CATEGORIES,
|
||||||
|
account: { signInAvailable: false, signedIn: false },
|
||||||
|
signIn: null
|
||||||
|
}
|
||||||
this.notify()
|
this.notify()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ export function matchesFilter (game: GameDto, filter: CategoryFilter): boolean {
|
|||||||
case 'platform':
|
case 'platform':
|
||||||
return game.platform === filter.value
|
return game.platform === filter.value
|
||||||
case 'mode':
|
case 'mode':
|
||||||
return game.mode === filter.value
|
// A title this machine cannot install has no mode worth filtering on: the engine
|
||||||
|
// sends none, and counting it as native would put a C64 cartridge under "native".
|
||||||
|
return game.installable && game.mode === filter.value
|
||||||
case 'group':
|
case 'group':
|
||||||
return matchesGroup(game, filter.value)
|
return matchesGroup(game, filter.value)
|
||||||
}
|
}
|
||||||
@@ -43,7 +45,15 @@ function matchesGroup (game: GameDto, group: string): boolean {
|
|||||||
case 'updates':
|
case 'updates':
|
||||||
return game.updateAvailable
|
return game.updateAvailable
|
||||||
case 'available':
|
case 'available':
|
||||||
return !game.installed
|
return game.installable && !game.installed
|
||||||
|
case 'owned':
|
||||||
|
// Owning something is not the same as having installed it — the point of the
|
||||||
|
// category is finding what you paid for and have not put on this machine yet.
|
||||||
|
return game.accessVerdict === 'entitled'
|
||||||
|
case 'purchasable':
|
||||||
|
return game.accessVerdict === 'purchasable'
|
||||||
|
case 'unsupported':
|
||||||
|
return !game.installable
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -53,8 +63,8 @@ function matchesGroup (game: GameDto, group: string): boolean {
|
|||||||
* The categories, built from what the catalog actually contains.
|
* The categories, built from what the catalog actually contains.
|
||||||
*
|
*
|
||||||
* There is no genre in a WarpEngine catalog, so the useful axes are the state of a
|
* There is no genre in a WarpEngine catalog, so the useful axes are the state of a
|
||||||
* title on this machine, the platform it was built with, and whether it runs here or
|
* title on this machine, what this person may have of it, the platform it was built
|
||||||
* in a browser. Empty axes are left out rather than shown as zeroes, and an axis with
|
* with, and whether it runs here or in a browser. Empty axes are left out rather than shown as zeroes, and an axis with
|
||||||
* a single value is left out too — a filter that changes nothing is noise.
|
* a single value is left out too — a filter that changes nothing is noise.
|
||||||
*/
|
*/
|
||||||
export function buildCategorySections (
|
export function buildCategorySections (
|
||||||
@@ -68,7 +78,10 @@ export function buildCategorySections (
|
|||||||
{ kind: 'group', value: 'all', label: messages.catAll, count: games.length },
|
{ kind: 'group', value: 'all', label: messages.catAll, count: games.length },
|
||||||
{ kind: 'group', value: 'installed', label: messages.catInstalled, count: count((game: GameDto): boolean => game.installed) },
|
{ kind: 'group', value: 'installed', label: messages.catInstalled, count: count((game: GameDto): boolean => game.installed) },
|
||||||
{ kind: 'group', value: 'updates', label: messages.catUpdates, count: count((game: GameDto): boolean => game.updateAvailable) },
|
{ kind: 'group', value: 'updates', label: messages.catUpdates, count: count((game: GameDto): boolean => game.updateAvailable) },
|
||||||
{ kind: 'group', value: 'available', label: messages.catAvailable, count: count((game: GameDto): boolean => !game.installed) }
|
{ kind: 'group', value: 'available', label: messages.catAvailable, count: count((game: GameDto): boolean => game.installable && !game.installed) },
|
||||||
|
{ kind: 'group', value: 'owned', label: messages.catOwned, count: count((game: GameDto): boolean => game.accessVerdict === 'entitled') },
|
||||||
|
{ kind: 'group', value: 'purchasable', label: messages.catPurchasable, count: count((game: GameDto): boolean => game.accessVerdict === 'purchasable') },
|
||||||
|
{ kind: 'group', value: 'unsupported', label: messages.catUnsupported, count: count((game: GameDto): boolean => !game.installable) }
|
||||||
]
|
]
|
||||||
sections.push({
|
sections.push({
|
||||||
title: null,
|
title: null,
|
||||||
@@ -90,7 +103,8 @@ export function buildCategorySections (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const modes = [...new Set(games.map((game: GameDto): string => game.mode))]
|
const installable = games.filter((game: GameDto): boolean => game.installable)
|
||||||
|
const modes = [...new Set(installable.map((game: GameDto): string => game.mode))]
|
||||||
if (modes.length > 1) {
|
if (modes.length > 1) {
|
||||||
sections.push({
|
sections.push({
|
||||||
title: messages.catMode,
|
title: messages.catMode,
|
||||||
@@ -98,7 +112,7 @@ export function buildCategorySections (
|
|||||||
kind: 'mode',
|
kind: 'mode',
|
||||||
value: mode,
|
value: mode,
|
||||||
label: mode === 'web' ? messages.hosted : messages.native,
|
label: mode === 'web' ? messages.hosted : messages.native,
|
||||||
count: count((game: GameDto): boolean => game.mode === mode)
|
count: count((game: GameDto): boolean => game.installable && game.mode === mode)
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+163
-3
@@ -14,6 +14,22 @@
|
|||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* The scrollbars are part of the theme too: the platform's own are light, and a white
|
||||||
|
track down the side menu of a dark window looks like a mistake. */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #33414f transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #33414f;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #46586b; background-clip: content-box; }
|
||||||
|
|
||||||
/* An explicit `display` beats the browser's own [hidden] rule, and most of the
|
/* An explicit `display` beats the browser's own [hidden] rule, and most of the
|
||||||
regions here have one — the gate's store picker showed as an empty stub because
|
regions here have one — the gate's store picker showed as an empty stub because
|
||||||
of exactly that. This makes `hidden` mean hidden everywhere. */
|
of exactly that. This makes `hidden` mean hidden everywhere. */
|
||||||
@@ -69,7 +85,11 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
|||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
/* The icons sit at the end of the footer row, away from the log toggle. */
|
||||||
|
.side-tools { display: flex; align-items: center; gap: 4px; margin-left: auto; }
|
||||||
.side-head {
|
.side-head {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -151,8 +171,51 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
|||||||
padding: 8px 10px 2px;
|
padding: 8px 10px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-lang { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-dim); }
|
/*
|
||||||
.side-lang .select { margin-left: auto; }
|
* An icon-only control: a square the size of its glyph, quiet until pointed at.
|
||||||
|
* `position: relative` is what lets the language picker put its <select> on top.
|
||||||
|
*/
|
||||||
|
.icon-btn {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.icon-btn:hover:not(:disabled) { color: var(--ink); background: var(--panel-2); border-color: var(--line); }
|
||||||
|
.icon-btn:active:not(:disabled) { transform: scale(.94); }
|
||||||
|
.icon-btn:disabled { opacity: .4; cursor: default; }
|
||||||
|
/* The select swallows focus, so the ring has to be drawn on the label around it. */
|
||||||
|
.icon-btn:focus-visible,
|
||||||
|
.icon-btn:focus-within { color: var(--ink); border-color: var(--accent); outline: none; }
|
||||||
|
|
||||||
|
.icon { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.5;
|
||||||
|
stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The real <select>, stretched over the icon and invisible. It keeps the native
|
||||||
|
* dropdown — and its keyboard and screen-reader behaviour — while only the glyph shows.
|
||||||
|
*/
|
||||||
|
.locale-select {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- top bar ------------------------------------------------------------ */
|
/* --- top bar ------------------------------------------------------------ */
|
||||||
.bar {
|
.bar {
|
||||||
@@ -207,7 +270,7 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
|||||||
}
|
}
|
||||||
.link { color: var(--accent); cursor: pointer; text-decoration: underline; }
|
.link { color: var(--accent); cursor: pointer; text-decoration: underline; }
|
||||||
|
|
||||||
/* --- gate (no python, or no store yet) ---------------------------------- */
|
/* --- gate (no store yet) ---------------------------------- */
|
||||||
.gate {
|
.gate {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
@@ -219,6 +282,34 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
|||||||
.gate-actions { display: flex; gap: 14px; justify-content: center; align-items: center; flex-wrap: wrap; }
|
.gate-actions { display: flex; gap: 14px; justify-content: center; align-items: center; flex-wrap: wrap; }
|
||||||
.gate-choice { display: inline-flex; align-items: center; gap: 8px; color: var(--ink-dim); font-size: 13px; }
|
.gate-choice { display: inline-flex; align-items: center; gap: 8px; color: var(--ink-dim); font-size: 13px; }
|
||||||
|
|
||||||
|
/* --- signing in --------------------------------------------------------- */
|
||||||
|
/*
|
||||||
|
* The code, while somebody carries it to a browser. A band across the top of the
|
||||||
|
* content rather than a screen of its own: the sign-in is happening elsewhere, and
|
||||||
|
* there is no reason the catalog should stop being readable while it does.
|
||||||
|
*/
|
||||||
|
.signin {
|
||||||
|
margin: 18px 18px 0;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--panel);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.signin h2 { margin: 0 0 6px; font-size: 16px; }
|
||||||
|
.signin p { margin: 0 0 10px; color: var(--ink-dim); font-size: 13px; }
|
||||||
|
/* The one thing on screen somebody has to copy by eye, so: large, spaced, and
|
||||||
|
selectable — a code that cannot be highlighted is a code that has to be retyped. */
|
||||||
|
.signin-code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 28px;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
color: var(--ink);
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
.signin-waiting { font-size: 12px; }
|
||||||
|
.signin-actions { display: flex; gap: 10px; justify-content: center; }
|
||||||
|
|
||||||
/* --- the grid ----------------------------------------------------------- */
|
/* --- the grid ----------------------------------------------------------- */
|
||||||
.grid {
|
.grid {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -237,6 +328,11 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
|||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
.empty { margin: auto; color: var(--ink-dim); }
|
.empty { margin: auto; color: var(--ink-dim); }
|
||||||
|
/* A title somebody has not bought is not a broken one: the dimming and the dashed
|
||||||
|
border belong to what this *machine* cannot do, and there is nothing wrong with
|
||||||
|
the machine here. */
|
||||||
|
.card.is-purchasable { opacity: 1; }
|
||||||
|
.side-quiet-text { color: var(--ink-dim); font-size: 12px; margin: 0 0 8px; }
|
||||||
.gate { overflow-y: auto; }
|
.gate { overflow-y: auto; }
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
@@ -248,6 +344,12 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.card.is-installed { border-color: #2f5a49; }
|
.card.is-installed { border-color: #2f5a49; }
|
||||||
|
/* Listed, but not for this machine: dimmed rather than hidden, and it says why. */
|
||||||
|
.card.is-unavailable { opacity: .55; }
|
||||||
|
.card.is-unavailable:hover { opacity: .8; }
|
||||||
|
.card.is-unavailable .art { filter: grayscale(1); }
|
||||||
|
.badge-unavailable { color: var(--ink-dim); border-color: var(--line); border-style: dashed; }
|
||||||
|
.actions-note { font-size: 12px; color: var(--ink-dim); margin-top: auto; }
|
||||||
|
|
||||||
.art {
|
.art {
|
||||||
/* One height for every card, art or not, so the titles and the buttons line up
|
/* One height for every card, art or not, so the titles and the buttons line up
|
||||||
@@ -276,6 +378,14 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
|||||||
}
|
}
|
||||||
.badge-app { color: var(--accent); border-color: #2f5a49; }
|
.badge-app { color: var(--accent); border-color: #2f5a49; }
|
||||||
.badge-web { color: var(--warn); border-color: #5a4a2f; }
|
.badge-web { color: var(--warn); border-color: #5a4a2f; }
|
||||||
|
/* A price reads as a fact rather than a warning: same weight as the mode badges,
|
||||||
|
filled rather than outlined, so it is findable while scanning a row of cards. */
|
||||||
|
.badge-price {
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--panel-2);
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
.badge-owned { color: var(--accent); border-color: #2f5a49; }
|
||||||
.version { font-size: 12px; color: var(--ink-dim); margin-left: auto; }
|
.version { font-size: 12px; color: var(--ink-dim); margin-left: auto; }
|
||||||
.desc {
|
.desc {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -288,6 +398,56 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
|||||||
}
|
}
|
||||||
.actions { display: flex; gap: 8px; margin-top: auto; }
|
.actions { display: flex; gap: 8px; margin-top: auto; }
|
||||||
|
|
||||||
|
/* --- the card's actions menu -------------------------------------------- */
|
||||||
|
/*
|
||||||
|
* A <details> holding the two actions that are not the card's headline. The card is the
|
||||||
|
* positioning context, and the panel is pinned to the button's right edge so it opens
|
||||||
|
* inward rather than off the side of the grid.
|
||||||
|
*/
|
||||||
|
.menu { position: relative; margin-left: auto; }
|
||||||
|
.menu-toggle { list-style: none; }
|
||||||
|
/* Safari and Chrome each draw their own marker on a summary; both have to go. */
|
||||||
|
.menu-toggle::-webkit-details-marker { display: none; }
|
||||||
|
.menu-toggle::marker { content: ''; }
|
||||||
|
.menu[open] .menu-toggle { color: var(--ink); background: var(--panel-2); border-color: var(--line); }
|
||||||
|
|
||||||
|
.menu-items {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
z-index: 20;
|
||||||
|
min-width: 148px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 4px;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 28px rgb(0 0 0 / .45);
|
||||||
|
}
|
||||||
|
.menu-item {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.menu-item:hover:not(:disabled) { background: #2b3746; }
|
||||||
|
.menu-item:disabled { color: var(--ink-dim); opacity: .5; cursor: default; }
|
||||||
|
|
||||||
|
.icon-dots { fill: currentColor; stroke: none; }
|
||||||
|
|
||||||
|
/* An installed version with a newer one behind it: the arrow carries the news, so the
|
||||||
|
colour only has to make it findable in a grid. */
|
||||||
|
.version.has-update { color: var(--accent); font-weight: 600; }
|
||||||
|
|
||||||
|
|
||||||
/* --- log ----------------------------------------------------------------
|
/* --- log ----------------------------------------------------------------
|
||||||
No permanent footer: the panel is in the flow only while it is open, and the
|
No permanent footer: the panel is in the flow only while it is open, and the
|
||||||
switch for it sits in the side menu with everything else that is not a title. */
|
switch for it sits in the side menu with everything else that is not a title. */
|
||||||
|
|||||||
@@ -9,7 +9,26 @@ export class CatalogGridView {
|
|||||||
private readonly grid = requireElement('grid', HTMLElement)
|
private readonly grid = requireElement('grid', HTMLElement)
|
||||||
private readonly empty = requireElement('empty', HTMLElement)
|
private readonly empty = requireElement('empty', HTMLElement)
|
||||||
|
|
||||||
public constructor (private readonly cards: GameCardView) {}
|
public constructor (private readonly cards: GameCardView) {
|
||||||
|
this.closeMenusOnOutsideClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One listener for every card's actions menu.
|
||||||
|
*
|
||||||
|
* A `<details>` does not close when the pointer goes elsewhere, and a card cannot own
|
||||||
|
* this: cards are rebuilt on every render, so a listener per card would be a listener
|
||||||
|
* per render. The grid is created once, which makes it the right place for it.
|
||||||
|
*/
|
||||||
|
private closeMenusOnOutsideClick (): void {
|
||||||
|
document.addEventListener('click', (event: MouseEvent): void => {
|
||||||
|
const target = event.target
|
||||||
|
const clicked = target instanceof Node ? target : null
|
||||||
|
for (const menu of this.grid.querySelectorAll('details.menu[open]')) {
|
||||||
|
if (clicked === null || !menu.contains(clicked)) menu.removeAttribute('open')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
public render (state: AppState): void {
|
public render (state: AppState): void {
|
||||||
const shown = state.games.filter((game: GameDto): boolean => matchesFilter(game, state.filter))
|
const shown = state.games.filter((game: GameDto): boolean => matchesFilter(game, state.filter))
|
||||||
|
|||||||
@@ -4,15 +4,22 @@ import { createElement } from '../dom/Dom'
|
|||||||
|
|
||||||
export interface GameCardViewCallbacks {
|
export interface GameCardViewCallbacks {
|
||||||
readonly onInstall: (name: string) => void
|
readonly onInstall: (name: string) => void
|
||||||
|
readonly onUpgrade: (name: string) => void
|
||||||
readonly onLaunch: (name: string) => void
|
readonly onLaunch: (name: string) => void
|
||||||
readonly onRemove: (name: string) => void
|
readonly onRemove: (name: string) => void
|
||||||
|
/** Opens the catalog's own purchase page in the person's browser. */
|
||||||
|
readonly onPurchase: (name: string) => void
|
||||||
|
readonly onSignIn: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One card.
|
* One card.
|
||||||
*
|
*
|
||||||
* A card is a function of a title and the strings: it holds no state of its own, so
|
* A card is a function of a title and the strings: it holds no state of its own, so the
|
||||||
* the grid can throw the lot away and rebuild after every listing.
|
* grid can throw the lot away and rebuild after every listing. The one exception is the
|
||||||
|
* actions menu, whose open/closed state lives in a `<details>` element — and being
|
||||||
|
* thrown away is exactly what should happen to an open menu when the catalog changes
|
||||||
|
* under it.
|
||||||
*/
|
*/
|
||||||
export class GameCardView {
|
export class GameCardView {
|
||||||
public constructor (private readonly callbacks: GameCardViewCallbacks) {}
|
public constructor (private readonly callbacks: GameCardViewCallbacks) {}
|
||||||
@@ -20,6 +27,8 @@ export class GameCardView {
|
|||||||
public createCard (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
public createCard (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||||
const card = createElement('article', 'card')
|
const card = createElement('article', 'card')
|
||||||
if (game.installed) card.classList.add('is-installed')
|
if (game.installed) card.classList.add('is-installed')
|
||||||
|
if (!game.installable) card.classList.add('is-unavailable')
|
||||||
|
if (game.accessVerdict === 'purchasable') card.classList.add('is-purchasable')
|
||||||
card.appendChild(this.createArt(game))
|
card.appendChild(this.createArt(game))
|
||||||
card.appendChild(this.createBody(game, messages, busy))
|
card.appendChild(this.createBody(game, messages, busy))
|
||||||
return card
|
return card
|
||||||
@@ -54,39 +63,190 @@ export class GameCardView {
|
|||||||
|
|
||||||
private createMeta (game: GameDto, messages: MessageBundle): HTMLElement {
|
private createMeta (game: GameDto, messages: MessageBundle): HTMLElement {
|
||||||
const meta = createElement('div', 'meta')
|
const meta = createElement('div', 'meta')
|
||||||
|
if (game.installable) {
|
||||||
const mode = createElement('span', `badge badge-${game.mode}`,
|
const mode = createElement('span', `badge badge-${game.mode}`,
|
||||||
game.mode === 'web' ? messages.hosted : messages.native)
|
game.mode === 'web' ? messages.hosted : messages.native)
|
||||||
mode.title = game.mode === 'web' ? messages.hostedHint : messages.nativeHint
|
mode.title = game.mode === 'web' ? messages.hostedHint : messages.nativeHint
|
||||||
meta.appendChild(mode)
|
meta.appendChild(mode)
|
||||||
|
} else {
|
||||||
|
// Which of the two it is matters: a platform this store does not carry is a
|
||||||
|
// different disappointment from a game with no build for your machine.
|
||||||
|
const label = game.unavailableReason === 'platformOff'
|
||||||
|
? messages.unsupportedPlatform
|
||||||
|
: messages.unsupportedBuild
|
||||||
|
const badge = createElement('span', 'badge badge-unavailable', label)
|
||||||
|
badge.title = game.unavailableDetail ?? label
|
||||||
|
meta.appendChild(badge)
|
||||||
|
}
|
||||||
meta.appendChild(createElement('span', 'badge badge-plain', game.platform))
|
meta.appendChild(createElement('span', 'badge badge-plain', game.platform))
|
||||||
meta.appendChild(createElement('span', 'version',
|
// The price is where the mode badge is rather than down by the button: what a title
|
||||||
game.installed && game.installedVersion !== null
|
// costs is something a person scans a grid for, and a number that only appears
|
||||||
? `${game.installedVersion} · ${messages.installed}`
|
// beside a button is a number they have to hunt for card by card.
|
||||||
: game.version))
|
if (game.priceLabel !== null && game.accessVerdict !== 'entitled') {
|
||||||
|
meta.appendChild(createElement('span', 'badge badge-price', game.priceLabel))
|
||||||
|
}
|
||||||
|
if (game.accessVerdict === 'entitled') {
|
||||||
|
const owned = createElement('span', 'badge badge-owned', messages.owned)
|
||||||
|
owned.title = messages.ownedHint
|
||||||
|
meta.appendChild(owned)
|
||||||
|
}
|
||||||
|
meta.appendChild(this.createVersion(game, messages))
|
||||||
return meta
|
return meta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The version, and what the catalog has that this machine does not.
|
||||||
|
*
|
||||||
|
* Both numbers, when they differ: which version is installed is the thing a person
|
||||||
|
* came to the card to find out, and "there is a newer one" is only meaningful next to
|
||||||
|
* it. Where they agree, the second number would be noise.
|
||||||
|
*/
|
||||||
|
private createVersion (game: GameDto, messages: MessageBundle): HTMLElement {
|
||||||
|
if (!game.installed || game.installedVersion === null) {
|
||||||
|
return createElement('span', 'version', game.version)
|
||||||
|
}
|
||||||
|
if (!game.updateAvailable) {
|
||||||
|
return createElement('span', 'version', `${game.installedVersion} · ${messages.installed}`)
|
||||||
|
}
|
||||||
|
const version = createElement('span', 'version has-update',
|
||||||
|
`${game.installedVersion} → ${game.version}`)
|
||||||
|
version.title = messages.updateAvailable
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
private createActions (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
private createActions (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||||
const actions = createElement('div', 'actions')
|
const actions = createElement('div', 'actions')
|
||||||
const primary = createElement('button', 'btn btn-primary')
|
|
||||||
primary.disabled = busy
|
|
||||||
|
|
||||||
if (game.installed && !game.updateAvailable) {
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not yours yet: the card sells rather than installs. Deliberately a live button
|
||||||
|
// and not a dimmed one — the dimmed treatment above is for what this *machine*
|
||||||
|
// cannot do, and there is nothing wrong with this machine.
|
||||||
|
if (!game.installed && game.accessVerdict === 'purchasable') {
|
||||||
|
actions.appendChild(this.createPurchase(game, messages, busy))
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
// The catalog would know, if it knew who was asking.
|
||||||
|
if (!game.installed && game.accessVerdict === 'signInRequired') {
|
||||||
|
actions.appendChild(this.createSignIn(messages, busy))
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.appendChild(this.createPrimary(game, messages, busy))
|
||||||
|
// Only an installed title has anything in the menu: nothing to upgrade and nothing
|
||||||
|
// to uninstall until there is something on the disk.
|
||||||
|
if (game.installed) actions.appendChild(this.createMenu(game, messages, busy))
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buy it — which happens in a browser, not here.
|
||||||
|
*
|
||||||
|
* Payment is the store's business and its own web pages already do it; a checkout
|
||||||
|
* rebuilt in this window would be a second place to get card handling wrong. After
|
||||||
|
* buying, Refresh is what turns the card into an Install.
|
||||||
|
*/
|
||||||
|
private createPurchase (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||||
|
const button = createElement('button', 'btn btn-primary btn-purchase')
|
||||||
|
button.textContent = game.priceLabel === null
|
||||||
|
? messages.purchase
|
||||||
|
: `${messages.purchase} · ${game.priceLabel}`
|
||||||
|
button.disabled = busy || game.purchaseUrl === null
|
||||||
|
button.title = messages.purchaseHint
|
||||||
|
button.addEventListener('click', (): void => { this.callbacks.onPurchase(game.name) })
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSignIn (messages: MessageBundle, busy: boolean): HTMLElement {
|
||||||
|
const button = createElement('button', 'btn btn-secondary')
|
||||||
|
button.textContent = messages.signInToInstall
|
||||||
|
button.disabled = busy
|
||||||
|
button.title = messages.signInToInstallHint
|
||||||
|
button.addEventListener('click', (): void => { this.callbacks.onSignIn() })
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one action a card leads with.
|
||||||
|
*
|
||||||
|
* For an installed title that is playing it — including when an upgrade is waiting,
|
||||||
|
* because the version on the disk still runs and wanting to play it is not the same
|
||||||
|
* as wanting to wait for a download.
|
||||||
|
*/
|
||||||
|
private createPrimary (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||||
|
const primary = createElement('button', 'btn btn-primary')
|
||||||
|
if (game.installed) {
|
||||||
primary.textContent = game.mode === 'web' ? messages.open : messages.play
|
primary.textContent = game.mode === 'web' ? messages.open : messages.play
|
||||||
primary.disabled = busy || !game.launchable
|
primary.disabled = busy || !game.launchable
|
||||||
primary.addEventListener('click', (): void => { this.callbacks.onLaunch(game.name) })
|
primary.addEventListener('click', (): void => { this.callbacks.onLaunch(game.name) })
|
||||||
} else {
|
return primary
|
||||||
primary.textContent = game.updateAvailable ? messages.update : messages.install
|
}
|
||||||
|
primary.textContent = messages.install
|
||||||
|
primary.disabled = busy
|
||||||
primary.addEventListener('click', (): void => { this.callbacks.onInstall(game.name) })
|
primary.addEventListener('click', (): void => { this.callbacks.onInstall(game.name) })
|
||||||
|
return primary
|
||||||
}
|
}
|
||||||
actions.appendChild(primary)
|
|
||||||
|
|
||||||
if (game.installed) {
|
/**
|
||||||
const remove = createElement('button', 'btn btn-ghost', messages.remove)
|
* The three-dot menu: upgrade, and uninstall.
|
||||||
remove.disabled = busy
|
*
|
||||||
remove.addEventListener('click', (): void => { this.callbacks.onRemove(game.name) })
|
* A `<details>` rather than a scripted popover, so the open state is the DOM's and the
|
||||||
actions.appendChild(remove)
|
* keyboard works without being taught to. Upgrade is present but disabled when there
|
||||||
|
* is nothing newer — a menu whose items appear and disappear makes a person hunt for
|
||||||
|
* the one they used last time, and "greyed out" already says "not now".
|
||||||
|
*/
|
||||||
|
private createMenu (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||||
|
const menu = createElement('details', 'menu')
|
||||||
|
const toggle = createElement('summary', 'icon-btn menu-toggle')
|
||||||
|
toggle.title = messages.moreActions
|
||||||
|
toggle.setAttribute('aria-label', messages.moreActions)
|
||||||
|
toggle.appendChild(this.createDotsIcon())
|
||||||
|
menu.appendChild(toggle)
|
||||||
|
|
||||||
|
const items = createElement('div', 'menu-items')
|
||||||
|
items.appendChild(this.createMenuItem(messages.upgrade, busy || !game.updateAvailable,
|
||||||
|
(): void => { this.callbacks.onUpgrade(game.name) }))
|
||||||
|
items.appendChild(this.createMenuItem(messages.uninstall, busy,
|
||||||
|
(): void => { this.callbacks.onRemove(game.name) }))
|
||||||
|
menu.appendChild(items)
|
||||||
|
return menu
|
||||||
}
|
}
|
||||||
return actions
|
|
||||||
|
private createMenuItem (
|
||||||
|
label: string,
|
||||||
|
disabled: boolean,
|
||||||
|
perform: () => void
|
||||||
|
): HTMLButtonElement {
|
||||||
|
const item = createElement('button', 'menu-item', label)
|
||||||
|
item.disabled = disabled
|
||||||
|
item.addEventListener('click', (): void => {
|
||||||
|
// Close before acting: the click starts work that re-renders the grid, and a menu
|
||||||
|
// left open would vanish mid-gesture rather than answer the press.
|
||||||
|
item.closest('details')?.removeAttribute('open')
|
||||||
|
perform()
|
||||||
|
})
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
private createDotsIcon (): SVGSVGElement {
|
||||||
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||||
|
svg.setAttribute('class', 'icon icon-dots')
|
||||||
|
svg.setAttribute('viewBox', '0 0 16 16')
|
||||||
|
svg.setAttribute('aria-hidden', 'true')
|
||||||
|
svg.setAttribute('focusable', 'false')
|
||||||
|
for (const y of [3.5, 8, 12.5]) {
|
||||||
|
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
|
||||||
|
dot.setAttribute('cx', '8')
|
||||||
|
dot.setAttribute('cy', String(y))
|
||||||
|
dot.setAttribute('r', '1.35')
|
||||||
|
svg.appendChild(dot)
|
||||||
|
}
|
||||||
|
return svg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface SideMenuViewCallbacks {
|
|||||||
readonly onRefresh: () => void
|
readonly onRefresh: () => void
|
||||||
readonly onSelectCategory: (filter: CategoryFilter) => void
|
readonly onSelectCategory: (filter: CategoryFilter) => void
|
||||||
readonly onSelectLocale: (locale: string) => void
|
readonly onSelectLocale: (locale: string) => void
|
||||||
|
readonly onSignIn: () => void
|
||||||
|
readonly onSignOut: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,30 +23,51 @@ export interface SideMenuViewCallbacks {
|
|||||||
*/
|
*/
|
||||||
export class SideMenuView {
|
export class SideMenuView {
|
||||||
private readonly storesHead = requireElement('head-stores', HTMLElement)
|
private readonly storesHead = requireElement('head-stores', HTMLElement)
|
||||||
private readonly actionsHead = requireElement('head-actions', HTMLElement)
|
|
||||||
private readonly categoriesHead = requireElement('head-cats', HTMLElement)
|
private readonly categoriesHead = requireElement('head-cats', HTMLElement)
|
||||||
private readonly languageHead = requireElement('head-lang', HTMLElement)
|
|
||||||
private readonly storeList = requireElement('store-list', HTMLElement)
|
private readonly storeList = requireElement('store-list', HTMLElement)
|
||||||
private readonly addStore = requireElement('add-store', HTMLButtonElement)
|
private readonly addStore = requireElement('add-store', HTMLButtonElement)
|
||||||
private readonly refresh = requireElement('refresh', HTMLButtonElement)
|
private readonly refresh = requireElement('refresh', HTMLButtonElement)
|
||||||
private readonly categories = requireElement('cats', HTMLElement)
|
private readonly categories = requireElement('cats', HTMLElement)
|
||||||
|
private readonly accountBlock = requireElement('account-block', HTMLElement)
|
||||||
|
private readonly accountHead = requireElement('head-account', HTMLElement)
|
||||||
|
private readonly accountState = requireElement('account-state', HTMLElement)
|
||||||
|
private readonly accountAction = requireElement('account-action', HTMLButtonElement)
|
||||||
private readonly locale = requireElement('locale', HTMLSelectElement)
|
private readonly locale = requireElement('locale', HTMLSelectElement)
|
||||||
|
/** The square around the select: it is what a pointer hovers, so the tooltip is its. */
|
||||||
|
private readonly localeControl = requireElement('locale-control', HTMLElement)
|
||||||
|
|
||||||
public constructor (private readonly callbacks: SideMenuViewCallbacks) {
|
public constructor (private readonly callbacks: SideMenuViewCallbacks) {
|
||||||
this.addStore.addEventListener('click', callbacks.onAddStore)
|
this.addStore.addEventListener('click', callbacks.onAddStore)
|
||||||
this.refresh.addEventListener('click', callbacks.onRefresh)
|
this.refresh.addEventListener('click', callbacks.onRefresh)
|
||||||
this.locale.addEventListener('change', (): void => { callbacks.onSelectLocale(this.locale.value) })
|
this.locale.addEventListener('change', (): void => { callbacks.onSelectLocale(this.locale.value) })
|
||||||
|
this.accountAction.addEventListener('click', (): void => {
|
||||||
|
if (this.signedIn) this.callbacks.onSignOut()
|
||||||
|
else this.callbacks.onSignIn()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of the two the one button does.
|
||||||
|
*
|
||||||
|
* Read at click time rather than rebound on every render: a listener replaced under
|
||||||
|
* a pointer that is already down is a click that goes nowhere.
|
||||||
|
*/
|
||||||
|
private signedIn = false
|
||||||
|
|
||||||
public render (state: AppState): void {
|
public render (state: AppState): void {
|
||||||
setText(this.storesHead, state.messages.stores)
|
setText(this.storesHead, state.messages.stores)
|
||||||
setText(this.actionsHead, state.messages.actions)
|
|
||||||
setText(this.categoriesHead, state.messages.categories)
|
setText(this.categoriesHead, state.messages.categories)
|
||||||
setText(this.languageHead, state.messages.language)
|
|
||||||
setText(this.addStore, state.messages.addStore)
|
setText(this.addStore, state.messages.addStore)
|
||||||
setText(this.refresh, state.messages.refresh)
|
// Refresh and the language picker are icons: naming them is all the view does, and
|
||||||
|
// writing text into them would replace the glyph.
|
||||||
|
describeControl(this.refresh, state.messages.refresh)
|
||||||
|
// The picker is two elements: the invisible select takes the focus and the reader's
|
||||||
|
// name, the square around it takes the hover and the tooltip.
|
||||||
|
describeControl(this.locale, state.messages.language)
|
||||||
|
this.localeControl.title = state.messages.language
|
||||||
|
|
||||||
this.renderStores(state)
|
this.renderStores(state)
|
||||||
|
this.renderAccount(state)
|
||||||
this.renderCategories(state)
|
this.renderCategories(state)
|
||||||
this.renderLocales(state)
|
this.renderLocales(state)
|
||||||
this.renderEnabled(state)
|
this.renderEnabled(state)
|
||||||
@@ -74,6 +97,27 @@ export class SideMenuView {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account block, or nothing at all.
|
||||||
|
*
|
||||||
|
* Hidden outright where the catalog offers no sign-in — which is most of them. A
|
||||||
|
* disabled "Sign in" would be telling somebody about a door that is not there.
|
||||||
|
*/
|
||||||
|
private renderAccount (state: AppState): void {
|
||||||
|
this.accountBlock.hidden = !state.account.signInAvailable
|
||||||
|
if (!state.account.signInAvailable) return
|
||||||
|
|
||||||
|
this.signedIn = state.account.signedIn
|
||||||
|
setText(this.accountHead, state.messages.account)
|
||||||
|
setText(this.accountState, state.account.signedIn ? state.messages.signedIn : '')
|
||||||
|
setText(this.accountAction, state.account.signedIn
|
||||||
|
? state.messages.signOut
|
||||||
|
: state.messages.signIn)
|
||||||
|
// A sign-in already under way has its own panel with a cancel on it; a second
|
||||||
|
// "Sign in" here would start a second flow behind the first one's code.
|
||||||
|
this.accountAction.disabled = state.busy || state.signIn !== null
|
||||||
|
}
|
||||||
|
|
||||||
private renderCategories (state: AppState): void {
|
private renderCategories (state: AppState): void {
|
||||||
const sections = buildCategorySections(state.games, state.messages)
|
const sections = buildCategorySections(state.games, state.messages)
|
||||||
const nodes: HTMLElement[] = []
|
const nodes: HTMLElement[] = []
|
||||||
@@ -117,3 +161,14 @@ export class SideMenuView {
|
|||||||
for (const row of this.storeList.querySelectorAll('button')) row.disabled = state.busy
|
for (const row of this.storeList.querySelectorAll('button')) row.disabled = state.busy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name an icon-only control.
|
||||||
|
*
|
||||||
|
* `title` is the tooltip a mouse finds and `aria-label` is what a screen reader reads;
|
||||||
|
* an icon button needs both, and they are the same sentence.
|
||||||
|
*/
|
||||||
|
function describeControl (element: HTMLElement, name: string): void {
|
||||||
|
element.title = name
|
||||||
|
element.setAttribute('aria-label', name)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { requireElement, setText } from '../dom/Dom'
|
||||||
|
import type { AppState } from '../state/AppStore'
|
||||||
|
|
||||||
|
export interface SignInViewCallbacks {
|
||||||
|
readonly onOpenPage: () => void
|
||||||
|
readonly onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The code, while somebody takes it to a browser.
|
||||||
|
*
|
||||||
|
* A panel over the catalog rather than a screen of its own: the sign-in is happening
|
||||||
|
* somewhere else, and there is no reason this window should stop being useful while it
|
||||||
|
* does. Everything on it is one of three things — the code, a way back to the page for
|
||||||
|
* whoever closed the tab, and a way out.
|
||||||
|
*/
|
||||||
|
export class SignInView {
|
||||||
|
private readonly panel = requireElement('signin', HTMLElement)
|
||||||
|
private readonly title = requireElement('signin-title', HTMLElement)
|
||||||
|
private readonly body = requireElement('signin-body', HTMLElement)
|
||||||
|
private readonly code = requireElement('signin-code', HTMLElement)
|
||||||
|
private readonly waiting = requireElement('signin-waiting', HTMLElement)
|
||||||
|
private readonly openPage = requireElement('signin-open', HTMLButtonElement)
|
||||||
|
private readonly cancel = requireElement('signin-cancel', HTMLButtonElement)
|
||||||
|
|
||||||
|
public constructor (callbacks: SignInViewCallbacks) {
|
||||||
|
this.openPage.addEventListener('click', callbacks.onOpenPage)
|
||||||
|
this.cancel.addEventListener('click', callbacks.onCancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
public render (state: AppState): void {
|
||||||
|
const prompt = state.signIn
|
||||||
|
if (prompt === null) {
|
||||||
|
this.panel.hidden = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeName = state.currentStore?.name ?? state.messages.appName
|
||||||
|
setText(this.title, state.messages.signInTitle.replace('%{store}', storeName))
|
||||||
|
setText(this.body, state.messages.signInBody)
|
||||||
|
setText(this.code, prompt.userCode)
|
||||||
|
setText(this.waiting, state.messages.signInWaiting)
|
||||||
|
setText(this.openPage, state.messages.signInOpenAgain)
|
||||||
|
setText(this.cancel, state.messages.signInCancel)
|
||||||
|
this.panel.hidden = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,8 +18,9 @@ export class TopBarView {
|
|||||||
|
|
||||||
public render (state: AppState): void {
|
public render (state: AppState): void {
|
||||||
setText(this.appName, state.messages.appName)
|
setText(this.appName, state.messages.appName)
|
||||||
// The badge is a bordered pill: empty, it renders as a stub next to the title.
|
// The bar names the app, not the store: which store is open is what the switcher in
|
||||||
setHidden(this.storeId, state.currentStore === null)
|
// the side menu says, and saying it twice made the title read like a breadcrumb. The
|
||||||
|
// element stays in the page, hidden, because the window check reads it.
|
||||||
setText(this.storeId, state.currentStore === null ? '' : state.currentStore.id)
|
setText(this.storeId, state.currentStore === null ? '' : state.currentStore.id)
|
||||||
this.navToggle.title = state.messages.menu
|
this.navToggle.title = state.messages.menu
|
||||||
this.navToggle.setAttribute('aria-label', state.messages.menu)
|
this.navToggle.setAttribute('aria-label', state.messages.menu)
|
||||||
|
|||||||
+83
-52
@@ -3,15 +3,13 @@ import path from 'node:path'
|
|||||||
import { GameDtoMapper } from '../application/mappers/GameDtoMapper'
|
import { GameDtoMapper } from '../application/mappers/GameDtoMapper'
|
||||||
import type { CatalogListing } from '../domain/models/CatalogListing'
|
import type { CatalogListing } from '../domain/models/CatalogListing'
|
||||||
import type { InstalledStore } from '../domain/models/InstalledStore'
|
import type { InstalledStore } from '../domain/models/InstalledStore'
|
||||||
import type { RegistryStore } from '../domain/models/RegistryStore'
|
|
||||||
import { DESKTOP_STORE_ENGINE } from '../domain/models/StoreEngine'
|
import { DESKTOP_STORE_ENGINE } from '../domain/models/StoreEngine'
|
||||||
import { deriveStoreId } from '../domain/models/StoreIdentity'
|
import { deriveStoreId } from '../domain/models/StoreIdentity'
|
||||||
|
import type { CredentialRepository } from '../domain/ports/CredentialRepository'
|
||||||
|
import { NativeStoreCatalogGateway } from '../infrastructure/engine/NativeStoreCatalogGateway'
|
||||||
import { HttpTextClient } from '../infrastructure/http/HttpTextClient'
|
import { HttpTextClient } from '../infrastructure/http/HttpTextClient'
|
||||||
import { PythonEngineProcessRunner } from '../infrastructure/process/PythonEngineProcessRunner'
|
|
||||||
import { SystemPythonRuntimeLocator } from '../infrastructure/process/SystemPythonRuntimeLocator'
|
|
||||||
import { FileSystemInstalledStoreRepository } from '../infrastructure/repositories/FileSystemInstalledStoreRepository'
|
import { FileSystemInstalledStoreRepository } from '../infrastructure/repositories/FileSystemInstalledStoreRepository'
|
||||||
import { HttpStoreRegistryRepository } from '../infrastructure/repositories/HttpStoreRegistryRepository'
|
import { HttpStoreRegistryRepository } from '../infrastructure/repositories/HttpStoreRegistryRepository'
|
||||||
import { PythonStoreCatalogGateway } from '../infrastructure/repositories/PythonStoreCatalogGateway'
|
|
||||||
import type { GameDto } from '../shared/contracts/dto/GameDto'
|
import type { GameDto } from '../shared/contracts/dto/GameDto'
|
||||||
import { ENGLISH_MESSAGES } from '../shared/i18n/EnglishMessages'
|
import { ENGLISH_MESSAGES } from '../shared/i18n/EnglishMessages'
|
||||||
import { HUNGARIAN_MESSAGES } from '../shared/i18n/HungarianMessages'
|
import { HUNGARIAN_MESSAGES } from '../shared/i18n/HungarianMessages'
|
||||||
@@ -20,21 +18,27 @@ import { LOCALES } from '../shared/i18n/MessageBundle'
|
|||||||
/**
|
/**
|
||||||
* Drives the store with no window and no Electron at all.
|
* Drives the store with no window and no Electron at all.
|
||||||
*
|
*
|
||||||
|
* Since the engine is part of this application, this exercises the real thing rather
|
||||||
|
* than a child process: a bad release choice or a broken menu entry fails here.
|
||||||
|
*
|
||||||
* This is the second composition root, and the reason the layers are worth having:
|
* This is the second composition root, and the reason the layers are worth having:
|
||||||
* the same services the window uses are assembled here against the same ports, so an
|
* the same services the window uses are assembled here against the same ports, so an
|
||||||
* integration mistake shows up in a terminal rather than in a screenshot.
|
* integration mistake shows up in a terminal rather than in a screenshot.
|
||||||
*
|
*
|
||||||
* npm run smoke the store on this machine
|
* npm run smoke the store on this machine
|
||||||
* SMOKE_HOME=/path/to/store-home npm run smoke a sandbox store
|
* SMOKE_HOME=/path/to/store-home npm run smoke a sandbox store
|
||||||
|
* SMOKE_TOKEN=<bearer> npm run smoke as a signed-in person
|
||||||
|
*
|
||||||
|
* `SMOKE_TOKEN` exists because the signed-in path is otherwise untestable here: the
|
||||||
|
* real credential store is the OS keychain, reached through Electron, and there is no
|
||||||
|
* Electron in this process. Without it a gated catalog can only ever be read as an
|
||||||
|
* anonymous caller, and "owned" and "not owned" never happen.
|
||||||
*/
|
*/
|
||||||
class SmokeTest {
|
class SmokeTest {
|
||||||
private failed = false
|
private failed = false
|
||||||
|
|
||||||
private readonly stores = new FileSystemInstalledStoreRepository()
|
private readonly stores = new FileSystemInstalledStoreRepository()
|
||||||
private readonly pythonLocator = new SystemPythonRuntimeLocator()
|
private readonly catalogGateway = new NativeStoreCatalogGateway(envCredentials())
|
||||||
private readonly catalogGateway = new PythonStoreCatalogGateway(
|
|
||||||
new PythonEngineProcessRunner(this.pythonLocator)
|
|
||||||
)
|
|
||||||
private readonly httpClient = new HttpTextClient()
|
private readonly httpClient = new HttpTextClient()
|
||||||
private readonly registry = new HttpStoreRegistryRepository(this.httpClient)
|
private readonly registry = new HttpStoreRegistryRepository(this.httpClient)
|
||||||
private readonly gameMapper = new GameDtoMapper()
|
private readonly gameMapper = new GameDtoMapper()
|
||||||
@@ -42,7 +46,6 @@ class SmokeTest {
|
|||||||
public async run (): Promise<number> {
|
public async run (): Promise<number> {
|
||||||
console.log('warp-engine-client smoke test')
|
console.log('warp-engine-client smoke test')
|
||||||
|
|
||||||
if (!this.checkPython()) return 1
|
|
||||||
this.checkMessages()
|
this.checkMessages()
|
||||||
await this.checkRegistry()
|
await this.checkRegistry()
|
||||||
|
|
||||||
@@ -53,16 +56,6 @@ class SmokeTest {
|
|||||||
return this.failed ? 1 : 0
|
return this.failed ? 1 : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkPython (): boolean {
|
|
||||||
const runtime = this.pythonLocator.findRuntime()
|
|
||||||
if (runtime === null) {
|
|
||||||
this.reportBad('python', 'not found — the store cannot run')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
this.reportOk('python', runtime.version)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The bundles are typed against one key set, so this only counts them. */
|
/** The bundles are typed against one key set, so this only counts them. */
|
||||||
private checkMessages (): void {
|
private checkMessages (): void {
|
||||||
const english = Object.keys(ENGLISH_MESSAGES).length
|
const english = Object.keys(ENGLISH_MESSAGES).length
|
||||||
@@ -79,34 +72,17 @@ class SmokeTest {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.reportOk('registry', `${String(stores.length)} store(s) from ${this.registry.sourceUrl}`)
|
this.reportOk('registry', `${String(stores.length)} store(s) from ${this.registry.sourceUrl}`)
|
||||||
|
// The slug is worth printing: it names the store home and the games subfolder, and
|
||||||
|
// it is derived here rather than told to us, so a wrong catalog URL shows up as a
|
||||||
|
// wrong folder name before anything is installed.
|
||||||
for (const store of stores) {
|
for (const store of stores) {
|
||||||
this.reportOk(` ${store.name}`, `${store.catalogUrl} · ${deriveStoreId(store)}`)
|
this.reportOk(` ${store.name}`, `${store.catalogUrl} · ${deriveStoreId(store)}`)
|
||||||
await this.checkStoreConfig(store)
|
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.reportBad('registry', `${this.registry.sourceUrl}: ${this.describe(error)}`)
|
this.reportBad('registry', `${this.registry.sourceUrl}: ${this.describe(error)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A store needs no repository, and a repository needs no config.json: either way
|
|
||||||
* the engine's defaults carry it. So both absences are reported, not failed.
|
|
||||||
*/
|
|
||||||
private async checkStoreConfig (store: RegistryStore): Promise<void> {
|
|
||||||
if (store.storeRepositoryUrl === null) {
|
|
||||||
this.reportOk(' config', 'no repository — the engine defaults would be used')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const url = `${store.storeRepositoryUrl.replace(/\/+$/, '')}/raw/branch/master/config.json`
|
|
||||||
try {
|
|
||||||
const config: unknown = JSON.parse(await this.httpClient.readText(url))
|
|
||||||
const sections = typeof config === 'object' && config !== null ? Object.keys(config).length : 0
|
|
||||||
this.reportOk(' config.json', `${String(sections)} sections`)
|
|
||||||
} catch (error: unknown) {
|
|
||||||
this.reportOk(' config.json', `absent (${this.describe(error)}) — defaults would be used`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private findStore (): InstalledStore | null {
|
private findStore (): InstalledStore | null {
|
||||||
const sandbox = process.env['SMOKE_HOME']
|
const sandbox = process.env['SMOKE_HOME']
|
||||||
if (sandbox !== undefined && sandbox.length > 0) {
|
if (sandbox !== undefined && sandbox.length > 0) {
|
||||||
@@ -115,16 +91,13 @@ class SmokeTest {
|
|||||||
id: path.basename(home).replace(DESKTOP_STORE_ENGINE.homeSuffix, ''),
|
id: path.basename(home).replace(DESKTOP_STORE_ENGINE.homeSuffix, ''),
|
||||||
name: path.basename(home),
|
name: path.basename(home),
|
||||||
home,
|
home,
|
||||||
scriptPath: path.join(home, DESKTOP_STORE_ENGINE.scriptFileName),
|
|
||||||
configPath: path.join(home, 'config.json'),
|
configPath: path.join(home, 'config.json'),
|
||||||
engine: DESKTOP_STORE_ENGINE.id
|
engine: DESKTOP_STORE_ENGINE.id
|
||||||
}
|
}
|
||||||
for (const file of [store.scriptPath, store.configPath]) {
|
if (!fs.existsSync(store.configPath)) {
|
||||||
if (!fs.existsSync(file)) {
|
this.reportBad('SMOKE_HOME', `${store.configPath} is missing`)
|
||||||
this.reportBad('SMOKE_HOME', `${file} is missing`)
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
|
||||||
this.reportOk('store (SMOKE_HOME)', store.home)
|
this.reportOk('store (SMOKE_HOME)', store.home)
|
||||||
return store
|
return store
|
||||||
}
|
}
|
||||||
@@ -143,11 +116,6 @@ class SmokeTest {
|
|||||||
const logLines: string[] = []
|
const logLines: string[] = []
|
||||||
const progress = { onLog: (line: string): void => { logLines.push(line) } }
|
const progress = { onLog: (line: string): void => { logLines.push(line) } }
|
||||||
|
|
||||||
const engine = this.catalogGateway.readEngineVersion(store)
|
|
||||||
if (engine === null) this.reportBad('engine', 'the store did not answer --version')
|
|
||||||
else if (!engine.supported) this.reportBad('engine', `${engine.text} is too old for this client`)
|
|
||||||
else this.reportOk('engine', engine.text)
|
|
||||||
|
|
||||||
const paths = await this.catalogGateway.readPaths(store, progress)
|
const paths = await this.catalogGateway.readPaths(store, progress)
|
||||||
if (paths.operatingSystem.length > 0 && paths.storeFolder.length > 0) {
|
if (paths.operatingSystem.length > 0 && paths.storeFolder.length > 0) {
|
||||||
this.reportOk('paths', `${paths.operatingSystem} → ${paths.storeFolder}`)
|
this.reportOk('paths', `${paths.operatingSystem} → ${paths.storeFolder}`)
|
||||||
@@ -162,14 +130,61 @@ class SmokeTest {
|
|||||||
}
|
}
|
||||||
const games = this.gameMapper.toDtoList(listing.games, listing.paths?.catalogBaseUrl ?? '')
|
const games = this.gameMapper.toDtoList(listing.games, listing.paths?.catalogBaseUrl ?? '')
|
||||||
this.reportListing(games)
|
this.reportListing(games)
|
||||||
|
this.reportAccess(listing, games)
|
||||||
|
|
||||||
if (logLines.length > 0) this.reportOk('stderr log', `${String(logLines.length)} lines (kept off stdout)`)
|
if (logLines.length > 0) this.reportOk('stderr log', `${String(logLines.length)} lines (kept off stdout)`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the catalog said about who may have what.
|
||||||
|
*
|
||||||
|
* The load-bearing case is the boring one: an older engine says nothing, every title
|
||||||
|
* comes back `open`, and the client behaves exactly as it did before any of this
|
||||||
|
* existed. A gated catalog is where the rest of it starts mattering.
|
||||||
|
*/
|
||||||
|
private reportAccess (listing: CatalogListing, games: readonly GameDto[]): void {
|
||||||
|
const account = listing.account
|
||||||
|
this.reportOk('sign-in', account.signInAvailable
|
||||||
|
? (account.signedIn ? 'offered, and signed in' : 'offered, not signed in')
|
||||||
|
: 'not offered by this catalog')
|
||||||
|
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const game of games) {
|
||||||
|
counts.set(game.accessVerdict, (counts.get(game.accessVerdict) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
const summary = [...counts.entries()]
|
||||||
|
.map(([verdict, count]: readonly [string, number]): string => `${verdict}:${String(count)}`)
|
||||||
|
.join(', ')
|
||||||
|
this.reportOk('access', summary)
|
||||||
|
|
||||||
|
// A price with nothing to click, or a purchase button with no price, is a card
|
||||||
|
// somebody cannot act on.
|
||||||
|
const unbuyable = games.filter((game: GameDto): boolean =>
|
||||||
|
game.accessVerdict === 'purchasable' && game.purchaseUrl === null)
|
||||||
|
if (unbuyable.length > 0) {
|
||||||
|
this.reportBad('purchase links', `${String(unbuyable.length)} priced titles have nowhere to buy them`)
|
||||||
|
} else if ((counts.get('purchasable') ?? 0) > 0) {
|
||||||
|
this.reportOk('purchase links', 'every priced title has one')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private reportListing (games: readonly GameDto[]): void {
|
private reportListing (games: readonly GameDto[]): void {
|
||||||
const native = games.filter((game: GameDto): boolean => game.mode === 'app').length
|
const installable = games.filter((game: GameDto): boolean => game.installable)
|
||||||
const hosted = games.length - native
|
const native = installable.filter((game: GameDto): boolean => game.mode === 'app').length
|
||||||
this.reportOk('list', `${String(games.length)} titles (app:${String(native)}, web:${String(hosted)})`)
|
const hosted = installable.length - native
|
||||||
|
this.reportOk('list', `${String(games.length)} titles ` +
|
||||||
|
`(app:${String(native)}, web:${String(hosted)}, unavailable:${String(games.length - installable.length)})`)
|
||||||
|
|
||||||
|
// The listing is supposed to carry what it cannot install, with a reason on each.
|
||||||
|
const unavailable = games.filter((game: GameDto): boolean => !game.installable)
|
||||||
|
const unexplained = unavailable.filter((game: GameDto): boolean => game.unavailableReason === null)
|
||||||
|
if (unexplained.length > 0) this.reportBad('unavailable', `${String(unexplained.length)} have no reason`)
|
||||||
|
else if (unavailable.length > 0) {
|
||||||
|
const first = unavailable[0]
|
||||||
|
if (first !== undefined) {
|
||||||
|
this.reportOk('unavailable', `${String(unavailable.length)}, e.g. ${first.name}: ${first.unavailableReason ?? ''}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const withoutTitle = games.filter((game: GameDto): boolean =>
|
const withoutTitle = games.filter((game: GameDto): boolean =>
|
||||||
game.name.length === 0 || game.title.length === 0 || game.platform.length === 0)
|
game.name.length === 0 || game.title.length === 0 || game.platform.length === 0)
|
||||||
@@ -211,3 +226,19 @@ void new SmokeTest().run().then(
|
|||||||
process.exitCode = 1
|
process.exitCode = 1
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The token from the environment, for every store.
|
||||||
|
*
|
||||||
|
* Deliberately not per store: this is a test harness pointed at one catalog at a time,
|
||||||
|
* and a keyed map here would be ceremony around a single value. Nothing writes — a
|
||||||
|
* smoke run must not leave a credential behind on the machine that ran it.
|
||||||
|
*/
|
||||||
|
function envCredentials (): CredentialRepository {
|
||||||
|
const token = process.env['SMOKE_TOKEN'] ?? ''
|
||||||
|
return {
|
||||||
|
readToken: (): string | null => (token.length > 0 ? token : null),
|
||||||
|
writeToken: (storeId: string, value: string): void => { void storeId; void value },
|
||||||
|
clearToken: (storeId: string): void => { void storeId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { AccountDto, SignInFinishedDto, SignInPromptDto } from './dto/AccountDto'
|
||||||
import type { AppStateDto } from './dto/AppStateDto'
|
import type { AppStateDto } from './dto/AppStateDto'
|
||||||
import type { CatalogListingDto } from './dto/CatalogListingDto'
|
import type { CatalogListingDto } from './dto/CatalogListingDto'
|
||||||
import type { InstalledStoreDto } from './dto/InstalledStoreDto'
|
import type { InstalledStoreDto } from './dto/InstalledStoreDto'
|
||||||
@@ -30,6 +31,12 @@ export interface BridgeApi {
|
|||||||
removeGame: (name: string) => Promise<void>
|
removeGame: (name: string) => Promise<void>
|
||||||
launchGame: (name: string) => Promise<boolean>
|
launchGame: (name: string) => Promise<boolean>
|
||||||
|
|
||||||
|
readAccount: () => Promise<AccountDto>
|
||||||
|
/** Answers with the code to show; how it ended arrives on `onSignInFinished`. */
|
||||||
|
beginSignIn: () => Promise<SignInPromptDto>
|
||||||
|
cancelSignIn: () => Promise<void>
|
||||||
|
signOut: () => Promise<AccountDto>
|
||||||
|
|
||||||
listRegistryStores: () => Promise<RegistryResultDto>
|
listRegistryStores: () => Promise<RegistryResultDto>
|
||||||
installStore: (store: RegistryStoreDto) => Promise<InstalledStoreDto>
|
installStore: (store: RegistryStoreDto) => Promise<InstalledStoreDto>
|
||||||
selectStore: (home: string) => Promise<StoreSelectionDto>
|
selectStore: (home: string) => Promise<StoreSelectionDto>
|
||||||
@@ -40,6 +47,7 @@ export interface BridgeApi {
|
|||||||
onLog: (listener: StreamListener<string>) => void
|
onLog: (listener: StreamListener<string>) => void
|
||||||
onSyncEvent: (listener: StreamListener<SyncEventDto>) => void
|
onSyncEvent: (listener: StreamListener<SyncEventDto>) => void
|
||||||
onBusyChanged: (listener: StreamListener<boolean>) => void
|
onBusyChanged: (listener: StreamListener<boolean>) => void
|
||||||
|
onSignInFinished: (listener: StreamListener<SignInFinishedDto>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The name the bridge is published under on `window`. */
|
/** The name the bridge is published under on `window`. */
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ export const IPC_CHANNELS = {
|
|||||||
catalogRemoveGame: 'catalog:removeGame',
|
catalogRemoveGame: 'catalog:removeGame',
|
||||||
catalogLaunchGame: 'catalog:launchGame',
|
catalogLaunchGame: 'catalog:launchGame',
|
||||||
|
|
||||||
|
accountRead: 'account:read',
|
||||||
|
accountBeginSignIn: 'account:beginSignIn',
|
||||||
|
accountCancelSignIn: 'account:cancelSignIn',
|
||||||
|
accountSignOut: 'account:signOut',
|
||||||
|
|
||||||
storeListRegistry: 'store:listRegistry',
|
storeListRegistry: 'store:listRegistry',
|
||||||
storeInstallStore: 'store:installStore',
|
storeInstallStore: 'store:installStore',
|
||||||
storeSelectStore: 'store:selectStore',
|
storeSelectStore: 'store:selectStore',
|
||||||
@@ -26,7 +31,9 @@ export const IPC_CHANNELS = {
|
|||||||
/** Main to renderer, one way. */
|
/** Main to renderer, one way. */
|
||||||
streamLog: 'stream:log',
|
streamLog: 'stream:log',
|
||||||
streamSyncEvent: 'stream:syncEvent',
|
streamSyncEvent: 'stream:syncEvent',
|
||||||
streamBusyChanged: 'stream:busyChanged'
|
streamBusyChanged: 'stream:busyChanged',
|
||||||
|
/** How a sign-in ended, once the browser half is done. */
|
||||||
|
streamSignInFinished: 'stream:signInFinished'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]
|
export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/** Where this machine stands with one store. */
|
||||||
|
export interface AccountDto {
|
||||||
|
readonly signInAvailable: boolean
|
||||||
|
readonly signedIn: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What to show while somebody finishes signing in in their browser. */
|
||||||
|
export interface SignInPromptDto {
|
||||||
|
/** The short code, read off this screen and typed into a browser. */
|
||||||
|
readonly userCode: string
|
||||||
|
readonly verificationUrl: string
|
||||||
|
readonly expiresInSeconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignInOutcomeDto = 'signedIn' | 'denied' | 'expired' | 'cancelled'
|
||||||
|
|
||||||
|
/** The end of a sign-in, pushed to the window when the waiting is over. */
|
||||||
|
export interface SignInFinishedDto {
|
||||||
|
readonly outcome: SignInOutcomeDto
|
||||||
|
readonly account: AccountDto
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Locale } from '../../i18n/MessageBundle'
|
import type { Locale } from '../../i18n/MessageBundle'
|
||||||
import type { MessageBundle } from '../../i18n/MessageBundle'
|
import type { MessageBundle } from '../../i18n/MessageBundle'
|
||||||
import type { EngineVersionDto } from './EngineVersionDto'
|
|
||||||
import type { InstalledStoreDto } from './InstalledStoreDto'
|
import type { InstalledStoreDto } from './InstalledStoreDto'
|
||||||
|
|
||||||
/** Everything the window needs before it can paint anything. */
|
/** Everything the window needs before it can paint anything. */
|
||||||
@@ -9,12 +8,8 @@ export interface AppStateDto {
|
|||||||
readonly locales: readonly Locale[]
|
readonly locales: readonly Locale[]
|
||||||
readonly messages: MessageBundle
|
readonly messages: MessageBundle
|
||||||
readonly navigationOpen: boolean
|
readonly navigationOpen: boolean
|
||||||
/** The Python 3 version string, or null when there is none to drive the store. */
|
|
||||||
readonly pythonVersion: string | null
|
|
||||||
readonly currentStore: InstalledStoreDto | null
|
readonly currentStore: InstalledStoreDto | null
|
||||||
readonly stores: readonly InstalledStoreDto[]
|
readonly stores: readonly InstalledStoreDto[]
|
||||||
readonly engine: EngineVersionDto | null
|
|
||||||
readonly minimumEngineVersion: string
|
|
||||||
readonly registryUrl: string
|
readonly registryUrl: string
|
||||||
readonly defaultStoreRoot: string
|
readonly defaultStoreRoot: string
|
||||||
readonly appVersion: string
|
readonly appVersion: string
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user