6 Commits
Author SHA1 Message Date
mr.zeroandClaude Opus 5 3d42355189 The smoke test can be somebody
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
Without a token the signed-in half of a gated catalog is untestable here: the
real credential store is the OS keychain reached through Electron, and there is
no Electron in this process, so every title comes back `signInRequired` and
"owned" and "not owned" never happen.

SMOKE_TOKEN supplies one. Against a live Orbit it now reports
`open:1, purchasable:1, entitled:1` — the free title, the one this account has
not bought, and the one it has — which is the first end-to-end proof that the
access block survives the whole path from the engine's policy to a card.

It only ever reads. A smoke run must not leave a credential on the machine that
ran it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:11:08 +02:00
mr.zeroandClaude Opus 5 26c7aa9be1 A catalog that can say a title is not yours
ci/woodpecker/push/woodpecker Pipeline was successful
A store with paid titles had nothing to tell this client and no way for it to
listen: the catalog carried no price, no entitlement and no sign-in, so a gated
download could only come back 403 and leave the window guessing why.

The knowledge belongs on the server, not here. This client serves whichever
catalog a registry names, so anything it knew about a particular shop would be
a rule that breaks every other one. WarpEngine 0.5 answers GET /api/service with
what it offers and puts an `access` block on every entry; this reads both. There
is no store name anywhere in the diff.

- **0.5 is a dialect of its own**, the older shape with `access` added. The
  version list is exhaustive over the selector, so adding it was a compile error
  until somebody said what it reads like — which is what that switch is for.
- **A card shows a price and a Buy button** when a title is not yours, opening
  the store's own page. Buying stays in a browser: a checkout rebuilt here would
  be a second place to get card handling wrong.
- **Signing in is the device grant**: a short code, the person's own browser, and
  no password crossing this window. The token goes in the OS keychain through
  safeStorage — one per store — and where no keychain exists it is not stored at
  all rather than written out in the clear.
- **Owned / To buy** join the categories, since owning something is not the same
  as having installed it.

Three things worth stating about the shape:

The bearer token stops at the origin that issued it. A gated download redirects
to signed storage — often somebody else's host — and some object stores refuse a
request outright when an Authorization header arrives alongside the signature.

An absent access block is not "free". It is an engine too old to have an
opinion, and only one of those two is a reason to offer somebody a sign-in, so
the three states are kept apart all the way to the card.

state.json does not carry entitlement. Whether somebody may download a title is
the server's answer to a question asked now; a copy on disk would go stale on the
next purchase or refund, and a stale yes is the dangerous direction.

A store with no sign-in shows none, and every WarpEngine before 0.5 is such a
store: no Account block, no prices, no new categories. The smoke test against the
live catalog reports exactly that — `sign-in: not offered`, `access: open:13`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:03:54 +02:00
mr.zeroandClaude Opus 5 e35a72336a Upgrade from the card, behind a three-dot menu
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
An installed title's version line now reads `0.1 → 0.3` where the catalog has moved on,
so a card answers both questions somebody brings to it: what is installed, and is there
anything better. Which version is installed was already recorded — `state.json` has
always carried it — what was missing was anywhere to act on it.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 06:43:39 +02:00
mr.zeroandClaude Opus 5 06f3f2a3b1 The store engine moves into the client, and Python goes with it
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
Reading the catalog, choosing the release that fits this machine, unpacking it,
writing the menu entry and remembering what went where all happen in process now.
There is no interpreter to find, no child process, and no JSON-lines protocol
between the two halves — `PythonEngineProcessRunner`, the runtime locator, the two
engine mappers and the version negotiation are all gone, and with them the one
unchecked cast this codebase had (engine stdout to a typed event).

What that buys a person: on Windows and on a fresh Mac the app simply works. It
used to look for `python3`, `python` and `py -3` and draw a link to python.org
where none answered.

What lands on disk is unchanged, deliberately. `config.json` and `state.json` keep
the shell engine's snake_case shape, its `<scope>:<name>` keys and its file modes,
so a machine whose library was installed by the CLI keeps it — verified against the
Python engine on the same catalog: the same 13-title listing with zero field
differences, byte-identical payloads, identical modes and an identical Info.plist,
and a re-sync over a Python-installed home that writes nothing. Remove, prune,
prune-suppression on a named sync and the v1 state migration were each exercised.

Three things worth knowing about the new code:

  - the zip reader is ~150 lines over `node:zlib`, because Node has none and this
    application has no runtime dependencies. It restores the executable bit from
    each entry's external attributes, without which nothing installed can start,
    and it refuses zip64, unknown compression and paths that escape the
    destination rather than guessing;

  - `SUPPORTED_WARP_ENGINE_VERSIONS` names the engine versions this client is
    written against, checked against the `WarpEngine-Version` header every
    response carries. `selectCatalogDialect` switches over that list exhaustively,
    so adding a version fails the build — type checker and linter both — until
    somebody says what its catalog reads like. An absent header is read as the
    oldest version, which is what an engine before 0.4.0 is;

  - refresh and the language picker are icons at the foot of the side menu now,
    both named for a tooltip and a screen reader, the picker still a real
    `<select>` under its glyph.

The repository is free of Python as well: the Makefile, the CI check and the
release script read package.json and the forge's JSON with Node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:36:25 +02:00
103 changed files with 5303 additions and 1053 deletions
+3 -8
View File
@@ -27,14 +27,9 @@ steps:
- npm run typecheck
- npm run lint
# The window test wants a display and a store on the machine; that check belongs
# where there is one. The bridge check is worth running here: it exercises the
# registry and the message bundles.
- |
if command -v python3 >/dev/null 2>&1; then
npm run smoke
else
echo "no python3 in the image — the smoke test needs it, skipping"
fi
# where there is one. The bridge check runs here unconditionally — it needs nothing
# but Node, now that the store engine is part of the application.
- npm run smoke
# 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.
+5 -4
View File
@@ -18,8 +18,10 @@ SHELL := /bin/sh
SCRIPTS := scripts
NODE_MIN := 22
# The version is package.json's, so the release tag never drifts from the app.
VERSION := $(shell python3 -c 'import json; print(json.load(open("package.json"))["version"])')
# The version is package.json's, so the release tag never drifts from the app. Read with
# 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)
# Which site's store registry a packaged build reads. Empty means the default in
@@ -116,6 +118,5 @@ version: ## Show the versions involved
@printf "typescript "; npx tsc --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 "python3 "; python3 --version 2>/dev/null || echo "missing"
@printf "registry "; python3 -c 'import json; print(json.load(open("package.json"))["warpEngine"]["registryUrl"])'
@printf "registry "; node -p 'require("./package.json").warpEngine.registryUrl'
@if [ -n "$(STORES_API)" ]; then printf " build override: %s\n" "$(STORES_API)"; fi
+142 -52
View File
@@ -1,18 +1,18 @@
# warp-engine-client — the WarpEngine Client app
The graphical client for a WarpEngine store the app is called **WarpEngine
Store** — driving
[`warp-engine-desktop-store`](https://git.teletypegames.org/stores/warp-engine-desktop-store)
underneath: the catalog as a grid of cards, one click to install a title into your
own application menu, one to play it, one to remove it. Linux, macOS and Windows.
The client for a WarpEngine store: the catalog as a grid of cards, one click to install
a title into your own application menu, one to play it, one to remove it. Linux, macOS
and Windows.
The CLI stays the product; this is its front door. Every action here runs
`desktop_store.py`, so there is one catalog logic, one state file and one delete
guard — the window never touches the filesystem itself.
**The store engine is part of this application.** Reading the catalog, choosing which
release fits this machine, unpacking it, writing the menu entry and remembering what
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
`curl … | sh`, which Windows does not have; this app downloads the store engine
itself, into the same folder the shell installer would use.
That also makes this the only install path that needs nothing of the machine. The shell
store's installer was `curl … | sh`, which Windows does not have.
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
@@ -20,10 +20,8 @@ catalog it serves and where its configuration lives.
## What it needs
- **Python 3** on the machine, because the store is a Python program. The app
looks for `python3`, `python` and `py -3`, and says so plainly if none answer.
- Nothing else at runtime. No Node, no package manager, no admin rights: the
store installs under your own user account.
- **Nothing.** No interpreter, no package manager, no admin rights: the app carries its
own runtime and the store installs under your own user account.
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`.
@@ -48,8 +46,8 @@ If macOS offers *Open Anyway* under **System Settings ▸ Privacy & Security** a
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.
Nothing the store itself downloads is affected: Python fetches those files, and
Python does not set the quarantine flag.
Nothing the store itself downloads is affected: the app fetches those files over its
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
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
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
On first run the client fetches the registry and offers what it finds. One store
@@ -64,35 +101,34 @@ and there is nothing to decide; several and the setup screen shows a picker.
```json
[
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org", "storeRepositoryUrl": null },
{
"name": "Some Other Store",
"catalogUrl": "https://games.example.org",
"storeRepositoryUrl": "https://git.example.org/stores/other-desktop-store"
}
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org" },
{ "name": "Some Other Store", "catalogUrl": "https://games.example.org" }
]
```
**A store needs no repository of its own.** A name and a catalog are enough: the
store engine's built-in defaults already cover the host-to-asset mapping, the
install modes, the platforms and the behaviour, so what is actually missing from
them is identity — a slug, a name and a catalog URL — and that is exactly what a
registry record carries. With `storeRepositoryUrl` null the client writes a
three-section config and the store installs.
**A name and a catalog are the whole record.** The store engine's built-in defaults
already cover the host-to-asset mapping, the install modes, the platforms and the
behaviour, so what is actually missing from them is identity — and identity is all a
registry says. Nothing a record carries decides where files go: how a store behaves is
fixed per installed client, which knows its own machine, and a copy of that on a server
would be a second authority over decisions this side has already made.
From a record the client works out the rest:
- **the store id** — which names the store home and the folder games land in —
comes from the repository name when there is one (`ttg-desktop-store` becomes
`ttg`), otherwise from the catalog host (`teletypegames.org` becomes
`teletypegames`), otherwise from the display name. A `config.json` that sets its
own id keeps it.
- **`catalogUrl` and `name`** override the config's own `store.base_url` and
`store.name`. The registry says which catalog this store is *for*, so it wins.
- **`storeRepositoryUrl`**, when given → the store's `config.json`, read from
`…/raw/branch/master/config.json`. That file stays the authority on how the store
behaves: which platforms, which statuses, where things land. A repository
**without** a `config.json` is treated as no repository at all.
- **the store id** — which names the store home and the folder games land in — is a slug
of the catalog host (`teletypegames.org` becomes `teletypegames`), or of the display
name if that fails. Derived from the *catalog* on purpose: the catalog is what a store
is, so two records naming the same one are the same store and land in the same place.
Reinstalling therefore never orphans what is already installed.
- **the games folder** is that same slug inside the OS's usual place for programs, and it
is the only subtree this store will ever delete from. That is the whole of how two
stores on one machine stay out of each other's files: a subfolder, derived here.
- **released, archived and demo** titles are listed, where the engine alone would show
released and archived only — a catalog that publishes a demo means it to be played.
Because a record has no paths in it and no config, there is nothing for the window to
tamper with: `RegistryStoreDtoMapper.toModel` can take its choice at face value, and the
config that lands on disk is written by the installer from the engine's own defaults.
What the defaults produce, for a record with no repository: the games land in a
folder named after the store id, and released, archived **and demo** titles are
@@ -125,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
the id would not tell them apart. **Add a store…** brings up the registry
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
one at a time from their own cards; there is no install-everything button.
- **Categories** narrows the grid, one category at a time, with the count next to
each: *Everything*, *Installed*, *Updates*, *Not installed*, then a row per
**platform** (`godot`, `tic80`, `love`, …) and per **kind** (native or hosted).
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
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
@@ -180,6 +219,7 @@ names. `make` on its own lists everything.
| `make check` | **typecheck, lint and both test suites** — the gate |
| `make start` | run the app against whatever store is installed |
| `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 |
| `SELFTEST_SHOT=shot.png npm run uitest` | the same, and the window photographs itself into that file |
| `make test` | both test suites |
@@ -275,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
command succeeded immediately afterwards.
Package names contain a space`WarpEngine Client-1.5.0-arm64.dmg`so the list of
files is passed one path per line rather than as one string; splitting it on
whitespace is what broke the first attempt at publishing 1.1.0.
Package names have no spaces in them`WarpEngineClient-2.3.0-arm64.dmg`because a
space in a release asset is a space in every `curl`, script and shell command that ever
touches it. The app itself is still called **WarpEngine Client**: that name is what
appears in the Dock and in `/Applications`, and only the file names were the problem.
The list of files is still passed one path per line rather than as one string, since a
path given on the command line can contain a space even when a built one cannot;
splitting it on whitespace is what broke the first attempt at publishing 1.1.0.
It needs `tea` installed and logged in — the devarea repo has `make tea` for that.
Overridable: `TAG`, `REPO`, `TEA_LOGIN`, `NOTES`, `DIST`.
@@ -311,9 +356,9 @@ is the map** — the layers, every pattern in use, and the naming rules. The sho
| Layer | What lives there |
|---|---|
| `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/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/preload/` | the bridge, bundled into one file — a sandboxed preload cannot require modules |
| `src/renderer/` | the state store, the views and the renderer controllers |
@@ -334,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
in the real browser; the window itself never navigates.
`PythonStoreCatalogGateway` is the only class that knows the store is a Python
program. It talks to the CLI through `--json`, which puts data on stdout and the
human-readable log on stderr. That flag arrived with engine **1.1.0**, and the client
checks: an older store is met with an offer to refresh it rather than a failed call.
`NativeStoreCatalogGateway` is the engine behind the `StoreCatalogGateway` port, and
`src/infrastructure/engine/` is the engine itself: the catalog client, the release
picker, the host match, the payload installer, the three launcher writers and the state
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,
so a second entry is the whole change needed to drive it too — that is why the table is
there.
`STORE_ENGINES` has one entry today. A RetroArch store writes playlists rather than
menu entries, so it would be an entry there and a gateway of its own.
### 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
**2.2.0** — the registry record was cut back to a name and a catalog, so the whole
install path was measured again against a local registry serving exactly that. The slug
came out `teletypegames` from the catalog host, the home `teletypegames-desktop`, the
games subfolder `teletypegames`, and installing the same record twice landed in the same
home. A record carrying `config` and `storeRepositoryUrl` — the fields a stale client or a
tampering renderer might still send — changed nothing, because neither exists in the model
any more. The site side was migrated and its specs re-run; the frontend was built, which
first required removing a dead `engines` list that had been failing `vue-tsc` on master.
Older entries below describe what was verified for the version they name, and some of
them predate the store engine moving into this application.
The pipeline's commands were run in the same containers it uses, before the pipeline was
committed: `electronuserland/builder:22` installs, type-checks, lints, passes the smoke
test (registry reached, store skipped as it should be on a machine that has none) and
+28 -49
View File
@@ -1,55 +1,34 @@
# WarpEngine Client 1.5.0
# WarpEngine Client 2.4.0
**The app is called WarpEngine Client.** It was WarpEngine Store, which named the thing
it opens rather than the thing you run — and the store is a catalog on a site, not a
window on your machine. The window title, the packages, the bundle and the menu entry all
say Client now; the repository already did. The store the window is driving is named in
the side menu, so the bar no longer repeats it as a badge.
**The catalog can now say a title is not yours, and the client can do something about
it.** Where a store sells things, a card shows the **price** and a **Buy** button that
opens the store's own page; where you own it, an **Install** as before. Two new
categories go with it — **Owned** and **To buy** — because owning something is not the
same as having installed it.
**Every title in the catalog is listed, including the ones this machine cannot install.**
A C64 cartridge on a desktop, or a game with no build for your operating system, used to
be silently absent — leaving you to wonder whether the catalog is small or your machine
is unusual. Those cards are now there, dimmed, with an *unsupported platform* or *no
build for this machine* badge and the engine's own sentence underneath, and with nothing
to press. They have a category of their own, *Not for this machine*, and they stay out of
the native/hosted counts: a title with no build has no mode to be counted under.
**Signing in, without a password ever reaching this window.** The side menu grows an
**Account** block, and signing in shows a short code: your browser opens on the store's
own page and you type it there. That detour is the point — a desktop application asking
for a password is a desktop application people should not be giving one to. The token
lives in the OS keychain (Keychain, libsecret, DPAPI), one per store, and *Sign out*
revokes it at the server as well as forgetting it here.
This needs store engine **1.2.0** (desktop) on **warpstore 1.4.0**, which report what
they had to leave out and why. An older engine still works — its listing is simply all
installable, as it was before.
**None of this is knowledge about any particular store.** It all arrives from the
catalog's own server: WarpEngine 0.5 answers `GET /api/service` with what it offers, and
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.
**A build can be pointed at another site's registry.**
**A store with no sign-in shows none.** Every WarpEngine before 0.5 has no descriptor at
all, and a 0.5 store that sells nothing reports none either. Both read as "a plain
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.
```sh
make dist STORES_API=https://games.example.org/api/stores
```
**A title nobody has bought is not dimmed.** The dimming and the dashed badge belong to
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.
The address is written into the packaged app's own `package.json`, so a client built for
somebody else's catalog needs no source change and nothing set on the user's machine. A
runtime `STORES_API` still wins, which is for trying something out rather than shipping.
### Also
The scrollbars are the window's own now: the platform's light track down the side menu of
a dark window looked like a mistake.
### Opening it on macOS
Ad-hoc signed, **not notarised**, so macOS asks first:
```sh
xattr -dr com.apple.quarantine "/Applications/WarpEngine Client.app"
```
### What is attached
The macOS package, built and verified on a Mac, plus the Linux (AppImage, deb) and
Windows (installer, portable) packages the pipeline builds when the tag is pushed.
### Verified
`make check` is clean. The store on this machine lists 13 titles — ten installable, three
C64 cartridges this store does not carry — and the window was photographed with all
thirteen on screen, the three dimmed and labelled. The engine change was measured through
the CLI as well, in both its human and its `--json` listing, and the other two store
engines still get the two-value answer they ask for.
**A bearer token is never sent to a host that did not issue it.** A gated download
answers with a redirect to signed storage, often somebody else's server, and some object
stores refuse a request outright when an `Authorization` header rides along with the
signature. The credential stops at the origin it belongs to; a redirect back to the
catalog keeps it.
+45 -23
View File
@@ -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)
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
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
preload ← the bridge, and only the bridge
renderer ← the window: state store, views, controllers
@@ -57,13 +57,19 @@ src/
services/ CatalogService, StoreSelectionService, …
mappers/ domain → DTO
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
mappers/ engine JSON → domain
http/ HttpTextClient, HttpStatusError
http/ HttpTextClient, StoreHttpClient, HttpStatusError
json/ JsonRecord: reading data that came from elsewhere
config/ BuildConfiguration: what was decided when this was packaged
electron/ ApplicationEnvironment and GameLauncher adapters
electron/ ApplicationEnvironment, GameLauncher, and the keychain
credential store
main/
main.ts the entry point: one line of work
ElectronApplication.ts lifecycle, single instance, self-test mode
@@ -95,9 +101,13 @@ on this list, it belongs on this list.
### Ports and adapters
`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
the Python CLI, the registry HTTP call and Electron's `shell` replaceable — by a stub
in a test, by a local endpoint in development, by a second engine later.
root is the only file that knows which implementation is in use. This is what makes the
store engine, the registry HTTP call and Electron's `shell` replaceable — by a stub in a
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
@@ -105,8 +115,10 @@ Both are ports; the distinction is what is behind them.
- **Repository** — a store of records this application owns the shape of:
`InstalledStoreRepository`, `PreferencesRepository`, `StoreRegistryRepository`.
- **Gateway** — another program or service with its own protocol:
`StoreCatalogGateway` (the engine).
- **Gateway** — something with a protocol of its own, whether or not it is another
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
@@ -119,8 +131,11 @@ controller or a view.
Data crossing a boundary is a DTO, and a mapper converts. Two boundaries, two
directions:
- `infrastructure/mappers/Engine*Mapper` — engine JSON (snake_case) → domain model.
These are the only files that know the engine's field names.
- `infrastructure/engine/dialects/*` — catalog JSON → typed catalog records. These are
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
window must not make live here: the absolute box-art URL, whether a title can be
launched at all.
@@ -176,8 +191,8 @@ never calls the bridge.
### Error hierarchy with codes
`DomainError` is abstract with a `code`; subclasses name a single failure
(`PythonMissingError`, `StoreMissingError`, `EngineInvocationError`,
`RegistryUnavailableError`, `BusyError`). The code is what crosses the bridge.
(`StoreMissingError`, `EngineInvocationError`, `RegistryUnavailableError`, `BusyError`).
The code is what crosses the bridge.
### Frozen constant tables
@@ -195,7 +210,8 @@ trying it out, someone shipping a client for another site, us.
### 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
fallback out. No `as` casts on foreign data.
@@ -219,7 +235,7 @@ The names are a pattern, not a preference, and are checked by
|---|---|---|
| Domain model | plain noun, no suffix | `Game`, `InstalledStore` |
| Port | `<Subject>Repository` / `Gateway` / `Locator` / `Installer` / `Launcher` | `StoreCatalogGateway` |
| Adapter | `<Technology><Port>` | `PythonStoreCatalogGateway`, `HttpStoreRegistryRepository`, `FileSystemInstalledStoreRepository` |
| Adapter | `<Technology><Port>` | `NativeStoreCatalogGateway`, `HttpStoreRegistryRepository`, `FileSystemInstalledStoreRepository` |
| Service | `<Area>Service` | `CatalogService` |
| Mapper | `<Subject>Mapper` / `<Subject>DtoMapper` | `EngineGameMapper`, `GameDtoMapper` |
| Wire type | `<Subject>Dto` | `CatalogListingDto` |
@@ -227,7 +243,7 @@ The names are a pattern, not a preference, and are checked by
| Renderer controller | `<Area>Controller` | `StoreController` |
| View | `<Region>View` | `SideMenuView`, `GameCardView` |
| Factory | `<Product>Factory` | `MainWindowFactory` |
| Error | `<Cause>Error` | `PythonMissingError` |
| Error | `<Cause>Error` | `StoreMissingError` |
| Callback bag | `<Owner>Callbacks` | `SideMenuViewCallbacks` |
| Type parameter | `T`-prefixed | `TResult`, `TElement` |
@@ -281,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
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
discovery, the home suffix and the launcher name all read from that table; the CLI has
the same command shape, so `PythonStoreCatalogGateway` is unchanged.
**A new engine (e.g. RetroArch).** Add an entry to `STORE_ENGINES` — the store discovery,
the home suffix and the launcher name all read from that table — and a `StoreCatalogGateway`
implementation for that host. Everything above the port is unchanged.
**A new field from the engine.** `EngineGameMapper` reads it into the model, `GameDto`
and `GameDtoMapper` carry it across if the window needs it, and a view renders it.
**A new WarpEngine version.** Add it to `SUPPORTED_WARP_ENGINE_VERSIONS`. The build then
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
to `LOCALES` and the bundle to `TranslationCatalog`. A missing key will not compile.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "warp-engine-client",
"version": "1.2.0",
"version": "2.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "warp-engine-client",
"version": "1.2.0",
"version": "2.4.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^26.2.0",
+15 -2
View File
@@ -1,7 +1,7 @@
{
"name": "warp-engine-client",
"productName": "WarpEngine Client",
"version": "1.5.0",
"version": "2.4.0",
"description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.",
"license": "MIT",
"author": "Teletype Games <games@teletype.hu>",
@@ -44,7 +44,11 @@
"target": [
"dmg",
"zip"
]
],
"artifactName": "WarpEngineClient-${version}-${arch}-mac.${ext}"
},
"dmg": {
"artifactName": "WarpEngineClient-${version}-${arch}.${ext}"
},
"win": {
"target": [
@@ -52,6 +56,12 @@
"portable"
]
},
"nsis": {
"artifactName": "WarpEngineClient-Setup-${version}-${arch}.${ext}"
},
"portable": {
"artifactName": "WarpEngineClient-Portable-${version}-${arch}.${ext}"
},
"linux": {
"category": "Game",
"target": [
@@ -59,6 +69,9 @@
"deb"
]
},
"appImage": {
"artifactName": "WarpEngineClient-${version}-${arch}.${ext}"
},
"afterPack": "scripts/after-pack.js"
},
"allowScripts": {
+4 -3
View File
@@ -53,9 +53,10 @@ api() {
curl -fsS -X "$method" -H "$AUTH" "$FORGE$path" "$@"
}
# Package names contain spaces — "WarpEngine Client Setup 1.5.0.exe" does — so the list
# lives one path per line in a file and is read with `while IFS= read -r`. A single
# variable looped over with $list splits on the space and uploads nothing.
# The list lives one path per line in a file and is read with `while IFS= read -r`. The
# built package names have no spaces in them any more, but a path given on the command
# line still can — and a single variable looped over with $list splits on the space and
# uploads nothing, which is a silent way to publish a release with no assets.
LIST="$(mktemp)"
trap 'rm -f "$LIST"' EXIT
if [ "$#" -gt 0 ]; then
+30 -38
View File
@@ -20,23 +20,14 @@ say() { echo "[release] $*"; }
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 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"
# Version and title in one go, through a heredoc rather than a quoted one-liner:
# nesting python quoting inside shell quoting inside a command substitution is how
# this produced an empty title on its first outing.
VERSION="$(python3 - <<'PY'
import json
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
)"
# Version and title from package.json, read with node. This project needs no interpreter
# beyond the one it already builds with — the app itself carries no Python any more, and
# neither should the script that ships it.
VERSION="$(node -p 'require("./package.json").version')"
TITLE="$(node -p 'const d = require("./package.json"); (d.productName || d.name) + " " + d.version')"
TAG="${TAG:-v$VERSION}"
[ -n "$VERSION" ] || die "cannot read the version from package.json"
[ -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
# and a release would quietly get the previous version's files attached;
# - the spaces. "WarpEngine Client-1.5.0-arm64.dmg" has one, so the list lives one
# path per line in a file and is read with `while IFS= read -r`. Holding it in
# a single variable and looping over $list splits it on the space.
# - the spaces. The built names have none since 2.3.0 — `WarpEngineClient-2.3.0-arm64.dmg`
# — but a path given as an argument still can, so the list stays one path per line in
# a file, read with `while IFS= read -r`. Holding it in a single variable and looping
# over $list splits it on the space, and publishes nothing.
LIST="$(mktemp)"
trap 'rm -f "$LIST"' EXIT
if [ "$#" -gt 0 ]; then
@@ -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
# 404 — it answers {"message":"not found"} — so the body is what has to be read.
release_id() {
tea api "/repos/$REPO/releases/tags/$TAG" 2>/dev/null | python3 -c '
import json, sys
try:
print(json.load(sys.stdin).get("id") or "")
except Exception:
pass
tea api "/repos/$REPO/releases/tags/$TAG" 2>/dev/null | node -e '
try {
console.log(JSON.parse(require("fs").readFileSync(0, "utf8")).id || "")
} catch {
// Not JSON, or no such release: an empty answer is the "no release yet" case.
}
'
}
@@ -103,13 +95,12 @@ RELEASE_ID="$(release_id)"
# refusing is what makes a rebuild-and-upload repeatable. Called before every
# attempt, so a retry cannot leave two copies behind.
drop_existing() {
ids="$(tea api "/repos/$REPO/releases/$RELEASE_ID/assets" | python3 -c "
import json, sys
name = sys.argv[1]
for a in json.load(sys.stdin):
if a['name'] == name:
print(a['id'])
" "$1")"
ids="$(tea api "/repos/$REPO/releases/$RELEASE_ID/assets" | node -e '
const name = process.argv[1]
for (const asset of JSON.parse(require("fs").readFileSync(0, "utf8"))) {
if (asset.name === name) console.log(asset.id)
}
' -- "$1")"
for id in $ids; do
say "replacing $1"
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
[ -f "$asset" ] || die "no such file: $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
# "invalid username, password or token" on the second package while the first
@@ -140,9 +131,10 @@ while IFS= read -r asset; do
done < "$LIST"
say "done:"
tea api "/repos/$REPO/releases/$RELEASE_ID" | python3 -c "
import json, sys
r = json.load(sys.stdin)
for a in r.get('assets') or []:
print(f\" {a['name']} {a['size']/1e6:.0f} MB\")
print(f\" {r['html_url']}\")"
tea api "/repos/$REPO/releases/$RELEASE_ID" | node -e '
const release = JSON.parse(require("fs").readFileSync(0, "utf8"))
for (const asset of release.assets || []) {
console.log(` ${asset.name} ${(asset.size / 1e6).toFixed(0)} MB`)
}
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 }
}
}
+32 -5
View File
@@ -1,3 +1,4 @@
import { readAccessVerdict, type CatalogPrice } from '../../domain/models/CatalogAccess'
import type { Game } from '../../domain/models/Game'
import type { GameDto } from '../../shared/contracts/dto/GameDto'
@@ -6,10 +7,11 @@ const ABSOLUTE_URL = /^https?:\/\//
/**
* A title as the window may see it.
*
* Two 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
* here — so the window never receives a filesystem path it could be talked into
* opening.
* Three decisions live here rather than in the renderer: the box art is resolved
* against the catalog's base URL, whether a title can be launched is answered here —
* so the window never receives a filesystem path it could be talked into 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 {
public toDto (game: Game, catalogBaseUrl: string): GameDto {
@@ -29,7 +31,10 @@ export class GameDtoMapper {
launchable: this.isLaunchable(game),
installable: game.installable,
unavailableReason: game.unavailableReason,
unavailableDetail: game.unavailableDetail
unavailableDetail: game.unavailableDetail,
accessVerdict: readAccessVerdict(game.access),
priceLabel: formatPrice(game.access?.price ?? null),
purchaseUrl: game.access?.purchaseUrl ?? null
}
}
@@ -49,3 +54,25 @@ export class GameDtoMapper {
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 {
name: store.name,
catalogUrl: store.catalogUrl,
storeRepositoryUrl: store.storeRepositoryUrl,
storeId: deriveStoreId(store)
}
}
@@ -18,10 +17,6 @@ export class RegistryStoreDtoMapper {
/** The window hands a record straight back when asking for an install. */
public toModel (dto: RegistryStoreDto): RegistryStore {
return {
name: dto.name,
catalogUrl: dto.catalogUrl,
storeRepositoryUrl: dto.storeRepositoryUrl
}
return { name: dto.name, catalogUrl: dto.catalogUrl }
}
}
+112
View File
@@ -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 { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment'
import type { PythonRuntimeLocator } from '../../domain/ports/PythonRuntimeLocator'
import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto'
import { TranslationCatalog } from '../../shared/i18n/TranslationCatalog'
import { EngineVersionDtoMapper } from '../mappers/EngineVersionDtoMapper'
import { InstalledStoreDtoMapper } from '../mappers/InstalledStoreDtoMapper'
import type { PreferencesService } from './PreferencesService'
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.
*
* One call rather than six, because the first frame should not be a sequence of
* round trips — and because the decisions the window makes from it (no Python, no
* store, an engine too old) all depend on each other.
* One call rather than six, because the first frame should not be a sequence of round
* trips. There is one decision left in it — whether a store is set up yet — now that
* the engine ships with the application and cannot be missing or out of date.
*/
export class ApplicationStateService {
public constructor (
private readonly preferences: PreferencesService,
private readonly selection: StoreSelectionService,
private readonly provisioning: StoreProvisioningService,
private readonly pythonLocator: PythonRuntimeLocator,
private readonly environment: ApplicationEnvironment,
private readonly translations: TranslationCatalog = new TranslationCatalog(),
private readonly storeMapper: InstalledStoreDtoMapper = new InstalledStoreDtoMapper(),
private readonly engineMapper: EngineVersionDtoMapper = new EngineVersionDtoMapper()
private readonly storeMapper: InstalledStoreDtoMapper = new InstalledStoreDtoMapper()
) {}
public readState (): AppStateDto {
const locale = this.preferences.readLocale()
const stores = this.selection.listStores()
const current: InstalledStore | null = this.selection.findCurrentStore()
const engine = current === null ? null : this.selection.findEngineVersion(current)
const runtime = this.pythonLocator.findRuntime()
return {
locale,
locales: this.translations.locales,
messages: this.translations.readBundle(locale),
navigationOpen: this.preferences.readNavigationOpen(),
pythonVersion: runtime === null ? null : runtime.version,
currentStore: current === null ? null : this.storeMapper.toDto(current),
stores: this.storeMapper.toDtoList(stores),
engine: engine === null ? null : this.engineMapper.toDto(engine),
minimumEngineVersion: formatVersion(MINIMUM_ENGINE_VERSION),
registryUrl: this.provisioning.registryUrl,
defaultStoreRoot: this.selection.readDefaultStoreRoot(),
appVersion: this.environment.readVersion()
@@ -30,6 +30,14 @@ export class StoreProvisioningService {
return this.registry.listStores()
}
/**
* Install the chosen store.
*
* The window's choice is taken at face value, which is safe because a record is only a
* name and a catalog: there is no path in it and nothing that decides what may be
* deleted. The store's own configuration is written by the installer from the engine's
* defaults, so the renderer cannot influence where anything lands.
*/
public async installStore (
store: RegistryStore,
progress?: EngineProgressListener
@@ -38,4 +46,5 @@ export class StoreProvisioningService {
const installed = await this.installer.installEngine(home, store, progress)
return this.selection.adoptStore(installed)
}
}
@@ -1,8 +1,6 @@
import { StoreMissingError } from '../../domain/errors/StoreMissingError'
import type { EngineVersion } from '../../domain/models/EngineVersion'
import type { InstalledStore } from '../../domain/models/InstalledStore'
import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository'
import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
import type { PreferencesService } from './PreferencesService'
/**
@@ -18,7 +16,6 @@ export class StoreSelectionService {
public constructor (
private readonly stores: InstalledStoreRepository,
private readonly catalogGateway: StoreCatalogGateway,
private readonly preferences: PreferencesService
) {}
@@ -58,15 +55,6 @@ export class StoreSelectionService {
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 {
return this.stores.readRoots()[0] ?? ''
}
-9
View File
@@ -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')
}
}
+42
View File
@@ -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'
}
+10
View File
@@ -1,4 +1,5 @@
import type { Game } from './Game'
import type { StoreAccount } from './StoreAccount'
import type { StorePaths } from './StorePaths'
/** One reading of a store's catalog. */
@@ -6,4 +7,13 @@ export interface CatalogListing {
readonly games: readonly Game[]
readonly skipped: readonly string[]
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
}
+39
View File
@@ -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'
}
-34
View File
@@ -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('.')
}
+10
View File
@@ -1,3 +1,5 @@
import type { CatalogAccess } from './CatalogAccess'
/** How a title runs: unpacked on this machine, or served as a web build. */
export type GameMode = 'app' | 'web'
@@ -41,4 +43,12 @@ export interface Game {
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
}
+65
View File
@@ -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)
}
+64
View File
@@ -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()))
}
+7 -2
View File
@@ -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 {
readonly id: string
readonly name: string
readonly home: string
readonly scriptPath: string
readonly configPath: string
readonly engine: string
}
-6
View File
@@ -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
}
+8 -7
View File
@@ -1,14 +1,15 @@
/**
* A store the site's registry offers.
* A store the site's registry offers: a name and a catalog.
*
* A name and a catalog are what make a store; the repository is optional. When
* there is one it stays the authority on how that store behaves — which platforms
* it offers, where things land — and when there is not, the engine's own defaults
* cover all of it and this record covers the identity. That is the whole reason a
* store needs no repository of its own.
* That is the whole record, and it is enough. How a store behaves is not the registry's
* business — this client carries its own store engine, whose defaults cover the
* host-to-asset mapping, the install modes, the platforms and the behaviour — so what
* was actually missing from those defaults is identity, and identity is all this is.
*
* Keeping two stores on one machine out of each other's files is a subfolder, derived
* here from the store's own slug rather than told to us by a server.
*/
export interface RegistryStore {
readonly name: string
readonly catalogUrl: string
readonly storeRepositoryUrl: string | null
}
+62
View File
@@ -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[]
}
+43
View File
@@ -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
}
+28
View File
@@ -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'
+141
View File
@@ -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 }
}
+7 -5
View File
@@ -1,13 +1,16 @@
/**
* A store engine this client knows how to drive.
*
* There is one today. The table exists because the RetroArch store has the same
* command shape, so a second entry — not a second code path — is what adding it
* would take.
* There is one today: the desktop engine, which is the code in
* `infrastructure/engine`. The table stays because a second host — RetroArch
* 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 {
readonly id: string
readonly scriptFileName: string
/** The installer names a store home `<store id><homeSuffix>`. */
readonly homeSuffix: string
readonly launcherSuffix: string
@@ -15,7 +18,6 @@ export interface StoreEngine {
export const DESKTOP_STORE_ENGINE: StoreEngine = {
id: 'desktop',
scriptFileName: 'desktop_store.py',
homeSuffix: '-desktop',
launcherSuffix: '-desktop-store'
}
+10 -16
View File
@@ -3,28 +3,22 @@ import type { RegistryStore } from './RegistryStore'
/**
* A store id, from whatever the registry gave us.
*
* The id names the store home, the folder games land in and the launcher files, so
* it has to be short and filesystem-safe. Three sources, in order of how much they
* were meant to be a name:
* The id names the store home, the folder games land in and the launcher files, so it
* has to be short and filesystem-safe. Two sources, in order of how much they were
* meant to be a name:
*
* 1. the repository name — `ttg-desktop-store` becomes `ttg`;
* 2. the catalog host — `https://teletypegames.org` becomes `teletypegames`;
* 3. the display name, slugged, as a last resort.
* 1. the catalog host — `https://teletypegames.org` becomes `teletypegames`;
* 2. the display name, slugged, as a last resort.
*
* The store's own config.json overrides all of it whenever one exists.
* Derived rather than carried, and derived from the catalog: the catalog is what a store
* *is*, so two records naming the same catalog are the same store and land in the same
* place, which is what keeps a reinstall from orphaning what is already there.
*/
export function deriveStoreId (store: RegistryStore): string {
const fromRepository = store.storeRepositoryUrl === null
? ''
: (lastSegment(store.storeRepositoryUrl).replace(/-(desktop-)?store$/, ''))
return toSlug(fromRepository) || toSlug(readHostLabel(store.catalogUrl)) || toSlug(store.name) || 'store'
return toSlug(readHostLabel(store.catalogUrl)) || toSlug(store.name) || 'store'
}
function lastSegment (url: string): string {
return url.replace(/\/+$/, '').split('/').pop() ?? ''
}
/** `https://www.teletypegames.org/x` → `teletypegames`. */
/** `https://www.teletypegames.org/x` -> `teletypegames`. */
function readHostLabel (catalogUrl: string): string {
try {
const host = new URL(catalogUrl).hostname.replace(/^www\./, '')
+109
View File
@@ -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]
}
+16
View File
@@ -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
}
-5
View File
@@ -1,5 +0,0 @@
import type { PythonRuntime } from '../models/PythonRuntime'
export interface PythonRuntimeLocator {
findRuntime: () => PythonRuntime | null
}
+20 -4
View File
@@ -1,19 +1,35 @@
import type { CatalogListing } from '../models/CatalogListing'
import type { EngineProgressListener } from '../models/EngineProgress'
import type { EngineVersion } from '../models/EngineVersion'
import type { InstalledStore } from '../models/InstalledStore'
import type { SignInPrompt, StoreAccount } from '../models/StoreAccount'
import type { StorePaths } from '../models/StorePaths'
/**
* The store engine, as an interface.
*
* Every catalog operation this client performs is one call on this port; the CLI
* behind it stays the product, and nothing above this line knows it is Python.
* Every catalog operation this client performs is one call on this port. The engine
* 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 {
listGames: (store: InstalledStore, progress?: EngineProgressListener) => Promise<CatalogListing>
readPaths: (store: InstalledStore, progress?: EngineProgressListener) => Promise<StorePaths>
syncGames: (store: InstalledStore, names: readonly 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
}
+168
View File
@@ -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,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)
}
}
+135
View File
@@ -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'
}
}
+216
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
@@ -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
}
}
+194
View File
@@ -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)
}
+218
View File
@@ -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,52 +0,0 @@
import type { Game, GameMode, UnavailableReason } 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'),
// Absent means installable: engines older than 1.2.0 list only what they can
// install, and treating their silence as "unavailable" would empty the window.
installable: readBoolean(record, 'installable', true),
unavailableReason: this.toReason(readOptionalString(record, 'unavailable_reason')),
unavailableDetail: readOptionalString(record, 'unavailable_detail')
}
}
private toMode (value: string): GameMode {
return value === 'web' ? 'web' : 'app'
}
/** The engine's snake_case codes, which are its wire format and not ours. */
private toReason (value: string | null): UnavailableReason | null {
const codes: Readonly<Record<string, UnavailableReason>> = {
platform_off: 'platformOff',
host_asset: 'hostAsset',
no_asset: 'noAsset',
vetoed: 'vetoed'
}
return value === null ? null : codes[value] ?? null
}
}
@@ -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'
/**
* 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
* first so a sandbox can be driven without touching a working installation which
* is how this repository is tested.
* The roots are searched in the shell installers' own order those homes are still
* valid stores and `STORE_ROOT` comes first so a sandbox can be driven without
* touching a working installation, which is how this repository is tested.
*/
export class FileSystemInstalledStoreRepository implements InstalledStoreRepository {
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 {
const configPath = path.join(home, CONFIG_FILE_NAME)
if (!fs.existsSync(configPath)) return null
for (const engine of STORE_ENGINES) {
const scriptPath = path.join(home, engine.scriptFileName)
const configPath = path.join(home, CONFIG_FILE_NAME)
if (!fs.existsSync(scriptPath) || !fs.existsSync(configPath)) continue
const id = directoryName.replace(engine.homeSuffix, '')
return {
id,
name: this.readStoreName(configPath, id),
home,
scriptPath,
configPath,
engine: engine.id
}
if (!directoryName.endsWith(engine.homeSuffix)) continue
const id = directoryName.slice(0, directoryName.length - engine.homeSuffix.length)
return { id, name: this.readStoreName(configPath, id), home, configPath, engine: engine.id }
}
return null
}
@@ -90,9 +90,8 @@ export class FileSystemInstalledStoreRepository implements InstalledStoreReposit
/**
* 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
* the machine, and starting a Python process per entry to learn its name would
* be absurd.
* Read here rather than asked of the engine: the switcher lists every store on the
* machine, and assembling an engine per entry to learn its name would be absurd.
*/
private readStoreName (configPath: string, fallback: string): string {
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}`
}
}
@@ -15,10 +15,10 @@ const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores'
* field a build was packaged with (for shipping a client for another site), and finally
* the address of ours.
*
* A record needs a name and a catalog URL; those two make a store. The repository
* is optional and arrives as null when absent a store configured by nothing but
* this record installs on the engine's defaults. Records missing either of the two
* required fields are dropped rather than half-used.
* A name and a catalog URL make a store, and are all a record carries. Anything else it
* happens to say is ignored: how a store behaves is this client's own business, decided
* by the engine it ships with. Records missing either field are dropped rather than
* half-used.
*/
export class HttpStoreRegistryRepository implements StoreRegistryRepository {
public readonly sourceUrl: string
@@ -44,18 +44,12 @@ export class HttpStoreRegistryRepository implements StoreRegistryRepository {
return parsed
.map((row: unknown): JsonRecord | null => asRecord(row))
.filter((row: JsonRecord | null): row is JsonRecord => row !== null)
.map((row: JsonRecord): RegistryStore => {
// Both spellings, because a registry is someone else's API: ours answers
// camelCase, and a hand-rolled one may not.
const repository = (
readString(row, 'storeRepositoryUrl') || readString(row, 'store_repository_url')
).trim()
return {
name: readString(row, 'name').trim(),
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim(),
storeRepositoryUrl: repository.length > 0 ? repository : null
}
})
// Both spellings, because a registry is someone else's API: ours answers
// camelCase, and a hand-rolled one may not.
.map((row: JsonRecord): RegistryStore => ({
name: readString(row, 'name').trim(),
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim()
}))
.filter((store: RegistryStore): boolean =>
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
}
}
+14 -11
View File
@@ -1,4 +1,5 @@
import type { App, IpcMain, Shell } from 'electron'
import { AccountService } from '../../application/services/AccountService'
import { ApplicationStateService } from '../../application/services/ApplicationStateService'
import { CatalogService } from '../../application/services/CatalogService'
import { GameLaunchService } from '../../application/services/GameLaunchService'
@@ -7,14 +8,14 @@ import { StoreProvisioningService } from '../../application/services/StoreProvis
import { StoreSelectionService } from '../../application/services/StoreSelectionService'
import { ElectronApplicationEnvironment } from '../../infrastructure/electron/ElectronApplicationEnvironment'
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 { PythonEngineProcessRunner } from '../../infrastructure/process/PythonEngineProcessRunner'
import { SystemPythonRuntimeLocator } from '../../infrastructure/process/SystemPythonRuntimeLocator'
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 { JsonFilePreferencesRepository } from '../../infrastructure/repositories/JsonFilePreferencesRepository'
import { PythonStoreCatalogGateway } from '../../infrastructure/repositories/PythonStoreCatalogGateway'
import { AccountIpcController } from '../ipc/AccountIpcController'
import { AppIpcController } from '../ipc/AppIpcController'
import { CatalogIpcController } from '../ipc/CatalogIpcController'
import { IpcRouter } from '../ipc/IpcRouter'
@@ -37,6 +38,7 @@ export class ServiceContainer {
public readonly provisioning: StoreProvisioningService
public readonly state: ApplicationStateService
public readonly launching: GameLaunchService
public readonly accounts: AccountService
private readonly controllers: readonly { register: (router: IpcRouter) => void }[]
@@ -46,28 +48,29 @@ export class ServiceContainer {
const environment = new ElectronApplicationEnvironment(app)
const httpClient = new HttpTextClient()
const pythonLocator = new SystemPythonRuntimeLocator()
const engineRunner = new PythonEngineProcessRunner(pythonLocator)
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 installer = new HttpStoreEngineInstaller(httpClient)
const installer = new NativeStoreEngineInstaller()
const preferencesRepository = new JsonFilePreferencesRepository(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.accounts = new AccountService(catalogGateway, this.selection)
this.provisioning = new StoreProvisioningService(registry, installer, stores, this.selection)
this.launching = new GameLaunchService(new ElectronGameLauncher(shell), this.catalog)
this.state = new ApplicationStateService(
preferences, this.selection, this.provisioning, pythonLocator, environment
preferences, this.selection, this.provisioning, environment
)
this.controllers = [
new AppIpcController(this.state, preferences, this.launching),
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)
]
}
+35 -3
View File
@@ -26,6 +26,10 @@ interface SelfTestReport {
readonly paths: string
readonly logLines: number
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. */
@@ -68,7 +72,15 @@ export class SelfTestRunner {
// A gate passes on having something to do, not on having a picker: the picker
// only appears when the registry offers more than one store, and one store is
// the ordinary case. Requiring choices here failed a perfectly good window.
const rendered = report.locales.length > 1 && (
// 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.categories.length > 0 && report.activeCategory !== null) ||
(report.gateVisible && report.gateAction.length > 0))
@@ -98,7 +110,25 @@ export class SelfTestRunner {
activeCategory: (document.querySelector('#cats .cat.is-active') || {}).textContent || null,
paths: document.getElementById('log-paths').textContent.slice(0, 120),
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 {
@@ -117,7 +147,9 @@ export class SelfTestRunner {
activeCategory: readOptionalString(record, 'activeCategory'),
paths: readString(record, 'paths'),
logLines: readNumber(record, 'logLines'),
locales: readStringArray(record, 'locales')
locales: readStringArray(record, 'locales'),
iconControls: readStringArray(record, 'iconControls'),
cardMenus: readStringArray(record, 'cardMenus')
}
}
+80
View File
@@ -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)
}
+5 -1
View File
@@ -46,7 +46,11 @@ export class CatalogIpcController {
return {
games: this.gameMapper.toDtoList(listing.games, baseUrl),
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
}
}
})
}
-3
View File
@@ -27,12 +27,9 @@ export function requireStringArray (value: unknown, name: string): readonly stri
export function requireRegistryStore (value: unknown): RegistryStoreDto {
const record = asRecord(value)
if (record === null) throw new TypeError('a store record is required')
const repository = readString(record, 'storeRepositoryUrl')
const store: RegistryStoreDto = {
name: readString(record, 'name'),
catalogUrl: readString(record, 'catalogUrl'),
// Optional: a store with no repository installs on the engine's defaults.
storeRepositoryUrl: repository.length > 0 ? repository : null,
storeId: readString(record, 'storeId')
}
if (store.name.length === 0 || store.catalogUrl.length === 0) {
+2 -9
View File
@@ -1,5 +1,4 @@
import { InstalledStoreDtoMapper } from '../../application/mappers/InstalledStoreDtoMapper'
import { EngineVersionDtoMapper } from '../../application/mappers/EngineVersionDtoMapper'
import { RegistryStoreDtoMapper } from '../../application/mappers/RegistryStoreDtoMapper'
import type { StoreProvisioningService } from '../../application/services/StoreProvisioningService'
import type { StoreSelectionService } from '../../application/services/StoreSelectionService'
@@ -20,8 +19,7 @@ export class StoreIpcController {
private readonly guard: SingleFlightGuard,
private readonly streams: WindowStreamBroadcaster,
private readonly registryMapper: RegistryStoreDtoMapper = new RegistryStoreDtoMapper(),
private readonly storeMapper: InstalledStoreDtoMapper = new InstalledStoreDtoMapper(),
private readonly engineMapper: EngineVersionDtoMapper = new EngineVersionDtoMapper()
private readonly storeMapper: InstalledStoreDtoMapper = new InstalledStoreDtoMapper()
) {}
public register (router: IpcRouter): void {
@@ -63,11 +61,6 @@ export class StoreIpcController {
}
private handleSelectStore (home: string): StoreSelectionDto {
const store = this.selection.selectStore(home)
const engine = this.selection.findEngineVersion(store)
return {
store: this.storeMapper.toDto(store),
engine: engine === null ? null : this.engineMapper.toDto(engine)
}
return { store: this.storeMapper.toDto(this.selection.selectStore(home)) }
}
}
+11 -1
View File
@@ -1,10 +1,12 @@
import type { BrowserWindow } from 'electron'
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels'
import type { SignInFinishedDto } from '../../shared/contracts/dto/AccountDto'
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
* 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)
}
/**
* 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. */
public asProgressListener (): EngineProgressListener {
return {
+18
View File
@@ -3,6 +3,9 @@ import {
BRIDGE_GLOBAL_NAME, type BridgeApi, type StreamListener
} from '../shared/contracts/BridgeApi'
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 { CatalogListingDto } from '../shared/contracts/dto/CatalogListingDto'
import type { InstalledStoreDto } from '../shared/contracts/dto/InstalledStoreDto'
@@ -40,6 +43,15 @@ const bridge: BridgeApi = {
launchGame: async (name: string): 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> =>
ipcRenderer.invoke(IPC_CHANNELS.storeListRegistry) as Promise<RegistryResultDto>,
installStore: async (store: RegistryStoreDto): Promise<InstalledStoreDto> =>
@@ -66,6 +78,12 @@ const bridge: BridgeApi = {
ipcRenderer.on(IPC_CHANNELS.streamBusyChanged, (_event: IpcRendererEvent, busy: boolean): void => {
listener(busy)
})
},
onSignInFinished: (listener: StreamListener<SignInFinishedDto>): void => {
ipcRenderer.on(
IPC_CHANNELS.streamSignInFinished,
(_event: IpcRendererEvent, payload: SignInFinishedDto): void => { listener(payload) }
)
}
}
+38 -10
View File
@@ -1,5 +1,6 @@
import type { BridgeApi } from '../shared/contracts/BridgeApi'
import { requireBridge } from './BridgeAccess'
import { AccountController } from './controllers/AccountController'
import { CatalogController } from './controllers/CatalogController'
import { EngineStreamController } from './controllers/EngineStreamController'
import { PreferencesController } from './controllers/PreferencesController'
@@ -11,6 +12,7 @@ import { GameCardView } from './views/GameCardView'
import { GateView } from './views/GateView'
import { LogDrawerView } from './views/LogDrawerView'
import { SideMenuView } from './views/SideMenuView'
import { SignInView } from './views/SignInView'
import { TopBarView } from './views/TopBarView'
/**
@@ -28,10 +30,12 @@ export class RendererApplication {
private readonly grid: CatalogGridView
private readonly topBar: TopBarView
private readonly sideMenu: SideMenuView
private readonly signInPanel: SignInView
private readonly catalog: CatalogController
private readonly stores: StoreController
private readonly preferences: PreferencesController
private readonly streams: EngineStreamController
private readonly accounts: AccountController
public constructor (bridge: BridgeApi = requireBridge()) {
this.bridge = bridge
@@ -44,12 +48,25 @@ export class RendererApplication {
this.stores = new StoreController(this.bridge, this.store, this.log, this.catalog)
this.preferences = new PreferencesController(this.bridge, this.store)
this.streams = new EngineStreamController(this.bridge, this.store, this.log)
this.accounts = new AccountController(
this.bridge, this.store, this.log,
async (): Promise<void> => { await this.catalog.refresh() }
)
this.grid = new CatalogGridView(new GameCardView({
onInstall: (name: string): void => { void this.catalog.syncGames([name]) },
// The same call as an install: a sync of one name fetches whatever the catalog
// now has for it, and the engine replaces the old payload and menu entry.
onUpgrade: (name: string): void => { void this.catalog.syncGames([name]) },
onLaunch: (name: string): void => { void this.catalog.launchGame(name) },
onRemove: (name: string): void => { void this.catalog.removeGame(name) }
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({
onToggleNavigation: (): void => { void this.preferences.toggleNavigation() }
})
@@ -58,11 +75,29 @@ export class RendererApplication {
onAddStore: (): void => { void this.stores.offerStores() },
onRefresh: (): void => { void this.catalog.refresh() },
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.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. */
@@ -70,18 +105,10 @@ export class RendererApplication {
this.store.applyAppState(await this.bridge.readState())
const state = this.store.readState()
if (state.pythonVersion === null) {
this.stores.showMissingPythonGate()
return
}
if (state.currentStore === null) {
await this.stores.offerStores()
return
}
if (state.engine !== null && !state.engine.supported) {
this.stores.showOutdatedEngineGate()
return
}
this.store.applyGate(null)
await this.catalog.refresh()
}
@@ -96,6 +123,7 @@ export class RendererApplication {
}
document.body.classList.toggle('nav-closed', !state.navigationOpen)
this.signInPanel.render(state)
this.topBar.render(state)
this.sideMenu.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> {
try {
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}`)
} catch (error: unknown) {
this.reportFailure(error)
+1 -24
View File
@@ -24,11 +24,7 @@ export class StoreController {
const messages = this.store.readState().messages
try {
const selection = await this.bridge.selectStore(home)
this.store.applySelectedStore(selection.store, selection.engine)
if (selection.engine !== null && !selection.engine.supported) {
this.showOutdatedEngineGate()
return
}
this.store.applySelectedStore(selection.store)
this.store.applyGate(null)
await this.catalog.refresh()
} 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> {
const messages = this.store.readState().messages
this.store.applyProgress({ total: 0, done: 0, label: messages.setupWorking })
+52 -7
View File
@@ -36,9 +36,15 @@
<button id="add-store" class="btn btn-ghost btn-wide"></button>
</section>
<section class="side-block">
<h2 class="side-head" id="head-actions"></h2>
<button id="refresh" class="btn btn-wide" disabled></button>
<!--
Signing in, and who is signed in. The whole block is hidden where the catalog
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 class="side-block side-cats">
@@ -48,14 +54,53 @@
<section class="side-block side-foot">
<button id="log-toggle" class="side-quiet" aria-expanded="false" aria-controls="log-panel"></button>
<label class="side-lang">
<span id="head-lang"></span>
<select id="locale" class="select" aria-label="Language"></select>
</label>
<!--
Two icons and nothing else. Both carry their name in `title` and `aria-label`,
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>
</div>
</section>
</aside>
<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. -->
<section id="gate" class="gate" hidden>
<h1 id="gate-title"></h1>
+37 -15
View File
@@ -1,5 +1,5 @@
import type { AccountDto, SignInPromptDto } from '../../shared/contracts/dto/AccountDto'
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 { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto'
import type { StorePathsDto } from '../../shared/contracts/dto/StorePathsDto'
@@ -21,11 +21,8 @@ export interface AppState {
readonly locales: readonly Locale[]
readonly messages: MessageBundle
readonly navigationOpen: boolean
readonly pythonVersion: string | null
readonly stores: readonly InstalledStoreDto[]
readonly currentStore: InstalledStoreDto | null
readonly engine: EngineVersionDto | null
readonly minimumEngineVersion: string
readonly registryUrl: string
readonly defaultStoreRoot: string
readonly games: readonly GameDto[]
@@ -35,6 +32,10 @@ export interface AppState {
readonly progress: SyncProgress | null
/** Non-null while the setup screen is up, which is also what hides the grid. */
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 = {
@@ -42,11 +43,8 @@ const INITIAL_STATE: AppState = {
locales: ['en'],
messages: ENGLISH_MESSAGES,
navigationOpen: true,
pythonVersion: null,
stores: [],
currentStore: null,
engine: null,
minimumEngineVersion: '',
registryUrl: '',
defaultStoreRoot: '',
games: [],
@@ -54,7 +52,9 @@ const INITIAL_STATE: AppState = {
filter: ALL_CATEGORIES,
busy: false,
progress: null,
gate: null
gate: null,
account: { signInAvailable: false, signedIn: false },
signIn: null
}
export type AppStateListener = (state: AppState) => void
@@ -85,11 +85,8 @@ export class AppStore {
locales: dto.locales,
messages: dto.messages,
navigationOpen: dto.navigationOpen,
pythonVersion: dto.pythonVersion,
stores: dto.stores,
currentStore: dto.currentStore,
engine: dto.engine,
minimumEngineVersion: dto.minimumEngineVersion,
registryUrl: dto.registryUrl,
defaultStoreRoot: dto.defaultStoreRoot
}
@@ -106,13 +103,38 @@ export class AppStore {
this.notify()
}
public applyCatalog (games: readonly GameDto[], paths: StorePathsDto | null): void {
this.state = { ...this.state, games, paths: paths ?? this.state.paths }
public applyCatalog (
games: readonly GameDto[],
paths: StorePathsDto | null,
account: AccountDto
): void {
this.state = { ...this.state, games, paths: paths ?? this.state.paths, account }
this.notify()
}
public applySelectedStore (store: InstalledStoreDto, engine: EngineVersionDto | null): void {
this.state = { ...this.state, currentStore: store, engine, games: [], paths: null, filter: ALL_CATEGORIES }
public applyAccount (account: AccountDto): void {
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()
}
+10 -2
View File
@@ -46,6 +46,12 @@ function matchesGroup (game: GameDto, group: string): boolean {
return game.updateAvailable
case 'available':
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:
@@ -57,8 +63,8 @@ function matchesGroup (game: GameDto, group: string): boolean {
* 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
* title on this machine, the platform it was built with, and whether it runs here or
* in a browser. Empty axes are left out rather than shown as zeroes, and an axis with
* title on this machine, what this person may have of it, the platform it was built
* 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.
*/
export function buildCategorySections (
@@ -73,6 +79,8 @@ export function buildCategorySections (
{ kind: 'group', value: 'installed', label: messages.catInstalled, count: count((game: GameDto): boolean => game.installed) },
{ kind: 'group', value: 'updates', label: messages.catUpdates, count: count((game: GameDto): boolean => game.updateAvailable) },
{ kind: 'group', value: 'available', label: messages.catAvailable, count: count((game: GameDto): boolean => game.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({
+141 -3
View File
@@ -85,7 +85,11 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
padding-top: 12px;
border-top: 1px solid var(--line);
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 {
font-size: 11px;
font-weight: 700;
@@ -167,8 +171,51 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
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 ------------------------------------------------------------ */
.bar {
@@ -223,7 +270,7 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
}
.link { color: var(--accent); cursor: pointer; text-decoration: underline; }
/* --- gate (no python, or no store yet) ---------------------------------- */
/* --- gate (no store yet) ---------------------------------- */
.gate {
margin: auto;
max-width: 520px;
@@ -235,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-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 ----------------------------------------------------------- */
.grid {
flex: 1;
@@ -253,6 +328,11 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
align-content: start;
}
.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; }
.card {
@@ -298,6 +378,14 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
}
.badge-app { color: var(--accent); border-color: #2f5a49; }
.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; }
.desc {
margin: 0;
@@ -310,6 +398,56 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
}
.actions { display: flex; gap: 8px; margin-top: auto; }
/* --- the card's actions menu -------------------------------------------- */
/*
* A <details> holding the two actions that are not the card's headline. The card is the
* positioning context, and the panel is pinned to the button's right edge so it opens
* inward rather than off the side of the grid.
*/
.menu { position: relative; margin-left: auto; }
.menu-toggle { list-style: none; }
/* Safari and Chrome each draw their own marker on a summary; both have to go. */
.menu-toggle::-webkit-details-marker { display: none; }
.menu-toggle::marker { content: ''; }
.menu[open] .menu-toggle { color: var(--ink); background: var(--panel-2); border-color: var(--line); }
.menu-items {
position: absolute;
right: 0;
bottom: calc(100% + 6px);
z-index: 20;
min-width: 148px;
display: flex;
flex-direction: column;
padding: 4px;
gap: 2px;
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 10px;
box-shadow: 0 10px 28px rgb(0 0 0 / .45);
}
.menu-item {
font: inherit;
font-size: 13px;
text-align: left;
padding: 7px 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--ink);
cursor: pointer;
white-space: nowrap;
}
.menu-item:hover:not(:disabled) { background: #2b3746; }
.menu-item:disabled { color: var(--ink-dim); opacity: .5; cursor: default; }
.icon-dots { fill: currentColor; stroke: none; }
/* An installed version with a newer one behind it: the arrow carries the news, so the
colour only has to make it findable in a grid. */
.version.has-update { color: var(--accent); font-weight: 600; }
/* --- log ----------------------------------------------------------------
No permanent footer: the panel is in the flow only while it is open, and the
switch for it sits in the side menu with everything else that is not a title. */
+20 -1
View File
@@ -9,7 +9,26 @@ export class CatalogGridView {
private readonly grid = requireElement('grid', HTMLElement)
private readonly empty = requireElement('empty', HTMLElement)
public constructor (private readonly cards: GameCardView) {}
public constructor (private readonly cards: GameCardView) {
this.closeMenusOnOutsideClick()
}
/**
* One listener for every card's actions menu.
*
* A `<details>` does not close when the pointer goes elsewhere, and a card cannot own
* this: cards are rebuilt on every render, so a listener per card would be a listener
* per render. The grid is created once, which makes it the right place for it.
*/
private closeMenusOnOutsideClick (): void {
document.addEventListener('click', (event: MouseEvent): void => {
const target = event.target
const clicked = target instanceof Node ? target : null
for (const menu of this.grid.querySelectorAll('details.menu[open]')) {
if (clicked === null || !menu.contains(clicked)) menu.removeAttribute('open')
}
})
}
public render (state: AppState): void {
const shown = state.games.filter((game: GameDto): boolean => matchesFilter(game, state.filter))
+158 -19
View File
@@ -4,15 +4,22 @@ import { createElement } from '../dom/Dom'
export interface GameCardViewCallbacks {
readonly onInstall: (name: string) => void
readonly onUpgrade: (name: string) => void
readonly onLaunch: (name: string) => void
readonly onRemove: (name: string) => void
/** Opens the catalog's own purchase page in the person's browser. */
readonly onPurchase: (name: string) => void
readonly onSignIn: () => void
}
/**
* One card.
*
* A card is a function of a title and the strings: it holds no state of its own, so
* the grid can throw the lot away and rebuild after every listing.
* A card is a function of a title and the strings: it holds no state of its own, so the
* grid can throw the lot away and rebuild after every listing. The one exception is the
* actions menu, whose open/closed state lives in a `<details>` element and being
* thrown away is exactly what should happen to an open menu when the catalog changes
* under it.
*/
export class GameCardView {
public constructor (private readonly callbacks: GameCardViewCallbacks) {}
@@ -21,6 +28,7 @@ export class GameCardView {
const card = createElement('article', 'card')
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.createBody(game, messages, busy))
return card
@@ -71,13 +79,41 @@ export class GameCardView {
meta.appendChild(badge)
}
meta.appendChild(createElement('span', 'badge badge-plain', game.platform))
meta.appendChild(createElement('span', 'version',
game.installed && game.installedVersion !== null
? `${game.installedVersion} · ${messages.installed}`
: game.version))
// The price is where the mode badge is rather than down by the button: what a title
// costs is something a person scans a grid for, and a number that only appears
// beside a button is a number they have to hunt for card by card.
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
}
/**
* The version, and what the catalog has that this machine does not.
*
* Both numbers, when they differ: which version is installed is the thing a person
* came to the card to find out, and "there is a newer one" is only meaningful next to
* it. Where they agree, the second number would be noise.
*/
private createVersion (game: GameDto, messages: MessageBundle): HTMLElement {
if (!game.installed || game.installedVersion === null) {
return createElement('span', 'version', game.version)
}
if (!game.updateAvailable) {
return createElement('span', 'version', `${game.installedVersion} · ${messages.installed}`)
}
const version = createElement('span', 'version has-update',
`${game.installedVersion}${game.version}`)
version.title = messages.updateAvailable
return version
}
private createActions (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
const actions = createElement('div', 'actions')
@@ -89,25 +125,128 @@ export class GameCardView {
return actions
}
const primary = createElement('button', 'btn btn-primary')
primary.disabled = busy
// 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
}
if (game.installed && !game.updateAvailable) {
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.disabled = busy || !game.launchable
primary.addEventListener('click', (): void => { this.callbacks.onLaunch(game.name) })
} else {
primary.textContent = game.updateAvailable ? messages.update : messages.install
primary.addEventListener('click', (): void => { this.callbacks.onInstall(game.name) })
return primary
}
actions.appendChild(primary)
primary.textContent = messages.install
primary.disabled = busy
primary.addEventListener('click', (): void => { this.callbacks.onInstall(game.name) })
return primary
}
if (game.installed) {
const remove = createElement('button', 'btn btn-ghost', messages.remove)
remove.disabled = busy
remove.addEventListener('click', (): void => { this.callbacks.onRemove(game.name) })
actions.appendChild(remove)
/**
* The three-dot menu: upgrade, and uninstall.
*
* A `<details>` rather than a scripted popover, so the open state is the DOM's and the
* keyboard works without being taught to. Upgrade is present but disabled when there
* is nothing newer a menu whose items appear and disappear makes a person hunt for
* the one they used last time, and "greyed out" already says "not now".
*/
private createMenu (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
const menu = createElement('details', 'menu')
const toggle = createElement('summary', 'icon-btn menu-toggle')
toggle.title = messages.moreActions
toggle.setAttribute('aria-label', messages.moreActions)
toggle.appendChild(this.createDotsIcon())
menu.appendChild(toggle)
const items = createElement('div', 'menu-items')
items.appendChild(this.createMenuItem(messages.upgrade, busy || !game.updateAvailable,
(): void => { this.callbacks.onUpgrade(game.name) }))
items.appendChild(this.createMenuItem(messages.uninstall, busy,
(): void => { this.callbacks.onRemove(game.name) }))
menu.appendChild(items)
return menu
}
private createMenuItem (
label: string,
disabled: boolean,
perform: () => void
): HTMLButtonElement {
const item = createElement('button', 'menu-item', label)
item.disabled = disabled
item.addEventListener('click', (): void => {
// Close before acting: the click starts work that re-renders the grid, and a menu
// left open would vanish mid-gesture rather than answer the press.
item.closest('details')?.removeAttribute('open')
perform()
})
return item
}
private createDotsIcon (): SVGSVGElement {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('class', 'icon icon-dots')
svg.setAttribute('viewBox', '0 0 16 16')
svg.setAttribute('aria-hidden', 'true')
svg.setAttribute('focusable', 'false')
for (const y of [3.5, 8, 12.5]) {
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
dot.setAttribute('cx', '8')
dot.setAttribute('cy', String(y))
dot.setAttribute('r', '1.35')
svg.appendChild(dot)
}
return actions
return svg
}
}
+60 -5
View File
@@ -12,6 +12,8 @@ export interface SideMenuViewCallbacks {
readonly onRefresh: () => void
readonly onSelectCategory: (filter: CategoryFilter) => void
readonly onSelectLocale: (locale: string) => void
readonly onSignIn: () => void
readonly onSignOut: () => void
}
/**
@@ -21,30 +23,51 @@ export interface SideMenuViewCallbacks {
*/
export class SideMenuView {
private readonly storesHead = requireElement('head-stores', HTMLElement)
private readonly actionsHead = requireElement('head-actions', HTMLElement)
private readonly categoriesHead = requireElement('head-cats', HTMLElement)
private readonly languageHead = requireElement('head-lang', HTMLElement)
private readonly storeList = requireElement('store-list', HTMLElement)
private readonly addStore = requireElement('add-store', HTMLButtonElement)
private readonly refresh = requireElement('refresh', HTMLButtonElement)
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)
/** 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) {
this.addStore.addEventListener('click', callbacks.onAddStore)
this.refresh.addEventListener('click', callbacks.onRefresh)
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 {
setText(this.storesHead, state.messages.stores)
setText(this.actionsHead, state.messages.actions)
setText(this.categoriesHead, state.messages.categories)
setText(this.languageHead, state.messages.language)
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.renderAccount(state)
this.renderCategories(state)
this.renderLocales(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 {
const sections = buildCategorySections(state.games, state.messages)
const nodes: HTMLElement[] = []
@@ -117,3 +161,14 @@ export class SideMenuView {
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)
}
+47
View File
@@ -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
}
}
+68 -50
View File
@@ -3,15 +3,13 @@ import path from 'node:path'
import { GameDtoMapper } from '../application/mappers/GameDtoMapper'
import type { CatalogListing } from '../domain/models/CatalogListing'
import type { InstalledStore } from '../domain/models/InstalledStore'
import type { RegistryStore } from '../domain/models/RegistryStore'
import { DESKTOP_STORE_ENGINE } from '../domain/models/StoreEngine'
import { deriveStoreId } from '../domain/models/StoreIdentity'
import type { CredentialRepository } from '../domain/ports/CredentialRepository'
import { NativeStoreCatalogGateway } from '../infrastructure/engine/NativeStoreCatalogGateway'
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 { HttpStoreRegistryRepository } from '../infrastructure/repositories/HttpStoreRegistryRepository'
import { PythonStoreCatalogGateway } from '../infrastructure/repositories/PythonStoreCatalogGateway'
import type { GameDto } from '../shared/contracts/dto/GameDto'
import { ENGLISH_MESSAGES } from '../shared/i18n/EnglishMessages'
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.
*
* 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:
* 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.
*
* npm run smoke the store on this machine
* 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 {
private failed = false
private readonly stores = new FileSystemInstalledStoreRepository()
private readonly pythonLocator = new SystemPythonRuntimeLocator()
private readonly catalogGateway = new PythonStoreCatalogGateway(
new PythonEngineProcessRunner(this.pythonLocator)
)
private readonly catalogGateway = new NativeStoreCatalogGateway(envCredentials())
private readonly httpClient = new HttpTextClient()
private readonly registry = new HttpStoreRegistryRepository(this.httpClient)
private readonly gameMapper = new GameDtoMapper()
@@ -42,7 +46,6 @@ class SmokeTest {
public async run (): Promise<number> {
console.log('warp-engine-client smoke test')
if (!this.checkPython()) return 1
this.checkMessages()
await this.checkRegistry()
@@ -53,16 +56,6 @@ class SmokeTest {
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. */
private checkMessages (): void {
const english = Object.keys(ENGLISH_MESSAGES).length
@@ -79,34 +72,17 @@ class SmokeTest {
return
}
this.reportOk('registry', `${String(stores.length)} store(s) from ${this.registry.sourceUrl}`)
// The slug is worth printing: it names the store home and the games subfolder, and
// it is derived here rather than told to us, so a wrong catalog URL shows up as a
// wrong folder name before anything is installed.
for (const store of stores) {
this.reportOk(` ${store.name}`, `${store.catalogUrl} · ${deriveStoreId(store)}`)
await this.checkStoreConfig(store)
}
} catch (error: unknown) {
this.reportBad('registry', `${this.registry.sourceUrl}: ${this.describe(error)}`)
}
}
/**
* A store needs no repository, and a repository needs no config.json: either way
* the engine's defaults carry it. So both absences are reported, not failed.
*/
private async checkStoreConfig (store: RegistryStore): Promise<void> {
if (store.storeRepositoryUrl === null) {
this.reportOk(' config', 'no repository — the engine defaults would be used')
return
}
const url = `${store.storeRepositoryUrl.replace(/\/+$/, '')}/raw/branch/master/config.json`
try {
const config: unknown = JSON.parse(await this.httpClient.readText(url))
const sections = typeof config === 'object' && config !== null ? Object.keys(config).length : 0
this.reportOk(' config.json', `${String(sections)} sections`)
} catch (error: unknown) {
this.reportOk(' config.json', `absent (${this.describe(error)}) — defaults would be used`)
}
}
private findStore (): InstalledStore | null {
const sandbox = process.env['SMOKE_HOME']
if (sandbox !== undefined && sandbox.length > 0) {
@@ -115,15 +91,12 @@ class SmokeTest {
id: path.basename(home).replace(DESKTOP_STORE_ENGINE.homeSuffix, ''),
name: path.basename(home),
home,
scriptPath: path.join(home, DESKTOP_STORE_ENGINE.scriptFileName),
configPath: path.join(home, 'config.json'),
engine: DESKTOP_STORE_ENGINE.id
}
for (const file of [store.scriptPath, store.configPath]) {
if (!fs.existsSync(file)) {
this.reportBad('SMOKE_HOME', `${file} is missing`)
return null
}
if (!fs.existsSync(store.configPath)) {
this.reportBad('SMOKE_HOME', `${store.configPath} is missing`)
return null
}
this.reportOk('store (SMOKE_HOME)', store.home)
return store
@@ -143,11 +116,6 @@ class SmokeTest {
const logLines: string[] = []
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)
if (paths.operatingSystem.length > 0 && paths.storeFolder.length > 0) {
this.reportOk('paths', `${paths.operatingSystem}${paths.storeFolder}`)
@@ -162,10 +130,44 @@ class SmokeTest {
}
const games = this.gameMapper.toDtoList(listing.games, listing.paths?.catalogBaseUrl ?? '')
this.reportListing(games)
this.reportAccess(listing, games)
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 {
const installable = games.filter((game: GameDto): boolean => game.installable)
const native = installable.filter((game: GameDto): boolean => game.mode === 'app').length
@@ -224,3 +226,19 @@ void new SmokeTest().run().then(
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 }
}
}
+8
View File
@@ -1,3 +1,4 @@
import type { AccountDto, SignInFinishedDto, SignInPromptDto } from './dto/AccountDto'
import type { AppStateDto } from './dto/AppStateDto'
import type { CatalogListingDto } from './dto/CatalogListingDto'
import type { InstalledStoreDto } from './dto/InstalledStoreDto'
@@ -30,6 +31,12 @@ export interface BridgeApi {
removeGame: (name: string) => Promise<void>
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>
installStore: (store: RegistryStoreDto) => Promise<InstalledStoreDto>
selectStore: (home: string) => Promise<StoreSelectionDto>
@@ -40,6 +47,7 @@ export interface BridgeApi {
onLog: (listener: StreamListener<string>) => void
onSyncEvent: (listener: StreamListener<SyncEventDto>) => void
onBusyChanged: (listener: StreamListener<boolean>) => void
onSignInFinished: (listener: StreamListener<SignInFinishedDto>) => void
}
/** The name the bridge is published under on `window`. */
+8 -1
View File
@@ -19,6 +19,11 @@ export const IPC_CHANNELS = {
catalogRemoveGame: 'catalog:removeGame',
catalogLaunchGame: 'catalog:launchGame',
accountRead: 'account:read',
accountBeginSignIn: 'account:beginSignIn',
accountCancelSignIn: 'account:cancelSignIn',
accountSignOut: 'account:signOut',
storeListRegistry: 'store:listRegistry',
storeInstallStore: 'store:installStore',
storeSelectStore: 'store:selectStore',
@@ -26,7 +31,9 @@ export const IPC_CHANNELS = {
/** Main to renderer, one way. */
streamLog: 'stream:log',
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
export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]
+21
View File
@@ -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
}
-5
View File
@@ -1,6 +1,5 @@
import type { Locale } from '../../i18n/MessageBundle'
import type { MessageBundle } from '../../i18n/MessageBundle'
import type { EngineVersionDto } from './EngineVersionDto'
import type { InstalledStoreDto } from './InstalledStoreDto'
/** Everything the window needs before it can paint anything. */
@@ -9,12 +8,8 @@ export interface AppStateDto {
readonly locales: readonly Locale[]
readonly messages: MessageBundle
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 stores: readonly InstalledStoreDto[]
readonly engine: EngineVersionDto | null
readonly minimumEngineVersion: string
readonly registryUrl: string
readonly defaultStoreRoot: string
readonly appVersion: string
@@ -1,3 +1,4 @@
import type { AccountDto } from './AccountDto'
import type { GameDto } from './GameDto'
import type { StorePathsDto } from './StorePathsDto'
@@ -6,4 +7,12 @@ export interface CatalogListingDto {
readonly games: readonly GameDto[]
readonly skipped: readonly string[]
readonly paths: StorePathsDto | null
/**
* Where this machine stood with the store when the catalog was read.
*
* Carried with the listing rather than asked for separately, because the entitlement
* each entry reports is only meaningful next to whether anybody was signed in when
* the catalog answered.
*/
readonly account: AccountDto
}
@@ -1,5 +0,0 @@
/** The installed engine's version, and whether this client can drive it. */
export interface EngineVersionDto {
readonly text: string
readonly supported: boolean
}
+18
View File
@@ -1,6 +1,19 @@
/** How a title runs: unpacked on this machine, or served as a web build. */
export type GameModeDto = 'app' | 'web'
/**
* Whether this person can install this title.
*
* Separate from `installable`, which is about the machine. A title can have a perfectly
* good build for this architecture and still not be yours.
*
* - `open` nothing to own; install it
* - `entitled` owned; install it
* - `purchasable` not owned, and here is the price
* - `signInRequired` the catalog would say, if it knew who was asking
*/
export type AccessVerdictDto = 'open' | 'entitled' | 'purchasable' | 'signInRequired'
/** Why a title cannot be installed on this machine. */
export type UnavailableReasonDto = 'platformOff' | 'hostAsset' | 'noAsset' | 'vetoed'
@@ -30,4 +43,9 @@ export interface GameDto {
readonly installable: boolean
readonly unavailableReason: UnavailableReasonDto | null
readonly unavailableDetail: string | null
readonly accessVerdict: AccessVerdictDto
/** Already formatted for the window's locale; null where there is no price to show. */
readonly priceLabel: string | null
/** Opened in the person's own browser. Null where the catalog named none. */
readonly purchaseUrl: string | null
}
+1 -3
View File
@@ -2,8 +2,6 @@
export interface RegistryStoreDto {
readonly name: string
readonly catalogUrl: string
/** Null when the store has no repository of its own; the engine's defaults are then used. */
readonly storeRepositoryUrl: string | null
/** Derived from the repository, the catalog host or the name — what the store will be called on disk. */
/** Derived from the catalog host, or the name — what the store will be called on disk. */
readonly storeId: string
}

Some files were not shown because too many files have changed in this diff Show More