7 Commits
Author SHA1 Message Date
mr.zeroandClaude Opus 5 6285d93790 Stores can be removed, and added from an address you type
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
Two gaps that were the same gap: the store list could only ever grow, and it could
only grow from what the registry happened to offer.

**Removing** uninstalls what the store installed, then deletes the store itself,
in that order. The order is the whole of it: `state.json` is the only record of
which payloads, icons and menu entries belong to a store, so deleting the home
first would strip the one thing that knows — leaving files nothing could ever
identify, least of all a later install of the same store into the same folder.
The confirmation says how many titles will go, because that is the part nobody
would otherwise expect. The token goes too; a credential for a store that is not
here is a secret kept for nothing.

The window names a *store*, never a path: the home is resolved against what a
disk scan actually found before anything is deleted, and `removeHome` refuses
anything else. That is the only guard between a bad argument and `rm -rf`, so it
has a test.

**Adding** moved to a + beside Refresh — both are actions on the whole store
rather than on one of them, and the full-width button under the list read as a
third store — and the picker now takes a catalog address as well as a listed one.
A bare host is enough and the name comes from the address; nothing else about
installing changes, which is why the typed path hands the same record to the same
method instead of growing a second one. The picker also has a Cancel now: opening
it with a store installed used to replace the grid with no way back.

`make storetest` is new, and it earned itself immediately. Removal is the only
code here that deletes a directory tree, which the smoke test cannot cover — it
runs against the real machine and would have to delete a real store to prove
anything. Two bugs on the first run:

- `http://` was accepted and became a store called *http*. The trailing slashes
  were stripped before the scheme was checked, turning `http://` into `http:` and
  then into `https://http:`, whose hostname parses as "http". The URL is rebuilt
  from the parsed form now, which also settles the trailing slash in one place.
- `STORE_ROOT` only *prepended* to the search path, so a "sandboxed" run still
  listed the real stores — despite the README saying "instead of the real one".
  Harmless while a sandbox could only add; not harmless now that it can delete.
  It replaces the search path.

The self-test needed two changes, both of which are it working: the store row is
a wrapper now, so clicking `.store-row` did nothing at all, and the footer icon
check counted exactly two named controls when there are three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 14:54:17 +02:00
mr.zeroandClaude Opus 5 255c588cbd The self-test was a stopwatch, not a check
ci/woodpecker/push/woodpecker Pipeline was successful
`--selftest` slept six seconds and then photographed whatever was on screen. On
the first cold run of the freshly packaged 2.4.0 — Gatekeeper checking the
bundle, a first DNS lookup, the catalog still in flight — six seconds was not
enough, and it reported an empty window as SELFTEST FAILED. Four runs
immediately afterwards passed with all 13 cards. The window was fine; the guess
about how long somebody else's machine takes was not.

It now polls for a settled window — a card, or the gate — and gives up only at a
30-second ceiling. The common case got *faster* than the old fixed wait (the run
finishes in about three seconds rather than always at least six), and the cold
case passes. A timeout is deliberately not a failure by itself: the report is
taken anyway and the existing checks decide, so a genuinely empty window still
fails for the right reason instead of as a bare timeout.

This is a diagnostic, not the product: nothing here runs without `--selftest`,
which is why 2.4.0 shipped as it is rather than being retagged over it. I made
the flake marginally likelier by adding one more startup request — the service
descriptor — so fixing it is mine to do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:17:05 +02:00
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
73 changed files with 2895 additions and 417 deletions
+6 -3
View File
@@ -36,7 +36,7 @@ BUILDER_ARGS := $(if $(STORES_API),-- --config.extraMetadata.warpEngine.registry
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.PHONY: help setup node-check build typecheck lint lint-fix check start smoke uitest test \ .PHONY: help setup node-check build typecheck lint lint-fix check start smoke uitest storetest test \
dist dist-mac dist-win dist-linux release publish clean distclean version dist dist-mac dist-win dist-linux release publish clean distclean version
help: ## List available targets help: ## List available targets
@@ -72,7 +72,7 @@ lint-fix: ## Lint and fix what can be fixed automatically
# The order is deliberate: a type error explains a lint error, and both explain a # The order is deliberate: a type error explains a lint error, and both explain a
# failing test, so the cheapest check that can fail runs first. # failing test, so the cheapest check that can fail runs first.
check: typecheck lint test ## Type-check, lint, and run both test suites check: typecheck lint test ## Type-check, lint, and run every test suite
start: ## Run the app against whatever store is installed start: ## Run the app against whatever store is installed
npm start npm start
@@ -83,7 +83,10 @@ smoke: ## Drive the store bridge with no window at all
uitest: ## Load the window once and report what rendered uitest: ## Load the window once and report what rendered
npm run uitest npm run uitest
test: smoke uitest ## Both checks storetest: ## Add and remove a store in a sandbox (the only code that deletes a tree)
npm run storetest
test: smoke storetest uitest ## All three checks
dist: node-check ## Package for this machine dist: node-check ## Package for this machine
npm run dist $(BUILDER_ARGS) npm run dist $(BUILDER_ARGS)
+109 -32
View File
@@ -55,6 +55,45 @@ so there was no resource seal and Gatekeeper refused it outright rather than
asking. `scripts/after-pack.js` signs the bundle during the build now, and the asking. `scripts/after-pack.js` signs the bundle during the build now, and the
result verifies as `valid on disk`. result verifies as `valid on disk`.
## Signing in, and titles that cost money
**Nothing in this client knows anything about a particular store.** What a title costs,
whether it needs an account, where to buy it and where to sign in all arrive from the
catalog's own server — WarpEngine 0.5 answers `GET /api/service` with what it offers, and
puts an `access` block on every catalog entry. A client that carried those facts would
work for exactly one shop; this one asks.
Where the server offers no sign-in — every WarpEngine before 0.5, and any store that
sells nothing — the window shows none, and behaves exactly as it always did.
Where it does:
- the side menu grows an **Account** block: *Sign in…*, and *Sign out* once you are;
- signing in shows a **short code**. Your browser opens on the store's own page and you
type the code there; approving it signs this device in. Nothing is typed into this
window, and no password ever reaches it — that is the whole reason for the detour;
- the token is kept in the **OS keychain** (Keychain, libsecret, DPAPI) through
Electron's `safeStorage`, one per store. Where no keychain is available it is not
stored at all rather than written out in the clear: the cost is signing in again next
run.
On a card, what you may do with a title is separate from what this machine can run:
- **owned** or free → *Install*, as before;
- **not owned** → the **price** on the card and a **Buy** button, which opens the store's
page in your browser. Buying happens there, not here — a checkout rebuilt in this
window would be a second place to get card handling wrong. **Refresh** afterwards and
the card becomes an *Install*;
- **signed out, catalog gates it** → *Sign in to install*, because the catalog cannot say
whether it is yours until it knows who is asking.
Two new categories go with it: **Owned** and **To buy**. Owning something is not the
same as having installed it, which is the point of the first one.
A title nobody has bought is **not** dimmed. That treatment belongs to what this
*machine* cannot do — an unsupported platform, no build for this architecture — and
there is nothing wrong with the machine here.
## Which store it installs ## Which store it installs
On first run the client fetches the registry and offers what it finds. One store On first run the client fetches the registry and offers what it finds. One store
@@ -62,35 +101,34 @@ and there is nothing to decide; several and the setup screen shows a picker.
```json ```json
[ [
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org", "storeRepositoryUrl": null }, { "name": "Teletype Games", "catalogUrl": "https://teletypegames.org" },
{ { "name": "Some Other Store", "catalogUrl": "https://games.example.org" }
"name": "Some Other Store",
"catalogUrl": "https://games.example.org",
"storeRepositoryUrl": "https://git.example.org/stores/other-desktop-store"
}
] ]
``` ```
**A store needs no repository of its own.** A name and a catalog are enough: the **A name and a catalog are the whole record.** The store engine's built-in defaults
store engine's built-in defaults already cover the host-to-asset mapping, the already cover the host-to-asset mapping, the install modes, the platforms and the
install modes, the platforms and the behaviour, so what is actually missing from behaviour, so what is actually missing from them is identity — and identity is all a
them is identity — a slug, a name and a catalog URL — and that is exactly what a registry says. Nothing a record carries decides where files go: how a store behaves is
registry record carries. With `storeRepositoryUrl` null the client writes a fixed per installed client, which knows its own machine, and a copy of that on a server
three-section config and the store installs. would be a second authority over decisions this side has already made.
From a record the client works out the rest: From a record the client works out the rest:
- **the store id** — which names the store home and the folder games land in — - **the store id** — which names the store home and the folder games land in — is a slug
comes from the repository name when there is one (`ttg-desktop-store` becomes of the catalog host (`teletypegames.org` becomes `teletypegames`), or of the display
`ttg`), otherwise from the catalog host (`teletypegames.org` becomes name if that fails. Derived from the *catalog* on purpose: the catalog is what a store
`teletypegames`), otherwise from the display name. A `config.json` that sets its is, so two records naming the same one are the same store and land in the same place.
own id keeps it. Reinstalling therefore never orphans what is already installed.
- **`catalogUrl` and `name`** override the config's own `store.base_url` and - **the games folder** is that same slug inside the OS's usual place for programs, and it
`store.name`. The registry says which catalog this store is *for*, so it wins. is the only subtree this store will ever delete from. That is the whole of how two
- **`storeRepositoryUrl`**, when given → the store's `config.json`, read from stores on one machine stay out of each other's files: a subfolder, derived here.
`…/raw/branch/master/config.json`. That file stays the authority on how the store - **released, archived and demo** titles are listed, where the engine alone would show
behaves: which platforms, which statuses, where things land. A repository released and archived only — a catalog that publishes a demo means it to be played.
**without** a `config.json` is treated as no repository at all.
Because a record has no paths in it and no config, there is nothing for the window to
tamper with: `RegistryStoreDtoMapper.toModel` can take its choice at face value, and the
config that lands on disk is written by the installer from the engine's own defaults.
What the defaults produce, for a record with no repository: the games land in a What the defaults produce, for a record with no repository: the games land in a
folder named after the store id, and released, archived **and demo** titles are folder named after the store id, and released, archived **and demo** titles are
@@ -121,13 +159,21 @@ Everything that is not a title lives in the **side menu** on the left, and the
another switches to it: the grid, the categories and the folders all follow, and another switches to it: the grid, the categories and the folders all follow, and
the client reopens on that store next time. Two stores installed from the same the client reopens on that store next time. Two stores installed from the same
catalog into different folders show their folder instead of their id, because catalog into different folders show their folder instead of their id, because
the id would not tell them apart. **Add a store…** brings up the registry the id would not tell them apart. Hovering a row shows a **bin**, which takes that
picker, the same one the first run offers. store off the machine — see below.
- **Actions** holds **Refresh**, which re-reads the catalog. Titles are installed - **+**, beside Refresh, brings up the picker: the stores the registry offers, and a
field for **any catalog address of your own**. A bare host is enough (`https` is
assumed) and the name is taken from it. This is the same screen the first run shows,
so a machine with no store yet can also start from a typed address rather than only
from the list.
- **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, and **+** to add one. Titles are installed
one at a time from their own cards; there is no install-everything button. one at a time from their own cards; there is no install-everything button.
- **Categories** narrows the grid, one category at a time, with the count next to - **Categories** narrows the grid, one category at a time, with the count next to
each: *Everything*, *Installed*, *Updates*, *Not installed*, then a row per each: *Everything*, *Installed*, *Updates*, *Not installed*, then a row per
**platform** (`godot`, `tic80`, `love`, …) and per **kind** (native or hosted). **platform** (`godot`, `tic80`, `love`, …) and per **kind** (native or hosted).
Where the catalog gates anything, **Owned** and **To buy** join them.
The axes are built from what the catalog actually contains — a platform with no The axes are built from what the catalog actually contains — a platform with no
titles is not listed, and a category that disappears under you falls back to titles is not listed, and a category that disappears under you falls back to
*Everything* rather than leaving an empty grid. There is no genre in a *Everything* rather than leaving an empty grid. There is no genre in a
@@ -161,6 +207,13 @@ While the store is working, only the things that would start a second call are
disabled: the menu, the log drawer and the category filters keep working, because disabled: the menu, the log drawer and the category filters keep working, because
they change what is on screen and nothing on disk. they change what is on screen and nothing on disk.
**Removing a store uninstalls what it installed.** The bin on a store row asks first,
and says how many titles will go with it. That is not a convenience — a store's
`state.json` is the only record of which payloads, icons and menu entries belong to it,
so leaving the games behind would leave orphans nothing could ever identify, least of
all a later install of the same store into the same folder. The catalog cache, the
settings and any sign-in token go too.
Anything installed from the window is a normal menu entry, so it also shows up in Anything installed from the window is a normal menu entry, so it also shows up in
your launcher, Dock or Start menu — the app does not have to be running to play. your launcher, Dock or Start menu — the app does not have to be running to play.
@@ -175,9 +228,11 @@ names. `make` on its own lists everything.
| `make build` | compile TypeScript, bundle the preload and the renderer | | `make build` | compile TypeScript, bundle the preload and the renderer |
| `make typecheck` | type-check everything, emitting nothing | | `make typecheck` | type-check everything, emitting nothing |
| `make lint` | the strict rule set (`lint-fix` fixes what it can) | | `make lint` | the strict rule set (`lint-fix` fixes what it can) |
| `make check` | **typecheck, lint and both test suites** — the gate | | `make check` | **typecheck, lint and every test suite** — the gate |
| `make start` | run the app against whatever store is installed | | `make start` | run the app against whatever store is installed |
| `make smoke` | drive the store with no window and no Electron at all | | `make smoke` | drive the store with no window and no Electron at all |
| `make storetest` | add and remove a store in a sandbox — the only code that deletes a tree |
| `SMOKE_HOME=<dir> SMOKE_TOKEN=<bearer> npm run smoke` | the same, against a sandbox store and as a signed-in person |
| `make uitest` | load the window once and report what rendered | | `make uitest` | load the window once and report what rendered |
| `SELFTEST_SHOT=shot.png npm run uitest` | the same, and the window photographs itself into that file | | `SELFTEST_SHOT=shot.png npm run uitest` | the same, and the window photographs itself into that file |
| `make test` | both test suites | | `make test` | both test suites |
@@ -273,9 +328,14 @@ on its own: publishing 1.2.0 got *"invalid username, password or token"* on the
second package while the first had just gone up with the same token, and the same second package while the first had just gone up with the same token, and the same
command succeeded immediately afterwards. command succeeded immediately afterwards.
Package names contain a space`WarpEngine Client-1.5.0-arm64.dmg`so the list of Package names have no spaces in them`WarpEngineClient-2.3.0-arm64.dmg`because a
files is passed one path per line rather than as one string; splitting it on space in a release asset is a space in every `curl`, script and shell command that ever
whitespace is what broke the first attempt at publishing 1.1.0. touches it. The app itself is still called **WarpEngine Client**: that name is what
appears in the Dock and in `/Applications`, and only the file names were the problem.
The list of files is still passed one path per line rather than as one string, since a
path given on the command line can contain a space even when a built one cannot;
splitting it on whitespace is what broke the first attempt at publishing 1.1.0.
It needs `tea` installed and logged in — the devarea repo has `make tea` for that. It needs `tea` installed and logged in — the devarea repo has `make tea` for that.
Overridable: `TAG`, `REPO`, `TEA_LOGIN`, `NOTES`, `DIST`. Overridable: `TAG`, `REPO`, `TEA_LOGIN`, `NOTES`, `DIST`.
@@ -293,14 +353,19 @@ own runtime.
single-instance lock. Otherwise a copy the user already has open swallows the test single-instance lock. Otherwise a copy the user already has open swallows the test
process, which exits 0 and reads as a pass. process, which exits 0 and reads as a pass.
Both test scripts accept a sandbox store instead of the real one, which is how The test scripts accept a sandbox store instead of the real one, which is how this
this repository is tested without touching a working installation: repository is tested without touching a working installation:
```sh ```sh
STORE_ROOT=/tmp/sandbox-root npm start STORE_ROOT=/tmp/sandbox-root npm start
SMOKE_HOME=/tmp/sandbox-root/ttg-desktop npm run smoke SMOKE_HOME=/tmp/sandbox-root/ttg-desktop npm run smoke
``` ```
**`STORE_ROOT` replaces the search path rather than being added to the front of it.**
It used to prepend, so a "sandboxed" run still listed the real stores and could switch
to one; now that a store can also be *removed*, a sandbox that can reach a working
installation is not a sandbox. `make storetest` relies on this.
### How it is put together ### How it is put together
TypeScript, in layers, with the dependency rule pointing inward. **[STRUCTURE.md](STRUCTURE.md) TypeScript, in layers, with the dependency rule pointing inward. **[STRUCTURE.md](STRUCTURE.md)
@@ -376,6 +441,18 @@ across them. One class serving three versions is the honest way to say that.
## Verified, and not ## Verified, and not
**2.2.0** — the registry record was cut back to a name and a catalog, so the whole
install path was measured again against a local registry serving exactly that. The slug
came out `teletypegames` from the catalog host, the home `teletypegames-desktop`, the
games subfolder `teletypegames`, and installing the same record twice landed in the same
home. A record carrying `config` and `storeRepositoryUrl` — the fields a stale client or a
tampering renderer might still send — changed nothing, because neither exists in the model
any more. The site side was migrated and its specs re-run; the frontend was built, which
first required removing a dead `engines` list that had been failing `vue-tsc` on master.
Older entries below describe what was verified for the version they name, and some of
them predate the store engine moving into this application.
The pipeline's commands were run in the same containers it uses, before the pipeline was The pipeline's commands were run in the same containers it uses, before the pipeline was
committed: `electronuserland/builder:22` installs, type-checks, lints, passes the smoke committed: `electronuserland/builder:22` installs, type-checks, lints, passes the smoke
test (registry reached, store skipped as it should be on a machine that has none) and test (registry reached, store skipped as it should be on a machine that has none) and
+25 -81
View File
@@ -1,86 +1,30 @@
# WarpEngine Client 2.0.0 # WarpEngine Client 2.5.0
**The store engine is part of the app. Nothing has to be installed on the machine any **A store can be taken off the machine again.** Hovering a store in the side menu shows
more.** Reading the catalog, choosing which release fits your computer, downloading and a bin. It asks first, and says how many titles will go with it — because removing a
unpacking it, writing the menu entry and remembering what went where all happen inside store *uninstalls what it installed*. That is not a convenience: a store's `state.json`
the application now. There is no Python to find, no child process, and no JSON protocol is the only record of which payloads, icons and menu entries belong to it, so leaving
between the two halves — which is why this is a major version rather than a feature. the games behind would leave orphans nothing could ever identify, least of all a later
install of the same store into the same folder. The catalog cache, the settings and any
sign-in token go too.
What that changes for a person: on Windows and on a fresh Mac the app simply works. **Adding one moved to a + beside Refresh**, and it now takes a catalog address of your
Before it looked for `python3`, `python` and `py -3`, and where none answered it drew a own as well as the ones the registry lists. A bare host is enough — `https` is assumed —
screen with a link to python.org instead of a catalog. That screen is gone, along with and the name is taken from the address. Both are actions on the whole store rather than
the one that offered to refresh a store engine too old to drive. on one of them, which is why they sit together; the full-width "Add a store…" button
under the list read as a third store.
**Your existing library is kept.** `config.json` and `state.json` on disk are unchanged — **The picker has a way out.** Opening it with a store already installed used to be a
the same field names, the same `<scope>:<name>` keys, the same file modes — so a machine trap: the grid was replaced and nothing short of installing something brought it back.
whose games were installed by the shell store keeps them. Opening this version against
such a store lists them as installed, offers no needless update, and a sync reports
*already up to date*. A `version: 1` state file is still migrated on first read.
The two Python files an earlier install left in the store folder are removed the next **Two things found by the new test.** `make storetest` adds and removes a store in a
time that store is set up. Nothing reads them, and a folder that still looks like it sandbox, because removal is the only code here that deletes a directory tree and the
holds the engine invites someone to run it against a state file this app is also writing. path it deletes is named by the window. It immediately caught that `http://` was
accepted and became a store called *http* — the trailing slashes were being stripped
before the scheme was checked — and that `STORE_ROOT` only *prepended* to the search
path, so a "sandboxed" run still listed the real stores. With a delete button in the
window, a sandbox that can reach a working installation is not a sandbox; it replaces
the search path now.
**The client knows which WarpEngine served a catalog.** Every WarpEngine API response **`btn-secondary` had no styling at all.** It was introduced in 2.4.0 on the card's
carries a `WarpEngine-Version` header, and the client now reads it, names the version in sign-in button and on the sign-in panel, and rendered as a plain button in both places.
the log, and picks the catalog dialect for it. `SUPPORTED_WARP_ENGINE_VERSIONS` lists what
this build was written against — 0.2, 0.3 and 0.4 — and the four cases are all handled:
| The header says | What the client does |
|---|---|
| a supported version | reads the catalog with that version's dialect |
| nothing at all | reads it as the oldest supported version — an engine before 0.4.0 sent no header |
| something older | the same, and says so in the log |
| something newer | tries the newest dialect anyway, warning that titles may be missed |
Adding a version to that list fails the build until somebody says what it reads like, in
the type checker and in the linter both. A new engine version cannot arrive unnoticed.
**Refresh and the language picker are icons.** They sit together at the foot of the side
menu, and the *Actions* heading that used to head a section of one button is gone. Both
carry their name as a tooltip and to a screen reader, and the language picker is still a
real `<select>` underneath — the native dropdown, keyboard and all, with only the glyph
showing.
### Also
No runtime dependencies, still: the zip reader the installer needs is about 150 lines over
`node:zlib` rather than a package. It restores the executable bit from each entry's
external attributes, which is what makes an unpacked game able to start at all, and it
refuses a zip64 archive, an unknown compression method or an entry that would be written
outside its destination rather than guessing.
The repository itself is free of Python too — the Makefile, the CI check and the release
script read `package.json` and the forge's JSON with Node now.
### 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: typecheck, lint, the headless smoke test and the window self-test.
The new engine was measured against the old one rather than trusted. On the same catalog
and the same config, the Python engine and this one produce **the same 13-title listing
with zero field differences** and the same resolved paths. Installing three titles — a
bare TIC-80 binary wrapped in a bundle, a Godot `.app` symlinked, and a hosted web entry —
gives **byte-identical payloads, identical file modes and an identical `Info.plist`**; the
only difference in the two trees is the sandbox path inside the generated launcher script.
`state.json` matches record for record.
The upgrade path was tested directly: pointed at a store home installed by the Python
engine, this one reports all three titles installed with no update available, and a
re-sync writes nothing. Remove, prune, prune-suppression on a named sync, and the v1→v2
state migration were each exercised. The zip reader was checked against Python's
`extractall` on an archive holding stored, deflated, directory and symlink entries —
identical bytes and identical modes — and its zip-slip and not-a-zip guards both fire.
+4 -1
View File
@@ -59,6 +59,8 @@ src/
infrastructure/ infrastructure/
engine/ the store engine: catalog, releases, install, state engine/ the store engine: catalog, releases, install, state
dialects/ one per WarpEngine version's catalog shape 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 launchers/ .desktop, .app bundle, .lnk — the three hosts
archive/ ZipArchive: a zip reader over node:zlib archive/ ZipArchive: a zip reader over node:zlib
files/ StoreFileSystem: atomic writes and the delete guard files/ StoreFileSystem: atomic writes and the delete guard
@@ -66,7 +68,8 @@ src/
http/ HttpTextClient, StoreHttpClient, HttpStatusError http/ HttpTextClient, StoreHttpClient, HttpStatusError
json/ JsonRecord: reading data that came from elsewhere json/ JsonRecord: reading data that came from elsewhere
config/ BuildConfiguration: what was decided when this was packaged config/ BuildConfiguration: what was decided when this was packaged
electron/ ApplicationEnvironment and GameLauncher adapters electron/ ApplicationEnvironment, GameLauncher, and the keychain
credential store
main/ main/
main.ts the entry point: one line of work main.ts the entry point: one line of work
ElectronApplication.ts lifecycle, single instance, self-test mode ElectronApplication.ts lifecycle, single instance, self-test mode
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "warp-engine-client", "name": "warp-engine-client",
"version": "1.2.0", "version": "2.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "warp-engine-client", "name": "warp-engine-client",
"version": "1.2.0", "version": "2.4.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
+18 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "warp-engine-client", "name": "warp-engine-client",
"productName": "WarpEngine Client", "productName": "WarpEngine Client",
"version": "2.0.0", "version": "2.5.0",
"description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.", "description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.",
"license": "MIT", "license": "MIT",
"author": "Teletype Games <games@teletype.hu>", "author": "Teletype Games <games@teletype.hu>",
@@ -21,7 +21,9 @@
"dist": "npm run build && electron-builder", "dist": "npm run build && electron-builder",
"dist:mac": "npm run build && electron-builder --mac", "dist:mac": "npm run build && electron-builder --mac",
"dist:win": "npm run build && electron-builder --win", "dist:win": "npm run build && electron-builder --win",
"dist:linux": "npm run build && electron-builder --linux" "dist:linux": "npm run build && electron-builder --linux",
"storetest": "npm run build && node build/scripts/StoreLifecycleTest.js",
"test": "npm run smoke && npm run storetest && npm run uitest"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
@@ -44,7 +46,11 @@
"target": [ "target": [
"dmg", "dmg",
"zip" "zip"
] ],
"artifactName": "WarpEngineClient-${version}-${arch}-mac.${ext}"
},
"dmg": {
"artifactName": "WarpEngineClient-${version}-${arch}.${ext}"
}, },
"win": { "win": {
"target": [ "target": [
@@ -52,6 +58,12 @@
"portable" "portable"
] ]
}, },
"nsis": {
"artifactName": "WarpEngineClient-Setup-${version}-${arch}.${ext}"
},
"portable": {
"artifactName": "WarpEngineClient-Portable-${version}-${arch}.${ext}"
},
"linux": { "linux": {
"category": "Game", "category": "Game",
"target": [ "target": [
@@ -59,6 +71,9 @@
"deb" "deb"
] ]
}, },
"appImage": {
"artifactName": "WarpEngineClient-${version}-${arch}.${ext}"
},
"afterPack": "scripts/after-pack.js" "afterPack": "scripts/after-pack.js"
}, },
"allowScripts": { "allowScripts": {
+4 -3
View File
@@ -53,9 +53,10 @@ api() {
curl -fsS -X "$method" -H "$AUTH" "$FORGE$path" "$@" curl -fsS -X "$method" -H "$AUTH" "$FORGE$path" "$@"
} }
# Package names contain spaces — "WarpEngine Client Setup 1.5.0.exe" does — so the list # The list lives one path per line in a file and is read with `while IFS= read -r`. The
# lives one path per line in a file and is read with `while IFS= read -r`. A single # built package names have no spaces in them any more, but a path given on the command
# variable looped over with $list splits on the space and uploads nothing. # line still can — and a single variable looped over with $list splits on the space and
# uploads nothing, which is a silent way to publish a release with no assets.
LIST="$(mktemp)" LIST="$(mktemp)"
trap 'rm -f "$LIST"' EXIT trap 'rm -f "$LIST"' EXIT
if [ "$#" -gt 0 ]; then if [ "$#" -gt 0 ]; then
+4 -3
View File
@@ -43,9 +43,10 @@ REPO="${REPO:-$(git remote get-url origin 2>/dev/null |
# #
# - the version filter, because dist/ keeps whatever earlier builds left there # - the version filter, because dist/ keeps whatever earlier builds left there
# and a release would quietly get the previous version's files attached; # and a release would quietly get the previous version's files attached;
# - the spaces. "WarpEngine Client-1.5.0-arm64.dmg" has one, so the list lives one # - the spaces. The built names have none since 2.3.0 — `WarpEngineClient-2.3.0-arm64.dmg`
# path per line in a file and is read with `while IFS= read -r`. Holding it in # — but a path given as an argument still can, so the list stays one path per line in
# a single variable and looping over $list splits it on the space. # a file, read with `while IFS= read -r`. Holding it in a single variable and looping
# over $list splits it on the space, and publishes nothing.
LIST="$(mktemp)" LIST="$(mktemp)"
trap 'rm -f "$LIST"' EXIT trap 'rm -f "$LIST"' EXIT
if [ "$#" -gt 0 ]; then if [ "$#" -gt 0 ]; then
+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 { Game } from '../../domain/models/Game'
import type { GameDto } from '../../shared/contracts/dto/GameDto' import type { GameDto } from '../../shared/contracts/dto/GameDto'
@@ -6,10 +7,11 @@ const ABSOLUTE_URL = /^https?:\/\//
/** /**
* A title as the window may see it. * A title as the window may see it.
* *
* Two decisions live here rather than in the renderer: the box art is resolved * Three decisions live here rather than in the renderer: the box art is resolved
* against the catalog's base URL, and whether a title can be launched is answered * against the catalog's base URL, whether a title can be launched is answered here —
* here — so the window never receives a filesystem path it could be talked into * so the window never receives a filesystem path it could be talked into opening —
* opening. * and the catalog's access block is reduced to a verdict and a printed price, because
* a view that had to reason about entitlement is a view with a rule in it.
*/ */
export class GameDtoMapper { export class GameDtoMapper {
public toDto (game: Game, catalogBaseUrl: string): GameDto { public toDto (game: Game, catalogBaseUrl: string): GameDto {
@@ -29,7 +31,10 @@ export class GameDtoMapper {
launchable: this.isLaunchable(game), launchable: this.isLaunchable(game),
installable: game.installable, installable: game.installable,
unavailableReason: game.unavailableReason, 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 return game.menuEntryPath !== null || game.executablePath !== null
} }
} }
/**
* A price as a person reads it, in the currency the catalog named.
*
* `Intl` with the *catalog's* currency and the system locale: the store decides what it
* charges in, the reader's machine decides where the symbol and the separators go.
* There is no conversion here and there must not be — inventing an exchange rate would
* be quoting a price nobody agreed to.
*/
function formatPrice (price: CatalogPrice | null): string | null {
if (price === null) return null
if (price.amountCents <= 0) return null
try {
return new Intl.NumberFormat(undefined, {
style: 'currency', currency: price.currency
}).format(price.amountCents / 100)
} catch {
// An unknown currency code: better the number and the code than nothing at all.
return `${(price.amountCents / 100).toFixed(2)} ${price.currency}`
}
}
@@ -7,7 +7,6 @@ export class RegistryStoreDtoMapper {
return { return {
name: store.name, name: store.name,
catalogUrl: store.catalogUrl, catalogUrl: store.catalogUrl,
storeRepositoryUrl: store.storeRepositoryUrl,
storeId: deriveStoreId(store) storeId: deriveStoreId(store)
} }
} }
@@ -18,10 +17,6 @@ export class RegistryStoreDtoMapper {
/** The window hands a record straight back when asking for an install. */ /** The window hands a record straight back when asking for an install. */
public toModel (dto: RegistryStoreDto): RegistryStore { public toModel (dto: RegistryStoreDto): RegistryStore {
return { return { name: dto.name, catalogUrl: dto.catalogUrl }
name: dto.name,
catalogUrl: dto.catalogUrl,
storeRepositoryUrl: dto.storeRepositoryUrl
}
} }
} }
+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)
})
}
@@ -45,6 +45,18 @@ export class PreferencesService {
this.merge({ storeHome: home }) this.merge({ storeHome: home })
} }
/**
* Stop remembering a store, for when it is no longer on the machine.
*
* The key is removed rather than blanked: an empty string would be a remembered home
* that matches nothing, and every reader would have to know to treat it as absent.
*/
public forgetStoreHome (): void {
const { storeHome, ...rest } = this.repository.read()
void storeHome
this.repository.write(rest)
}
private merge (changes: Preferences): void { private merge (changes: Preferences): void {
this.repository.write({ ...this.repository.read(), ...changes }) this.repository.write({ ...this.repository.read(), ...changes })
} }
@@ -4,6 +4,7 @@ import type { RegistryStore } from '../../domain/models/RegistryStore'
import { deriveStoreId } from '../../domain/models/StoreIdentity' import { deriveStoreId } from '../../domain/models/StoreIdentity'
import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository' import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository'
import type { StoreEngineInstaller } from '../../domain/ports/StoreEngineInstaller' import type { StoreEngineInstaller } from '../../domain/ports/StoreEngineInstaller'
import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository' import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository'
import type { StoreSelectionService } from './StoreSelectionService' import type { StoreSelectionService } from './StoreSelectionService'
@@ -19,7 +20,8 @@ export class StoreProvisioningService {
private readonly registry: StoreRegistryRepository, private readonly registry: StoreRegistryRepository,
private readonly installer: StoreEngineInstaller, private readonly installer: StoreEngineInstaller,
private readonly stores: InstalledStoreRepository, private readonly stores: InstalledStoreRepository,
private readonly selection: StoreSelectionService private readonly selection: StoreSelectionService,
private readonly catalogGateway: StoreCatalogGateway
) {} ) {}
public get registryUrl (): string { public get registryUrl (): string {
@@ -30,6 +32,14 @@ export class StoreProvisioningService {
return this.registry.listStores() return this.registry.listStores()
} }
/**
* Install the chosen store.
*
* The window's choice is taken at face value, which is safe because a record is only a
* name and a catalog: there is no path in it and nothing that decides what may be
* deleted. The store's own configuration is written by the installer from the engine's
* defaults, so the renderer cannot influence where anything lands.
*/
public async installStore ( public async installStore (
store: RegistryStore, store: RegistryStore,
progress?: EngineProgressListener progress?: EngineProgressListener
@@ -38,4 +48,80 @@ export class StoreProvisioningService {
const installed = await this.installer.installEngine(home, store, progress) const installed = await this.installer.installEngine(home, store, progress)
return this.selection.adoptStore(installed) return this.selection.adoptStore(installed)
} }
/**
* A catalog the registry does not offer.
*
* Nothing about installing changes — a record is still a name and a catalog, and the
* configuration still comes from the engine's defaults. What differs is only where
* the two fields came from, which is why this hands the same record to the same
* method rather than growing a second path.
*
* The name is derived from the host when none is given: it is a label for the picker,
* and asking somebody to invent one before they can try a URL is a question with no
* useful answer.
*/
public async installCatalog (
catalogUrl: string,
name: string | null = null,
progress?: EngineProgressListener
): Promise<InstalledStore> {
const url = normaliseCatalogUrl(catalogUrl)
const chosen: RegistryStore = { name: name?.trim() ?? '', catalogUrl: url }
return await this.installStore(
chosen.name.length > 0 ? chosen : { ...chosen, name: readHostName(url) },
progress
)
}
/**
* Remove a store: everything it installed, then the store itself.
*
* Whichever store is open afterwards is decided by re-reading the disk rather than
* guessed at here — removing the open one has to leave the window pointing at
* something that exists, and that answer lives in one place.
*/
public async removeStore (store: InstalledStore, progress?: EngineProgressListener): Promise<void> {
await this.catalogGateway.removeStore(store, progress)
this.selection.forgetStore(store)
}
}
/**
* What somebody typed, as a URL this can be used as.
*
* Two liberties taken on purpose, because both are what a person means: a bare host
* gets https, and a trailing slash goes. Anything still unparseable is refused here
* rather than at the first fetch — a store home written for a bad URL is a directory
* somebody has to find and delete.
*/
function normaliseCatalogUrl (value: string): string {
const trimmed = value.trim()
if (trimmed.length === 0) throw new Error('a catalog address is needed')
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
let parsed: URL
try {
parsed = new URL(withScheme)
} catch {
throw new Error(`not a usable address: ${value}`)
}
// A URL can parse and still have no host — `http://` does. That one used to slip
// through and become a store called "http", because the trailing slashes were being
// stripped *before* the scheme was checked, turning `http://` into `http:` and then
// into `https://http:`.
if (parsed.hostname.length === 0) throw new Error(`not a usable address: ${value}`)
// Rebuilt from the parsed URL rather than from the string: it drops the query and
// the fragment — a catalog is a base address, not a request — and settles the
// trailing slash in one place instead of at every call site that appends a path.
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '')
}
function readHostName (catalogUrl: string): string {
try {
return new URL(catalogUrl).hostname.replace(/^www\./, '')
} catch {
return catalogUrl
}
} }
@@ -40,6 +40,13 @@ export class StoreSelectionService {
return store return store
} }
/** The store at this home, or an error naming it. Does not change what is open. */
public requireStoreAt (home: string): InstalledStore {
const store = this.stores.findByHome(home)
if (store === null) throw new StoreMissingError(home)
return store
}
public selectStore (home: string): InstalledStore { public selectStore (home: string): InstalledStore {
const store = this.stores.findByHome(home) const store = this.stores.findByHome(home)
if (store === null) throw new StoreMissingError(home) if (store === null) throw new StoreMissingError(home)
@@ -55,6 +62,20 @@ export class StoreSelectionService {
return store return store
} }
/**
* Forget a store that is no longer on the machine.
*
* The next store is not chosen here: `findCurrentStore` re-reads the disk and applies
* the same rule it always does, so "which store is open" has exactly one answer in
* one place. Clearing the remembered home first is what stops it choosing the one
* that has just been deleted.
*/
public forgetStore (store: InstalledStore): void {
if (this.preferences.readStoreHome() === store.home) this.preferences.forgetStoreHome()
if (this.current?.home === store.home) this.current = null
this.findCurrentStore()
}
public readDefaultStoreRoot (): string { public readDefaultStoreRoot (): string {
return this.stores.readRoots()[0] ?? '' return this.stores.readRoots()[0] ?? ''
} }
+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 { Game } from './Game'
import type { StoreAccount } from './StoreAccount'
import type { StorePaths } from './StorePaths' import type { StorePaths } from './StorePaths'
/** One reading of a store's catalog. */ /** One reading of a store's catalog. */
@@ -6,4 +7,13 @@ export interface CatalogListing {
readonly games: readonly Game[] readonly games: readonly Game[]
readonly skipped: readonly string[] readonly skipped: readonly string[]
readonly paths: StorePaths | null readonly paths: StorePaths | null
/**
* Where this machine stands with the store, as of this reading.
*
* Part of the listing rather than a call of its own because it is the same answer
* from the same request: the catalog was fetched with whatever credential we hold,
* and what it said about entitlements is only meaningful next to whether anybody was
* signed in when it said it.
*/
readonly account: StoreAccount
} }
+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. */ /** How a title runs: unpacked on this machine, or served as a web build. */
export type GameMode = 'app' | 'web' export type GameMode = 'app' | 'web'
@@ -41,4 +43,12 @@ export interface Game {
readonly unavailableReason: UnavailableReason | null readonly unavailableReason: UnavailableReason | null
/** The engine's sentence for it, for a tooltip or the log. */ /** The engine's sentence for it, for a tooltip or the log. */
readonly unavailableDetail: string | null 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
} }
+6 -1
View File
@@ -6,8 +6,13 @@ import type { SelectedGame } from './SelectedGame'
* This is the shape `state.json` carries, keyed `<scope>:<name>`. Every path in it * 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 * is something the store put there and may therefore delete — which is why an
* uninstall reads the record rather than guessing at paths. * 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 SelectedGame { export interface InstalledRecord extends Omit<SelectedGame, 'access'> {
/** The unpacked archive's directory; null for a hosted entry, which has none. */ /** The unpacked archive's directory; null for a hosted entry, which has none. */
readonly payload: string | null readonly payload: string | null
readonly executable: string | null readonly executable: string | null
+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 * That is the whole record, and it is enough. How a store behaves is not the registry's
* there is one it stays the authority on how that store behaves — which platforms * business — this client carries its own store engine, whose defaults cover the
* it offers, where things land — and when there is not, the engine's own defaults * host-to-asset mapping, the install modes, the platforms and the behaviour — so what
* cover all of it and this record covers the identity. That is the whole reason a * was actually missing from those defaults is identity, and identity is all this is.
* store needs no repository of its own. *
* Keeping two stores on one machine out of each other's files is a subfolder, derived
* here from the store's own slug rather than told to us by a server.
*/ */
export interface RegistryStore { export interface RegistryStore {
readonly name: string readonly name: string
readonly catalogUrl: string readonly catalogUrl: string
readonly storeRepositoryUrl: string | null
} }
+5
View File
@@ -1,3 +1,4 @@
import type { CatalogAccess } from './CatalogAccess'
import type { UnavailableReason } from './Game' import type { UnavailableReason } from './Game'
/** /**
@@ -26,6 +27,8 @@ export interface SelectedGame {
readonly createdAt: string | null readonly createdAt: string | null
/** `app` for a native archive, `web` for a hosted page. */ /** `app` for a native archive, `web` for a hosted page. */
readonly mode: string readonly mode: string
/** What the catalog says about getting it; null from an engine that cannot say. */
readonly access: CatalogAccess | null
} }
/** /**
@@ -47,6 +50,8 @@ export interface UnavailableEntry {
readonly reason: UnavailableReason readonly reason: UnavailableReason
/** The sentence behind the code, for a tooltip or the log. */ /** The sentence behind the code, for a tooltip or the log. */
readonly detail: string 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. */ /** What a survey of the catalog found: installable, why not, and what was skipped. */
+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'
+10 -16
View File
@@ -3,28 +3,22 @@ import type { RegistryStore } from './RegistryStore'
/** /**
* A store id, from whatever the registry gave us. * A store id, from whatever the registry gave us.
* *
* The id names the store home, the folder games land in and the launcher files, so * The id names the store home, the folder games land in and the launcher files, so it
* it has to be short and filesystem-safe. Three sources, in order of how much they * has to be short and filesystem-safe. Two sources, in order of how much they were
* were meant to be a name: * meant to be a name:
* *
* 1. the repository name — `ttg-desktop-store` becomes `ttg`; * 1. the catalog host — `https://teletypegames.org` becomes `teletypegames`;
* 2. the catalog host — `https://teletypegames.org` becomes `teletypegames`; * 2. the display name, slugged, as a last resort.
* 3. the display name, slugged, as a last resort.
* *
* The store's own config.json overrides all of it whenever one exists. * Derived rather than carried, and derived from the catalog: the catalog is what a store
* *is*, so two records naming the same catalog are the same store and land in the same
* place, which is what keeps a reinstall from orphaning what is already there.
*/ */
export function deriveStoreId (store: RegistryStore): string { export function deriveStoreId (store: RegistryStore): string {
const fromRepository = store.storeRepositoryUrl === null return toSlug(readHostLabel(store.catalogUrl)) || toSlug(store.name) || 'store'
? ''
: (lastSegment(store.storeRepositoryUrl).replace(/-(desktop-)?store$/, ''))
return toSlug(fromRepository) || toSlug(readHostLabel(store.catalogUrl)) || toSlug(store.name) || 'store'
} }
function lastSegment (url: string): string { /** `https://www.teletypegames.org/x` -> `teletypegames`. */
return url.replace(/\/+$/, '').split('/').pop() ?? ''
}
/** `https://www.teletypegames.org/x` → `teletypegames`. */
function readHostLabel (catalogUrl: string): string { function readHostLabel (catalogUrl: string): string {
try { try {
const host = new URL(catalogUrl).hostname.replace(/^www\./, '') const host = new URL(catalogUrl).hostname.replace(/^www\./, '')
+1 -1
View File
@@ -18,7 +18,7 @@ export const WARP_ENGINE_VERSION_HEADER = 'warpengine-version'
* compile error until `selectCatalogDialect` says which dialect it gets, which is the * compile error until `selectCatalogDialect` says which dialect it gets, which is the
* point — a new engine version should not be able to arrive silently. * point — a new engine version should not be able to arrive silently.
*/ */
export const SUPPORTED_WARP_ENGINE_VERSIONS = ['0.2', '0.3', '0.4'] as const 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] export type SupportedWarpEngineVersion = typeof SUPPORTED_WARP_ENGINE_VERSIONS[number]
+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
}
@@ -7,4 +7,9 @@ export interface InstalledStoreRepository {
/** The roots that are searched, in the order the shell installer would use them. */ /** The roots that are searched, in the order the shell installer would use them. */
readRoots: () => readonly string[] readRoots: () => readonly string[]
resolveDefaultHome: (storeId: string) => string resolveDefaultHome: (storeId: string) => string
/**
* Delete a store home. Only a directory this repository would have *found* is
* accepted, so a caller cannot name an arbitrary path and have it removed.
*/
removeHome: (home: string) => void
} }
+26
View File
@@ -1,6 +1,7 @@
import type { CatalogListing } from '../models/CatalogListing' import type { CatalogListing } from '../models/CatalogListing'
import type { EngineProgressListener } from '../models/EngineProgress' import type { EngineProgressListener } from '../models/EngineProgress'
import type { InstalledStore } from '../models/InstalledStore' import type { InstalledStore } from '../models/InstalledStore'
import type { SignInPrompt, StoreAccount } from '../models/StoreAccount'
import type { StorePaths } from '../models/StorePaths' import type { StorePaths } from '../models/StorePaths'
/** /**
@@ -15,4 +16,29 @@ export interface StoreCatalogGateway {
readPaths: (store: InstalledStore, progress?: EngineProgressListener) => Promise<StorePaths> readPaths: (store: InstalledStore, progress?: EngineProgressListener) => Promise<StorePaths>
syncGames: (store: InstalledStore, names: readonly string[], progress?: EngineProgressListener) => Promise<void> syncGames: (store: InstalledStore, names: readonly string[], progress?: EngineProgressListener) => Promise<void>
removeGame: (store: InstalledStore, name: string, progress?: EngineProgressListener) => Promise<void> removeGame: (store: InstalledStore, name: string, progress?: EngineProgressListener) => Promise<void>
/**
* Take a whole store off this machine: everything it installed, then its own home.
*
* The games go first and deliberately so. A store's `state.json` is the only record
* of what it put where, so deleting the home first would strip the one thing that
* knows which payloads, icons and menu entries belong to it — leaving a library of
* orphans nothing can ever clean up.
*/
removeStore: (store: InstalledStore, progress?: EngineProgressListener) => Promise<void>
/** Whether this store offers a sign-in, and whether we are holding a token for it. */
readAccount: (store: InstalledStore) => Promise<StoreAccount>
/**
* Ask the store for a code pair. The *waiting* is not here: polling is a loop with a
* cancel in it, which is orchestration, and orchestration belongs above this port.
*/
requestSignIn: (store: InstalledStore, clientName: string) => Promise<SignInPrompt>
/** One poll. Returns the account once it is answered, or null while it is not. */
pollSignIn: (store: InstalledStore, deviceCode: string) => Promise<SignInPollResult>
signOut: (store: InstalledStore) => Promise<StoreAccount>
}
export interface SignInPollResult {
readonly state: 'pending' | 'approved' | 'denied' | 'expired'
readonly account: StoreAccount
} }
@@ -0,0 +1,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)
}
}
+16 -2
View File
@@ -41,15 +41,29 @@ export class CatalogClient {
private readonly configuration: StoreConfiguration, private readonly configuration: StoreConfiguration,
private readonly files: StoreFileSystem, private readonly files: StoreFileSystem,
private readonly cachePath: string, private readonly cachePath: string,
private readonly log: (line: string) => void 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({ this.http = new StoreHttpClient({
userAgent: `warp-engine-client/${CLIENT_VERSION} (${configuration.store.id})`, userAgent: `warp-engine-client/${CLIENT_VERSION} (${configuration.store.id})`,
timeout: configuration.behavior.timeout, timeout: configuration.behavior.timeout,
insecure: configuration.behavior.insecure 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 { public apiUrl (endpoint: 'catalog' | 'download', parameters?: Readonly<Record<string, string>>): string {
const { baseUrl, api } = this.configuration.store const { baseUrl, api } = this.configuration.store
const url = `${baseUrl}/${api[endpoint].replace(/^\/+/, '')}` const url = `${baseUrl}/${api[endpoint].replace(/^\/+/, '')}`
+3 -1
View File
@@ -134,7 +134,8 @@ export class CatalogSurveyor {
author: software.author, author: software.author,
imageUrl: software.imageUrl, imageUrl: software.imageUrl,
createdAt: release.createdAt, createdAt: release.createdAt,
mode mode,
access: entry.access
}) })
} }
return { games, reasons, unavailable } return { games, reasons, unavailable }
@@ -172,6 +173,7 @@ function toUnavailable (
): UnavailableEntry { ): UnavailableEntry {
const software: CatalogSoftware = entry.software const software: CatalogSoftware = entry.software
return { return {
access: entry.access,
name: software.name, name: software.name,
title: software.title, title: software.title,
platform: software.platform, platform: software.platform,
@@ -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'
}
}
@@ -10,19 +10,25 @@ import type { InstalledStore } from '../../domain/models/InstalledStore'
import type { import type {
CatalogSurvey, SelectedGame, UnavailableEntry CatalogSurvey, SelectedGame, UnavailableEntry
} from '../../domain/models/SelectedGame' } 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 { APP_MODE, WEB_MODE, type StoreConfiguration } from '../../domain/models/StoreConfiguration'
import type { StorePaths } from '../../domain/models/StorePaths' import type { StorePaths } from '../../domain/models/StorePaths'
import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway' import type { CredentialRepository } from '../../domain/ports/CredentialRepository'
import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository'
import type { SignInPollResult, StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
import { StoreFileSystem } from '../files/StoreFileSystem' import { StoreFileSystem } from '../files/StoreFileSystem'
import { CatalogClient, type FetchedCatalog } from './CatalogClient' import { CatalogClient, type FetchedCatalog } from './CatalogClient'
import { CatalogSurveyor } from './CatalogSurveyor' import { CatalogSurveyor } from './CatalogSurveyor'
import type { CatalogEntry } from './dialects/CatalogDialect' import type { CatalogEntry } from './dialects/CatalogDialect'
import { selectCatalogDialect } from './dialects/CatalogDialectSelector' import { selectCatalogDialect } from './dialects/CatalogDialectSelector'
import { DesktopLayoutResolver } from './DesktopLayoutResolver' import { DesktopLayoutResolver } from './DesktopLayoutResolver'
import { DeviceSignInClient } from './DeviceSignInClient'
import { GameInstaller } from './GameInstaller' import { GameInstaller } from './GameInstaller'
import { HostMachineDetector } from './HostMachineDetector' import { HostMachineDetector } from './HostMachineDetector'
import { LauncherWriter } from './launchers/LauncherWriter' import { LauncherWriter } from './launchers/LauncherWriter'
import { PayloadInstaller } from './PayloadInstaller' import { PayloadInstaller } from './PayloadInstaller'
import { ServiceDescriptorClient } from './ServiceDescriptorClient'
import { StoreConfigurationReader } from './StoreConfigurationReader' import { StoreConfigurationReader } from './StoreConfigurationReader'
import { StoreStateRepository } from './StoreStateRepository' import { StoreStateRepository } from './StoreStateRepository'
@@ -45,6 +51,15 @@ const CATALOG_CACHE_FILE_NAME = 'catalog.json'
export class NativeStoreCatalogGateway implements StoreCatalogGateway { export class NativeStoreCatalogGateway implements StoreCatalogGateway {
private readonly hosts = new HostMachineDetector() 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,
private readonly stores: InstalledStoreRepository = NO_STORES
) {}
public async listGames ( public async listGames (
store: InstalledStore, store: InstalledStore,
progress: EngineProgressListener = {} progress: EngineProgressListener = {}
@@ -53,7 +68,10 @@ export class NativeStoreCatalogGateway implements StoreCatalogGateway {
const host = this.hosts.findHost() const host = this.hosts.findHost()
engine.log(`host: ${host.operatingSystem}/${host.architecture}`) engine.log(`host: ${host.operatingSystem}/${host.architecture}`)
const survey = engine.surveyor.survey(await this.readEntries(engine), host) const [ descriptor, entries ] = await Promise.all([
engine.service.fetchDescriptor(), this.readEntries(engine)
])
const survey = engine.surveyor.survey(entries, host)
const installed = engine.state.readState() const installed = engine.state.readState()
// One list, both kinds: a client that hides what it cannot install leaves the // One list, both kinds: a client that hides what it cannot install leaves the
@@ -64,7 +82,12 @@ export class NativeStoreCatalogGateway implements StoreCatalogGateway {
].sort((left: Game, right: Game): number => ].sort((left: Game, right: Game): number =>
left.title.toLowerCase().localeCompare(right.title.toLowerCase())) left.title.toLowerCase().localeCompare(right.title.toLowerCase()))
return { games, skipped: survey.skipped, paths: this.toPaths(engine) } return {
games,
skipped: survey.skipped,
paths: this.toPaths(engine),
account: toAccount(descriptor, this.credentials.readToken(store.id))
}
} }
/** /**
@@ -132,6 +155,100 @@ export class NativeStoreCatalogGateway implements StoreCatalogGateway {
return Promise.resolve() return Promise.resolve()
} }
/**
* Take a whole store off this machine.
*
* The order is the whole of it. `state.json` is the only record of what this store
* put where — which payload, which icon, which menu entry — so the games have to go
* *before* the home does. Delete the home first and every one of those files is an
* orphan nothing will ever be able to identify, least of all a later install of the
* same store into the same folder.
*
* The token goes too: a credential for a store that is no longer here is a secret
* kept for nothing.
*/
public removeStore (store: InstalledStore, progress: EngineProgressListener = {}): Promise<void> {
const engine = this.openStore(store, progress)
const installed = engine.state.readState()
const count = installed.size
engine.installer.purge(engine.layout, installed)
engine.state.writeState(installed)
engine.launchers.refreshMenu(engine.layout)
engine.log(`removed ${String(count)} installed title(s)`)
this.credentials.clearToken(store.id)
this.stores.removeHome(store.home)
engine.log(`removed the store home ${store.home}`)
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. * Fetch the catalog and read it with the dialect its engine version calls for.
* *
@@ -157,7 +274,8 @@ export class NativeStoreCatalogGateway implements StoreCatalogGateway {
const layouts = new DesktopLayoutResolver(configuration, this.hosts) const layouts = new DesktopLayoutResolver(configuration, this.hosts)
const layout = layouts.resolveLayout() const layout = layouts.resolveLayout()
const catalog = new CatalogClient( const catalog = new CatalogClient(
configuration, files, path.join(store.home, CATALOG_CACHE_FILE_NAME), log) 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) const launchers = new LauncherWriter(configuration, layouts, files, log)
return { return {
@@ -167,6 +285,7 @@ export class NativeStoreCatalogGateway implements StoreCatalogGateway {
catalog, catalog,
launchers, launchers,
log, log,
service: new ServiceDescriptorClient(catalog.httpClient(), configuration.store.baseUrl, log),
surveyor: new CatalogSurveyor(configuration, log), surveyor: new CatalogSurveyor(configuration, log),
state: new StoreStateRepository(files, path.join(store.home, STATE_FILE_NAME), log), state: new StoreStateRepository(files, path.join(store.home, STATE_FILE_NAME), log),
installer: new GameInstaller( installer: new GameInstaller(
@@ -197,10 +316,16 @@ export class NativeStoreCatalogGateway implements StoreCatalogGateway {
installedVersion: record?.version ?? null, installedVersion: record?.version ?? null,
menuEntryPath: record?.menuEntry ?? null, menuEntryPath: record?.menuEntry ?? null,
executablePath: record?.executable ?? null, executablePath: record?.executable ?? null,
hostedUrl: game.mode === WEB_MODE ? engine.launchers.webUrl(game) : 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, installable: true,
unavailableReason: null, unavailableReason: null,
unavailableDetail: null unavailableDetail: null,
access: game.access
} }
} }
@@ -226,6 +351,7 @@ interface StoreEngineContext {
readonly layout: DesktopLayout readonly layout: DesktopLayout
readonly layouts: DesktopLayoutResolver readonly layouts: DesktopLayoutResolver
readonly catalog: CatalogClient readonly catalog: CatalogClient
readonly service: ServiceDescriptorClient
readonly launchers: LauncherWriter readonly launchers: LauncherWriter
readonly surveyor: CatalogSurveyor readonly surveyor: CatalogSurveyor
readonly state: StoreStateRepository readonly state: StoreStateRepository
@@ -258,10 +384,57 @@ function toUnavailableGame (entry: UnavailableEntry): Game {
hostedUrl: null, hostedUrl: null,
installable: false, installable: false,
unavailableReason: entry.reason, unavailableReason: entry.reason,
unavailableDetail: entry.detail 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 }
}
/**
* Removing a store needs the repository that found it; nothing else here does.
*
* The default refuses rather than pretending. A gateway assembled without one — the
* smoke test — reads catalogs perfectly well, and should say so plainly if somebody
* asks it to delete something, instead of silently doing nothing.
*/
const NO_STORES: InstalledStoreRepository = {
findAll: (): readonly [] => [],
findByHome: (): null => null,
readRoots: (): readonly [] => [],
resolveDefaultHome: (storeId: string): string => storeId,
removeHome: (): never => { throw new Error('this gateway was built without a store repository') }
}
/**
* 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 { function toMode (mode: string): GameMode {
return mode === WEB_MODE ? WEB_MODE : APP_MODE return mode === WEB_MODE ? WEB_MODE : APP_MODE
} }
@@ -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,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 }
}
@@ -1,3 +1,4 @@
import type { CatalogAccess } from '../../../domain/models/CatalogAccess'
import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngineVersion' import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngineVersion'
/** /**
@@ -17,6 +18,15 @@ export interface CatalogDialect {
export interface CatalogEntry { export interface CatalogEntry {
readonly software: CatalogSoftware 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. * The release the catalog itself calls newest-and-stable, or null when it names none.
* *
@@ -1,4 +1,5 @@
import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngineVersion' import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngineVersion'
import { AccessAwareCatalogDialect } from './AccessAwareCatalogDialect'
import type { CatalogDialect } from './CatalogDialect' import type { CatalogDialect } from './CatalogDialect'
import { SoftwareListCatalogDialect } from './SoftwareListCatalogDialect' import { SoftwareListCatalogDialect } from './SoftwareListCatalogDialect'
@@ -7,9 +8,11 @@ import { SoftwareListCatalogDialect } from './SoftwareListCatalogDialect'
* *
* The switch is exhaustive over `SUPPORTED_WARP_ENGINE_VERSIONS`, which is the whole * 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 * mechanism: adding a version to that list stops compiling here until somebody decides
* what it reads like. Three versions share one dialect today because the catalog's * what it reads like. Three versions share one dialect because the catalog's shape did
* shape has not changed across them — and one class serving three versions is the * not change across them — and one class serving three versions is the honest way to
* honest way to say that, rather than three identical ones pretending otherwise. * 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 { export function selectCatalogDialect (version: SupportedWarpEngineVersion): CatalogDialect {
switch (version) { switch (version) {
@@ -17,5 +20,7 @@ export function selectCatalogDialect (version: SupportedWarpEngineVersion): Cata
case '0.3': case '0.3':
case '0.4': case '0.4':
return new SoftwareListCatalogDialect(version) return new SoftwareListCatalogDialect(version)
case '0.5':
return new AccessAwareCatalogDialect(version)
} }
} }
@@ -2,6 +2,7 @@ import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngi
import { import {
asRecord, readOptionalString, readRecord, readString, type JsonRecord asRecord, readOptionalString, readRecord, readString, type JsonRecord
} from '../../json/JsonRecord' } from '../../json/JsonRecord'
import type { CatalogAccess } from '../../../domain/models/CatalogAccess'
import type { import type {
CatalogAsset, CatalogDialect, CatalogEntry, CatalogRelease, CatalogSoftware CatalogAsset, CatalogDialect, CatalogEntry, CatalogRelease, CatalogSoftware
} from './CatalogDialect' } from './CatalogDialect'
@@ -35,6 +36,7 @@ export class SoftwareListCatalogDialect implements CatalogDialect {
if (software === null) continue if (software === null) continue
found.push({ found.push({
software, software,
access: this.readAccess(entry),
latestRelease: this.readLatestRelease(entry), latestRelease: this.readLatestRelease(entry),
releaseCandidates: this.readCandidates(entry) releaseCandidates: this.readCandidates(entry)
}) })
@@ -42,8 +44,20 @@ export class SoftwareListCatalogDialect implements CatalogDialect {
return found 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. */ /** A title with no name is not a title: nothing could be keyed by it. */
private readSoftware (entry: JsonRecord): CatalogSoftware | null { protected readSoftware (entry: JsonRecord): CatalogSoftware | null {
const software = readRecord(entry, 'software') const software = readRecord(entry, 'software')
if (software === null) return null if (software === null) return null
const name = readOptionalString(software, 'name') const name = readOptionalString(software, 'name')
@@ -59,7 +73,7 @@ export class SoftwareListCatalogDialect implements CatalogDialect {
} }
} }
private readLatestRelease (entry: JsonRecord): CatalogRelease | null { protected readLatestRelease (entry: JsonRecord): CatalogRelease | null {
const latest = readRecord(entry, 'latestRelease') const latest = readRecord(entry, 'latestRelease')
return latest === null ? null : this.readRelease(latest) return latest === null ? null : this.readRelease(latest)
} }
@@ -70,7 +84,7 @@ export class SoftwareListCatalogDialect implements CatalogDialect {
* `releases` arrives newest-first from the API and `latestRelease` is usually its * `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. * first element, so identity is settled on the release's own id where it has one.
*/ */
private readCandidates (entry: JsonRecord): readonly CatalogRelease[] { protected readCandidates (entry: JsonRecord): readonly CatalogRelease[] {
const records: JsonRecord[] = [] const records: JsonRecord[] = []
const latest = readRecord(entry, 'latestRelease') const latest = readRecord(entry, 'latestRelease')
if (latest !== null) records.push(latest) if (latest !== null) records.push(latest)
@@ -93,7 +107,7 @@ export class SoftwareListCatalogDialect implements CatalogDialect {
return candidates return candidates
} }
private readRelease (release: JsonRecord): CatalogRelease { protected readRelease (release: JsonRecord): CatalogRelease {
const assets: CatalogAsset[] = [] const assets: CatalogAsset[] = []
const listed = release['assets'] const listed = release['assets']
if (Array.isArray(listed)) { if (Array.isArray(listed)) {
+102 -14
View File
@@ -12,6 +12,7 @@ export interface HttpResponseBody {
readonly contentType: string readonly contentType: string
/** Lower-cased names, as Node delivers them. The engine version arrives in one. */ /** Lower-cased names, as Node delivers them. The engine version arrives in one. */
readonly headers: Readonly<Record<string, string>> readonly headers: Readonly<Record<string, string>>
readonly statusCode: number
} }
export interface StoreHttpOptions { export interface StoreHttpOptions {
@@ -19,6 +20,22 @@ export interface StoreHttpOptions {
/** Seconds, as the store config states it. */ /** Seconds, as the store config states it. */
readonly timeout: number readonly timeout: number
readonly insecure: boolean 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[]
} }
/** /**
@@ -33,8 +50,8 @@ export interface StoreHttpOptions {
export class StoreHttpClient { export class StoreHttpClient {
public constructor (private readonly options: StoreHttpOptions) {} public constructor (private readonly options: StoreHttpOptions) {}
public async readBytes (url: string): Promise<HttpResponseBody> { public async readBytes (url: string, request: RequestOptions = {}): Promise<HttpResponseBody> {
return await this.request(url, MAX_REDIRECTS, async ( return await this.request(url, MAX_REDIRECTS, request, async (
response: http.IncomingMessage response: http.IncomingMessage
): Promise<HttpResponseBody> => { ): Promise<HttpResponseBody> => {
const chunks: Buffer[] = [] const chunks: Buffer[] = []
@@ -42,11 +59,31 @@ export class StoreHttpClient {
return { return {
body: Buffer.concat(chunks), body: Buffer.concat(chunks),
contentType: response.headers['content-type'] ?? '', contentType: response.headers['content-type'] ?? '',
headers: readHeaders(response) 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. * Stream `url` into `destination` atomically. Returns bytes written.
* *
@@ -60,7 +97,7 @@ export class StoreHttpClient {
let written = 0 let written = 0
try { try {
await this.request(url, MAX_REDIRECTS, async (response: http.IncomingMessage): Promise<void> => { await this.request(url, MAX_REDIRECTS, {}, async (response: http.IncomingMessage): Promise<void> => {
response.on('data', (chunk: Buffer): void => { written += chunk.length }) response.on('data', (chunk: Buffer): void => { written += chunk.length })
await pipeline(response, fs.createWriteStream(partial)) await pipeline(response, fs.createWriteStream(partial))
}) })
@@ -76,42 +113,93 @@ export class StoreHttpClient {
private async request<TResult> ( private async request<TResult> (
url: string, url: string,
redirectsLeft: number, redirectsLeft: number,
consume: (response: http.IncomingMessage) => Promise<TResult> request: RequestOptions,
consume: (response: http.IncomingMessage) => Promise<TResult>,
origin: string = originOf(url)
): Promise<TResult> { ): Promise<TResult> {
const response = await this.open(url) const response = await this.open(url, request, origin)
const status = response.statusCode ?? 0 const status = response.statusCode ?? 0
const location = response.headers.location const location = response.headers.location
if (status >= 300 && status < 400 && location !== undefined) { if (status >= 300 && status < 400 && location !== undefined) {
response.resume() response.resume()
if (redirectsLeft <= 0) throw new Error(`too many redirects for ${url}`) if (redirectsLeft <= 0) throw new Error(`too many redirects for ${url}`)
return await this.request(new URL(location, url).toString(), redirectsLeft - 1, consume) 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) { if (status !== 200 && !(request.accept ?? []).includes(status)) {
response.resume() response.resume()
throw new HttpStatusError(url, status) throw new HttpStatusError(url, status)
} }
return await consume(response) return await consume(response)
} }
private async open (url: string): Promise<http.IncomingMessage> { private async open (
url: string,
request: RequestOptions,
origin: string
): Promise<http.IncomingMessage> {
return new Promise<http.IncomingMessage>(( return new Promise<http.IncomingMessage>((
resolve: (response: http.IncomingMessage) => void, resolve: (response: http.IncomingMessage) => void,
reject: (error: Error) => void reject: (error: Error) => void
): void => { ): void => {
const secure = !url.startsWith('http://') const secure = !url.startsWith('http://')
const client = secure ? https : http const client = secure ? https : http
const request = client.get(url, { const outgoing = client.request(url, {
headers: { 'User-Agent': this.options.userAgent }, method: request.method ?? 'GET',
headers: this.buildHeaders(url, request, origin),
...(secure && this.options.insecure ? { rejectUnauthorized: false } : {}) ...(secure && this.options.insecure ? { rejectUnauthorized: false } : {})
}, resolve) }, resolve)
request.setTimeout(Math.max(1, this.options.timeout) * 1000, (): void => { outgoing.setTimeout(Math.max(1, this.options.timeout) * 1000, (): void => {
request.destroy(new Error(`${url} timed out`)) outgoing.destroy(new Error(`${url} timed out`))
}) })
request.on('error', reject) 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 ''
}
} }
/** /**
@@ -13,8 +13,10 @@ const CONFIG_FILE_NAME = 'config.json'
* Finds stores where they were put. * Finds stores where they were put.
* *
* The roots are searched in the shell installers' own order — those homes are still * 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 * valid stores. `STORE_ROOT` replaces the lot: a sandbox has to be a sandbox, and it
* touching a working installation, which is how this repository is tested. * only prepended before, so a "sandboxed" run still listed the real stores, could
* switch to one, and — now that stores can be removed — could delete one. The README
* always said "instead of the real one"; this is the behaviour catching up.
*/ */
export class FileSystemInstalledStoreRepository implements InstalledStoreRepository { export class FileSystemInstalledStoreRepository implements InstalledStoreRepository {
public findAll (): readonly InstalledStore[] { public findAll (): readonly InstalledStore[] {
@@ -34,10 +36,11 @@ export class FileSystemInstalledStoreRepository implements InstalledStoreReposit
} }
public readRoots (): readonly string[] { public readRoots (): readonly string[] {
const override = process.env['STORE_ROOT']
if (override !== undefined && override.length > 0) return [override]
const home = os.homedir() const home = os.homedir()
const roots: string[] = [] const roots: string[] = []
const override = process.env['STORE_ROOT']
if (override !== undefined && override.length > 0) roots.push(override)
const xdgDataHome = process.env['XDG_DATA_HOME'] const xdgDataHome = process.env['XDG_DATA_HOME']
if (xdgDataHome !== undefined && xdgDataHome.length > 0) { if (xdgDataHome !== undefined && xdgDataHome.length > 0) {
roots.push(path.join(xdgDataHome, STORE_DIRECTORY_NAME)) roots.push(path.join(xdgDataHome, STORE_DIRECTORY_NAME))
@@ -58,6 +61,22 @@ export class FileSystemInstalledStoreRepository implements InstalledStoreReposit
return path.join(root, `${storeId}${DESKTOP_STORE_ENGINE.homeSuffix}`) return path.join(root, `${storeId}${DESKTOP_STORE_ENGINE.homeSuffix}`)
} }
/**
* Remove a store home, and only one this repository actually found.
*
* The check is the point. This is the one call in the application that deletes a
* directory tree the *window* named, and the window is the least trusted thing here;
* resolving the path against what a scan returns means a caller can ask for the
* removal of a store, never of a path.
*/
public removeHome (home: string): void {
const known = this.findByHome(home)
if (known === null) {
throw new Error(`not a store home on this machine: ${home}`)
}
fs.rmSync(known.home, { recursive: true, force: true })
}
private readDirectories (root: string): readonly string[] { private readDirectories (root: string): readonly string[] {
try { try {
return fs.readdirSync(root, { withFileTypes: true }) return fs.readdirSync(root, { withFileTypes: true })
@@ -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 * field a build was packaged with (for shipping a client for another site), and finally
* the address of ours. * the address of ours.
* *
* A record needs a name and a catalog URL; those two make a store. The repository * A name and a catalog URL make a store, and are all a record carries. Anything else it
* is optional and arrives as null when absent — a store configured by nothing but * happens to say is ignored: how a store behaves is this client's own business, decided
* this record installs on the engine's defaults. Records missing either of the two * by the engine it ships with. Records missing either field are dropped rather than
* required fields are dropped rather than half-used. * half-used.
*/ */
export class HttpStoreRegistryRepository implements StoreRegistryRepository { export class HttpStoreRegistryRepository implements StoreRegistryRepository {
public readonly sourceUrl: string public readonly sourceUrl: string
@@ -44,18 +44,12 @@ export class HttpStoreRegistryRepository implements StoreRegistryRepository {
return parsed return parsed
.map((row: unknown): JsonRecord | null => asRecord(row)) .map((row: unknown): JsonRecord | null => asRecord(row))
.filter((row: JsonRecord | null): row is JsonRecord => row !== null) .filter((row: JsonRecord | null): row is JsonRecord => row !== null)
.map((row: JsonRecord): RegistryStore => { // Both spellings, because a registry is someone else's API: ours answers
// Both spellings, because a registry is someone else's API: ours answers // camelCase, and a hand-rolled one may not.
// camelCase, and a hand-rolled one may not. .map((row: JsonRecord): RegistryStore => ({
const repository = ( name: readString(row, 'name').trim(),
readString(row, 'storeRepositoryUrl') || readString(row, 'store_repository_url') catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim()
).trim() }))
return {
name: readString(row, 'name').trim(),
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim(),
storeRepositoryUrl: repository.length > 0 ? repository : null
}
})
.filter((store: RegistryStore): boolean => .filter((store: RegistryStore): boolean =>
store.name.length > 0 && store.catalogUrl.length > 0) store.name.length > 0 && store.catalogUrl.length > 0)
} }
@@ -6,12 +6,8 @@ import type { RegistryStore } from '../../domain/models/RegistryStore'
import { DESKTOP_STORE_ENGINE } from '../../domain/models/StoreEngine' import { DESKTOP_STORE_ENGINE } from '../../domain/models/StoreEngine'
import { deriveStoreId } from '../../domain/models/StoreIdentity' import { deriveStoreId } from '../../domain/models/StoreIdentity'
import type { StoreEngineInstaller } from '../../domain/ports/StoreEngineInstaller' 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 CONFIG_FILE_NAME = 'config.json'
const DEFAULT_FORGE_BASE = 'https://git.teletypegames.org'
const DEFAULT_BRANCH = 'master'
/** What an install used to leave in a store home, back when the engine was a script. */ /** 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'] const RETIRED_ENGINE_FILES: readonly string[] = ['desktop_store.py', 'warpstore.py']
@@ -19,51 +15,63 @@ const RETIRED_ENGINE_FILES: readonly string[] = ['desktop_store.py', 'warpstore.
/** /**
* Setting up a store where there is none. * Setting up a store where there is none.
* *
* Since the engine moved into this application there is nothing to download but the * Nothing is downloaded and nothing is asked of a server. The engine ships in this
* store's own configuration, so an install is one HTTP call and one file. The store * application and its defaults already cover the host-to-asset mapping, the install
* home stays where it was and keeps its name, because the state and the catalog cache * modes, the platforms and the behaviour; what a registry record adds is identity — a
* beside that config are what make an existing library recognisable. * 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 { export class NativeStoreEngineInstaller implements StoreEngineInstaller {
private readonly forgeBase: string public installEngine (
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, home: string,
store: RegistryStore, store: RegistryStore,
progress: EngineProgressListener = {} progress: EngineProgressListener = {}
): Promise<InstalledStore> { ): Promise<InstalledStore> {
fs.mkdirSync(home, { recursive: true }) fs.mkdirSync(home, { recursive: true })
const config = await this.readStoreConfig(store, progress) const storeId = deriveStoreId(store)
const configPath = path.join(home, CONFIG_FILE_NAME) const configPath = path.join(home, CONFIG_FILE_NAME)
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) fs.writeFileSync(configPath, `${JSON.stringify(this.buildConfig(store, storeId), null, 2)}\n`)
this.removeRetiredEngine(home, progress) this.removeRetiredEngine(home, progress)
progress.onLog?.(`${store.name} is set up in ${home}`) progress.onLog?.(`${store.name} is set up in ${home}`)
const configStore = asRecord(config['store']) return Promise.resolve({
return { id: storeId,
id: configStore === null ? deriveStoreId(store) : readString(configStore, 'id', deriveStoreId(store)),
name: store.name, name: store.name,
home, home,
configPath, configPath,
engine: DESKTOP_STORE_ENGINE.id 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. * Clear out the scripts an older client downloaded here.
* *
* A store home provisioned by 1.5.0 or by the shell installer holds two Python * A store home provisioned by 1.5.0 or by a shell installer holds two Python files
* files that nothing reads any more. They are harmless, but a directory that still * that nothing reads any more. They are harmless, but a directory that still looks
* looks like it holds the engine invites someone to run it against a state file * like it holds the engine invites someone to run it against a state file this
* this application is also writing. * application is also writing.
*/ */
private removeRetiredEngine (home: string, progress: EngineProgressListener): void { private removeRetiredEngine (home: string, progress: EngineProgressListener): void {
for (const fileName of RETIRED_ENGINE_FILES) { for (const fileName of RETIRED_ENGINE_FILES) {
@@ -73,79 +81,4 @@ export class NativeStoreEngineInstaller implements StoreEngineInstaller {
progress.onLog?.(`removed the retired ${fileName}`) progress.onLog?.(`removed the retired ${fileName}`)
} }
} }
/**
* The store's configuration.
*
* Three cases, and all of them install:
*
* - **a repository with a config.json** — that file is the authority on how the
* store behaves: which platforms it offers, which statuses it shows, where
* things land;
* - **a repository without one** (404) — the engine's defaults, as below;
* - **no repository at all** — the same defaults, without the round trip.
*
* The engine's built-in defaults already cover the host-to-asset mapping, the
* modes, the platforms and the behaviour, so what a store actually has to supply is
* identity: a slug, a name and a catalog. That is exactly what a registry record
* carries, which is why a store needs no repository of its own. The registry always
* wins on those three, whatever a config file says.
*/
private async readStoreConfig (
store: RegistryStore,
progress: EngineProgressListener
): Promise<Record<string, unknown>> {
const storeId = deriveStoreId(store)
const config = await this.readPublishedConfig(store, storeId, progress)
const existing = asRecord(config['store']) ?? {}
config['store'] = {
...existing,
id: readString(existing, 'id', storeId),
name: store.name,
base_url: store.catalogUrl
}
return config
}
private async readPublishedConfig (
store: RegistryStore,
storeId: string,
progress: EngineProgressListener
): Promise<Record<string, unknown>> {
const repositoryUrl = store.storeRepositoryUrl
if (repositoryUrl === null) {
progress.onLog?.(`${store.name} has no store repository — using the engine defaults`)
return this.defaultConfig(storeId)
}
try {
progress.onLog?.(`reading the store config from ${repositoryUrl}`)
const body = await this.httpClient.readText(this.configUrl(repositoryUrl))
return { ...(asRecord(JSON.parse(body)) ?? {}) }
} catch (error: unknown) {
if (!(error instanceof HttpStatusError) || error.statusCode !== 404) throw error
progress.onLog?.('no config.json in the store repository — using the engine defaults')
return this.defaultConfig(storeId)
}
}
/**
* What a store gets when nothing else says otherwise.
*
* Two fields, on top of the identity added by the caller. The subfolder keeps two
* stores on one machine out of each other's files, and it is the prune boundary, so
* it must be the store's own. Demo titles are listed because a catalog that
* publishes them means them to be played — the engine defaults to released and
* archived only, which is the safer default for a store nobody configured.
*/
private defaultConfig (storeId: string): Record<string, unknown> {
return {
paths: { subfolder: storeId },
catalog: { statuses: ['released', 'archived', 'demo'] }
}
}
private configUrl (repositoryUrl: string, branch: string = DEFAULT_BRANCH): string {
return `${repositoryUrl.replace(/\/+$/, '')}/raw/branch/${branch}/${CONFIG_FILE_NAME}`
}
} }
+13 -4
View File
@@ -1,4 +1,5 @@
import type { App, IpcMain, Shell } from 'electron' import type { App, IpcMain, Shell } from 'electron'
import { AccountService } from '../../application/services/AccountService'
import { ApplicationStateService } from '../../application/services/ApplicationStateService' import { ApplicationStateService } from '../../application/services/ApplicationStateService'
import { CatalogService } from '../../application/services/CatalogService' import { CatalogService } from '../../application/services/CatalogService'
import { GameLaunchService } from '../../application/services/GameLaunchService' import { GameLaunchService } from '../../application/services/GameLaunchService'
@@ -7,12 +8,14 @@ import { StoreProvisioningService } from '../../application/services/StoreProvis
import { StoreSelectionService } from '../../application/services/StoreSelectionService' import { StoreSelectionService } from '../../application/services/StoreSelectionService'
import { ElectronApplicationEnvironment } from '../../infrastructure/electron/ElectronApplicationEnvironment' import { ElectronApplicationEnvironment } from '../../infrastructure/electron/ElectronApplicationEnvironment'
import { ElectronGameLauncher } from '../../infrastructure/electron/ElectronGameLauncher' import { ElectronGameLauncher } from '../../infrastructure/electron/ElectronGameLauncher'
import { SafeStorageCredentialRepository } from '../../infrastructure/electron/SafeStorageCredentialRepository'
import { NativeStoreCatalogGateway } from '../../infrastructure/engine/NativeStoreCatalogGateway' import { NativeStoreCatalogGateway } from '../../infrastructure/engine/NativeStoreCatalogGateway'
import { HttpTextClient } from '../../infrastructure/http/HttpTextClient' import { HttpTextClient } from '../../infrastructure/http/HttpTextClient'
import { FileSystemInstalledStoreRepository } from '../../infrastructure/repositories/FileSystemInstalledStoreRepository' import { FileSystemInstalledStoreRepository } from '../../infrastructure/repositories/FileSystemInstalledStoreRepository'
import { NativeStoreEngineInstaller } from '../../infrastructure/repositories/NativeStoreEngineInstaller' import { NativeStoreEngineInstaller } from '../../infrastructure/repositories/NativeStoreEngineInstaller'
import { HttpStoreRegistryRepository } from '../../infrastructure/repositories/HttpStoreRegistryRepository' import { HttpStoreRegistryRepository } from '../../infrastructure/repositories/HttpStoreRegistryRepository'
import { JsonFilePreferencesRepository } from '../../infrastructure/repositories/JsonFilePreferencesRepository' import { JsonFilePreferencesRepository } from '../../infrastructure/repositories/JsonFilePreferencesRepository'
import { AccountIpcController } from '../ipc/AccountIpcController'
import { AppIpcController } from '../ipc/AppIpcController' import { AppIpcController } from '../ipc/AppIpcController'
import { CatalogIpcController } from '../ipc/CatalogIpcController' import { CatalogIpcController } from '../ipc/CatalogIpcController'
import { IpcRouter } from '../ipc/IpcRouter' import { IpcRouter } from '../ipc/IpcRouter'
@@ -35,6 +38,7 @@ export class ServiceContainer {
public readonly provisioning: StoreProvisioningService public readonly provisioning: StoreProvisioningService
public readonly state: ApplicationStateService public readonly state: ApplicationStateService
public readonly launching: GameLaunchService public readonly launching: GameLaunchService
public readonly accounts: AccountService
private readonly controllers: readonly { register: (router: IpcRouter) => void }[] private readonly controllers: readonly { register: (router: IpcRouter) => void }[]
@@ -46,15 +50,19 @@ export class ServiceContainer {
const httpClient = new HttpTextClient() const httpClient = new HttpTextClient()
const stores = new FileSystemInstalledStoreRepository() const stores = new FileSystemInstalledStoreRepository()
const catalogGateway = new NativeStoreCatalogGateway() const credentials = new SafeStorageCredentialRepository(environment)
const catalogGateway = new NativeStoreCatalogGateway(credentials, stores)
const registry = new HttpStoreRegistryRepository(httpClient) const registry = new HttpStoreRegistryRepository(httpClient)
const installer = new NativeStoreEngineInstaller(httpClient) const installer = new NativeStoreEngineInstaller()
const preferencesRepository = new JsonFilePreferencesRepository(environment) const preferencesRepository = new JsonFilePreferencesRepository(environment)
const preferences = new PreferencesService(preferencesRepository, environment) const preferences = new PreferencesService(preferencesRepository, environment)
this.selection = new StoreSelectionService(stores, preferences) this.selection = new StoreSelectionService(stores, preferences)
this.catalog = new CatalogService(catalogGateway, this.selection) this.catalog = new CatalogService(catalogGateway, this.selection)
this.provisioning = new StoreProvisioningService(registry, installer, stores, this.selection) this.accounts = new AccountService(catalogGateway, this.selection)
this.provisioning = new StoreProvisioningService(
registry, installer, stores, this.selection, catalogGateway
)
this.launching = new GameLaunchService(new ElectronGameLauncher(shell), this.catalog) this.launching = new GameLaunchService(new ElectronGameLauncher(shell), this.catalog)
this.state = new ApplicationStateService( this.state = new ApplicationStateService(
preferences, this.selection, this.provisioning, environment preferences, this.selection, this.provisioning, environment
@@ -63,7 +71,8 @@ export class ServiceContainer {
this.controllers = [ this.controllers = [
new AppIpcController(this.state, preferences, this.launching), new AppIpcController(this.state, preferences, this.launching),
new CatalogIpcController(this.catalog, this.launching, this.guard, this.streams), new CatalogIpcController(this.catalog, this.launching, this.guard, this.streams),
new StoreIpcController(this.provisioning, this.selection, this.guard, this.streams) new StoreIpcController(this.provisioning, this.selection, this.guard, this.streams),
new AccountIpcController(this.accounts, this.streams)
] ]
} }
+72 -7
View File
@@ -4,7 +4,20 @@ import {
asRecord, readBoolean, readNumber, readOptionalString, readString, readStringArray asRecord, readBoolean, readNumber, readOptionalString, readString, readStringArray
} from '../../infrastructure/json/JsonRecord' } from '../../infrastructure/json/JsonRecord'
const SETTLE_DELAY_MS = 6_000 /**
* How long to keep waiting for the window to have something on it.
*
* This used to be a flat six-second sleep, which is a guess about somebody else's
* machine: on a cold start a freshly built app, Gatekeeper checking it, a first DNS
* lookup and the catalog still in flight six seconds is sometimes not enough, and the
* run reported an empty window as a failure. It was not a failure; it was a stopwatch.
*
* Now it polls for a settled window and only gives up at the ceiling, so the common
* case is *faster* than the old fixed wait and the cold case still passes.
*/
const SETTLE_POLL_MS = 400
const SETTLE_CEILING_MS = 30_000
const SETTLE_DELAY_MS = 1_000
const SWITCH_SETTLE_DELAY_MS = 8_000 const SWITCH_SETTLE_DELAY_MS = 8_000
const SHOT_FRAME_DELAY_MS = 400 const SHOT_FRAME_DELAY_MS = 400
@@ -28,6 +41,8 @@ interface SelfTestReport {
readonly locales: readonly string[] readonly locales: readonly string[]
/** `<accessible name>:<glyph count>` per icon-only control in the footer. */ /** `<accessible name>:<glyph count>` per icon-only control in the footer. */
readonly iconControls: readonly string[] readonly iconControls: readonly string[]
/** `<title> [current|newer] Upgrade:on|off Uninstall:on|off` per installed card. */
readonly cardMenus: readonly string[]
} }
/** What changed after clicking a store that was not open. */ /** What changed after clicking a store that was not open. */
@@ -53,12 +68,14 @@ export class SelfTestRunner {
private readonly shotPath: string | null = process.env['SELFTEST_SHOT'] ?? null private readonly shotPath: string | null = process.env['SELFTEST_SHOT'] ?? null
) {} ) {}
/** A short first wait; `run` does the rest of the waiting itself. */
public get settleDelayMs (): number { public get settleDelayMs (): number {
return SETTLE_DELAY_MS return SETTLE_DELAY_MS
} }
/** True when the window is in a state a user could work with. */ /** True when the window is in a state a user could work with. */
public async run (): Promise<boolean> { public async run (): Promise<boolean> {
await this.awaitSettled()
const report = await this.readReport() const report = await this.readReport()
console.log(JSON.stringify(report, null, 2)) console.log(JSON.stringify(report, null, 2))
@@ -70,11 +87,18 @@ export class SelfTestRunner {
// A gate passes on having something to do, not on having a picker: the picker // A gate passes on having something to do, not on having a picker: the picker
// only appears when the registry offers more than one store, and one store is // only appears when the registry offers more than one store, and one store is
// the ordinary case. Requiring choices here failed a perfectly good window. // the ordinary case. Requiring choices here failed a perfectly good window.
// Both footer icons must be present, named and drawn: refresh and the language // Every footer icon must be named and drawn. There are three — refresh, add a
// picker are the only way to reach those two actions now that neither has a label. // store, and the language picker — and none of them has a label, so an unnamed one
const iconsNamed = report.iconControls.length === 2 && // is a button nobody can identify and the failure is silent because the glyph still
// draws. The count is a floor rather than an equality: a fourth control is somebody
// adding one, which this should not fail on; a missing one is what it guards.
const iconsNamed = report.iconControls.length >= 3 &&
report.iconControls.every((control: string): boolean => /^.+:1$/.test(control)) report.iconControls.every((control: string): boolean => /^.+:1$/.test(control))
const rendered = report.locales.length > 1 && iconsNamed && ( // Every installed card offers both actions, and Upgrade is enabled exactly when the
// version line says there is something newer. Uninstall is always available.
const menusAgree = report.cardMenus.every((entry: string): boolean =>
/\[newer\] \S+:on \S+:on$/.test(entry) || /\[current\] \S+:off \S+:on$/.test(entry))
const rendered = report.locales.length > 1 && iconsNamed && menusAgree && (
(report.cards > 0 && !report.gateVisible && report.stores.length > 0 && (report.cards > 0 && !report.gateVisible && report.stores.length > 0 &&
report.categories.length > 0 && report.activeCategory !== null) || report.categories.length > 0 && report.activeCategory !== null) ||
(report.gateVisible && report.gateAction.length > 0)) (report.gateVisible && report.gateAction.length > 0))
@@ -87,6 +111,27 @@ export class SelfTestRunner {
return passed return passed
} }
/**
* Wait until the window is showing something, or until the ceiling.
*
* "Something" is a card or the gate: those are the two states a person could act on,
* and between them they cover every way this application legitimately ends up. Timing
* out is not treated as a failure here the report is taken anyway, and the checks
* below decide, so a genuinely empty window still fails for the right reason rather
* than as a timeout with no detail.
*/
private async awaitSettled (): Promise<void> {
const deadline = Date.now() + SETTLE_CEILING_MS
while (Date.now() < deadline) {
const ready = await this.evaluate(
"String(document.querySelectorAll('.card').length > 0 || " +
"!document.getElementById('gate').hidden)"
)
if (ready === 'true') return
await delay(SETTLE_POLL_MS)
}
}
private async readReport (): Promise<SelfTestReport> { private async readReport (): Promise<SelfTestReport> {
const record = asRecord(JSON.parse(await this.evaluate(`JSON.stringify({ const record = asRecord(JSON.parse(await this.evaluate(`JSON.stringify({
cards: document.querySelectorAll('.card').length, cards: document.querySelectorAll('.card').length,
@@ -107,6 +152,16 @@ export class SelfTestRunner {
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 // 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. // 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')] iconControls: [...document.querySelectorAll('.side-tools .icon-btn')]
.map((control) => { .map((control) => {
const named = control.getAttribute('aria-label') || control.getAttribute('title') || const named = control.getAttribute('aria-label') || control.getAttribute('title') ||
@@ -132,7 +187,8 @@ export class SelfTestRunner {
paths: readString(record, 'paths'), paths: readString(record, 'paths'),
logLines: readNumber(record, 'logLines'), logLines: readNumber(record, 'logLines'),
locales: readStringArray(record, 'locales'), locales: readStringArray(record, 'locales'),
iconControls: readStringArray(record, 'iconControls') iconControls: readStringArray(record, 'iconControls'),
cardMenus: readStringArray(record, 'cardMenus')
} }
} }
@@ -143,9 +199,12 @@ export class SelfTestRunner {
*/ */
private async switchStore (): Promise<StoreSwitchReport> { private async switchStore (): Promise<StoreSwitchReport> {
const record = asRecord(JSON.parse(await this.evaluate(`(async () => { const record = asRecord(JSON.parse(await this.evaluate(`(async () => {
// The row is a wrapper now; the part that switches stores is the button inside
// it. Clicking the wrapper did nothing at all, and a click that does nothing is
// exactly the kind of silent break this test exists for.
const other = [...document.querySelectorAll('#store-list .store-row')] const other = [...document.querySelectorAll('#store-list .store-row')]
.find((row) => !row.classList.contains('is-active')) .find((row) => !row.classList.contains('is-active'))
other.click() other.querySelector('.store-row-open').click()
await new Promise((done) => setTimeout(done, ${String(SWITCH_SETTLE_DELAY_MS)})) await new Promise((done) => setTimeout(done, ${String(SWITCH_SETTLE_DELAY_MS)}))
return JSON.stringify({ return JSON.stringify({
storeId: document.getElementById('store-id').textContent, storeId: document.getElementById('store-id').textContent,
@@ -189,3 +248,9 @@ export class SelfTestRunner {
return typeof result === 'string' ? result : JSON.stringify(result ?? null) return typeof result === 'string' ? result : JSON.stringify(result ?? null)
} }
} }
async function delay (milliseconds: number): Promise<void> {
await new Promise<void>((resolve: () => void): void => {
setTimeout((): void => { resolve() }, milliseconds)
})
}
+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 { return {
games: this.gameMapper.toDtoList(listing.games, baseUrl), games: this.gameMapper.toDtoList(listing.games, baseUrl),
skipped: listing.skipped, skipped: listing.skipped,
paths: listing.paths === null ? null : this.pathsMapper.toDto(listing.paths) paths: listing.paths === null ? null : this.pathsMapper.toDto(listing.paths),
account: {
signInAvailable: listing.account.signInAvailable,
signedIn: listing.account.signedIn
}
} }
}) })
} }
-3
View File
@@ -27,12 +27,9 @@ export function requireStringArray (value: unknown, name: string): readonly stri
export function requireRegistryStore (value: unknown): RegistryStoreDto { export function requireRegistryStore (value: unknown): RegistryStoreDto {
const record = asRecord(value) const record = asRecord(value)
if (record === null) throw new TypeError('a store record is required') if (record === null) throw new TypeError('a store record is required')
const repository = readString(record, 'storeRepositoryUrl')
const store: RegistryStoreDto = { const store: RegistryStoreDto = {
name: readString(record, 'name'), name: readString(record, 'name'),
catalogUrl: readString(record, 'catalogUrl'), catalogUrl: readString(record, 'catalogUrl'),
// Optional: a store with no repository installs on the engine's defaults.
storeRepositoryUrl: repository.length > 0 ? repository : null,
storeId: readString(record, 'storeId') storeId: readString(record, 'storeId')
} }
if (store.name.length === 0 || store.catalogUrl.length === 0) { if (store.name.length === 0 || store.catalogUrl.length === 0) {
+36
View File
@@ -27,8 +27,12 @@ export class StoreIpcController {
this.handleListRegistry()) this.handleListRegistry())
router.handle(IPC_CHANNELS.storeInstallStore, async (store: unknown): Promise<InstalledStoreDto> => router.handle(IPC_CHANNELS.storeInstallStore, async (store: unknown): Promise<InstalledStoreDto> =>
this.handleInstallStore(store)) this.handleInstallStore(store))
router.handle(IPC_CHANNELS.storeInstallCatalog, async (url: unknown): Promise<InstalledStoreDto> =>
this.handleInstallCatalog(requireString(url, 'catalogUrl')))
router.handle(IPC_CHANNELS.storeSelectStore, (home: unknown): StoreSelectionDto => router.handle(IPC_CHANNELS.storeSelectStore, (home: unknown): StoreSelectionDto =>
this.handleSelectStore(requireString(home, 'home'))) this.handleSelectStore(requireString(home, 'home')))
router.handle(IPC_CHANNELS.storeRemoveStore, async (home: unknown): Promise<void> =>
this.handleRemoveStore(requireString(home, 'home')))
} }
/** /**
@@ -60,7 +64,39 @@ export class StoreIpcController {
}) })
} }
/**
* A catalog somebody typed, rather than one the registry offered.
*
* Only the address crosses the bridge. The name is derived from it and the
* configuration comes from the engine's defaults, so a typed URL can no more decide
* where files land than a registry record can.
*/
private async handleInstallCatalog (catalogUrl: string): Promise<InstalledStoreDto> {
return this.guard.run(async (): Promise<InstalledStoreDto> => {
const installed = await this.provisioning.installCatalog(
catalogUrl, null, this.streams.asProgressListener()
)
return this.storeMapper.toDto(installed)
})
}
private handleSelectStore (home: string): StoreSelectionDto { private handleSelectStore (home: string): StoreSelectionDto {
return { store: this.storeMapper.toDto(this.selection.selectStore(home)) } return { store: this.storeMapper.toDto(this.selection.selectStore(home)) }
} }
/**
* Remove a store by its home.
*
* The home is resolved against the stores actually on this machine before anything
* is deleted the window names a store, never a path. Guarded, because it uninstalls
* every title the store put here and a second engine call across that would be
* working on files this one is removing.
*/
private async handleRemoveStore (home: string): Promise<void> {
await this.guard.run(async (): Promise<void> => {
await this.provisioning.removeStore(
this.selection.requireStoreAt(home), this.streams.asProgressListener()
)
})
}
} }
+11 -1
View File
@@ -1,10 +1,12 @@
import type { BrowserWindow } from 'electron' import type { BrowserWindow } from 'electron'
import type { EngineProgressListener } from '../../domain/models/EngineProgress' import type { EngineProgressListener } from '../../domain/models/EngineProgress'
import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels' import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels'
import type { SignInFinishedDto } from '../../shared/contracts/dto/AccountDto'
import type { SyncEventDto } from '../../shared/contracts/dto/SyncEventDto' import type { SyncEventDto } from '../../shared/contracts/dto/SyncEventDto'
/** /**
* The three one-way streams to the window: log lines, progress events, busy state. * The one-way streams to the window: log lines, progress events, busy state, and how a
* sign-in ended.
* *
* Holds no window of its own the reference is handed in when one exists and * Holds no window of its own the reference is handed in when one exists and
* cleared when it does not, so a stream that outlives the window is a no-op rather * cleared when it does not, so a stream that outlives the window is a no-op rather
@@ -33,6 +35,14 @@ export class WindowStreamBroadcaster {
this.send(IPC_CHANNELS.streamBusyChanged, busy) this.send(IPC_CHANNELS.streamBusyChanged, busy)
} }
/**
* A sign-in finishes minutes after the call that started it returned, and in another
* window entirely so it arrives as an event rather than as a reply.
*/
public publishSignInFinished (result: SignInFinishedDto): void {
this.send(IPC_CHANNELS.streamSignInFinished, result)
}
/** A progress listener wired to these streams, for handing to the engine. */ /** A progress listener wired to these streams, for handing to the engine. */
public asProgressListener (): EngineProgressListener { public asProgressListener (): EngineProgressListener {
return { return {
+22
View File
@@ -3,6 +3,9 @@ import {
BRIDGE_GLOBAL_NAME, type BridgeApi, type StreamListener BRIDGE_GLOBAL_NAME, type BridgeApi, type StreamListener
} from '../shared/contracts/BridgeApi' } from '../shared/contracts/BridgeApi'
import { IPC_CHANNELS } from '../shared/contracts/IpcChannels' import { IPC_CHANNELS } from '../shared/contracts/IpcChannels'
import type {
AccountDto, SignInFinishedDto, SignInPromptDto
} from '../shared/contracts/dto/AccountDto'
import type { AppStateDto } from '../shared/contracts/dto/AppStateDto' import type { AppStateDto } from '../shared/contracts/dto/AppStateDto'
import type { CatalogListingDto } from '../shared/contracts/dto/CatalogListingDto' import type { CatalogListingDto } from '../shared/contracts/dto/CatalogListingDto'
import type { InstalledStoreDto } from '../shared/contracts/dto/InstalledStoreDto' import type { InstalledStoreDto } from '../shared/contracts/dto/InstalledStoreDto'
@@ -40,12 +43,25 @@ const bridge: BridgeApi = {
launchGame: async (name: string): Promise<boolean> => launchGame: async (name: string): Promise<boolean> =>
ipcRenderer.invoke(IPC_CHANNELS.catalogLaunchGame, name) as Promise<boolean>, ipcRenderer.invoke(IPC_CHANNELS.catalogLaunchGame, name) as Promise<boolean>,
readAccount: async (): Promise<AccountDto> =>
ipcRenderer.invoke(IPC_CHANNELS.accountRead) as Promise<AccountDto>,
beginSignIn: async (): Promise<SignInPromptDto> =>
ipcRenderer.invoke(IPC_CHANNELS.accountBeginSignIn) as Promise<SignInPromptDto>,
cancelSignIn: async (): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.accountCancelSignIn) as Promise<void>,
signOut: async (): Promise<AccountDto> =>
ipcRenderer.invoke(IPC_CHANNELS.accountSignOut) as Promise<AccountDto>,
listRegistryStores: async (): Promise<RegistryResultDto> => listRegistryStores: async (): Promise<RegistryResultDto> =>
ipcRenderer.invoke(IPC_CHANNELS.storeListRegistry) as Promise<RegistryResultDto>, ipcRenderer.invoke(IPC_CHANNELS.storeListRegistry) as Promise<RegistryResultDto>,
installStore: async (store: RegistryStoreDto): Promise<InstalledStoreDto> => installStore: async (store: RegistryStoreDto): Promise<InstalledStoreDto> =>
ipcRenderer.invoke(IPC_CHANNELS.storeInstallStore, store) as Promise<InstalledStoreDto>, ipcRenderer.invoke(IPC_CHANNELS.storeInstallStore, store) as Promise<InstalledStoreDto>,
installCatalog: async (catalogUrl: string): Promise<InstalledStoreDto> =>
ipcRenderer.invoke(IPC_CHANNELS.storeInstallCatalog, catalogUrl) as Promise<InstalledStoreDto>,
selectStore: async (home: string): Promise<StoreSelectionDto> => selectStore: async (home: string): Promise<StoreSelectionDto> =>
ipcRenderer.invoke(IPC_CHANNELS.storeSelectStore, home) as Promise<StoreSelectionDto>, ipcRenderer.invoke(IPC_CHANNELS.storeSelectStore, home) as Promise<StoreSelectionDto>,
removeStore: async (home: string): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.storeRemoveStore, home) as Promise<void>,
openFolder: async (directory: string): Promise<boolean> => openFolder: async (directory: string): Promise<boolean> =>
ipcRenderer.invoke(IPC_CHANNELS.appOpenFolder, directory) as Promise<boolean>, ipcRenderer.invoke(IPC_CHANNELS.appOpenFolder, directory) as Promise<boolean>,
@@ -66,6 +82,12 @@ const bridge: BridgeApi = {
ipcRenderer.on(IPC_CHANNELS.streamBusyChanged, (_event: IpcRendererEvent, busy: boolean): void => { ipcRenderer.on(IPC_CHANNELS.streamBusyChanged, (_event: IpcRendererEvent, busy: boolean): void => {
listener(busy) listener(busy)
}) })
},
onSignInFinished: (listener: StreamListener<SignInFinishedDto>): void => {
ipcRenderer.on(
IPC_CHANNELS.streamSignInFinished,
(_event: IpcRendererEvent, payload: SignInFinishedDto): void => { listener(payload) }
)
} }
} }
+39 -2
View File
@@ -1,5 +1,6 @@
import type { BridgeApi } from '../shared/contracts/BridgeApi' import type { BridgeApi } from '../shared/contracts/BridgeApi'
import { requireBridge } from './BridgeAccess' import { requireBridge } from './BridgeAccess'
import { AccountController } from './controllers/AccountController'
import { CatalogController } from './controllers/CatalogController' import { CatalogController } from './controllers/CatalogController'
import { EngineStreamController } from './controllers/EngineStreamController' import { EngineStreamController } from './controllers/EngineStreamController'
import { PreferencesController } from './controllers/PreferencesController' import { PreferencesController } from './controllers/PreferencesController'
@@ -11,6 +12,7 @@ import { GameCardView } from './views/GameCardView'
import { GateView } from './views/GateView' import { GateView } from './views/GateView'
import { LogDrawerView } from './views/LogDrawerView' import { LogDrawerView } from './views/LogDrawerView'
import { SideMenuView } from './views/SideMenuView' import { SideMenuView } from './views/SideMenuView'
import { SignInView } from './views/SignInView'
import { TopBarView } from './views/TopBarView' import { TopBarView } from './views/TopBarView'
/** /**
@@ -28,10 +30,12 @@ export class RendererApplication {
private readonly grid: CatalogGridView private readonly grid: CatalogGridView
private readonly topBar: TopBarView private readonly topBar: TopBarView
private readonly sideMenu: SideMenuView private readonly sideMenu: SideMenuView
private readonly signInPanel: SignInView
private readonly catalog: CatalogController private readonly catalog: CatalogController
private readonly stores: StoreController private readonly stores: StoreController
private readonly preferences: PreferencesController private readonly preferences: PreferencesController
private readonly streams: EngineStreamController private readonly streams: EngineStreamController
private readonly accounts: AccountController
public constructor (bridge: BridgeApi = requireBridge()) { public constructor (bridge: BridgeApi = requireBridge()) {
this.bridge = bridge this.bridge = bridge
@@ -44,25 +48,57 @@ export class RendererApplication {
this.stores = new StoreController(this.bridge, this.store, this.log, this.catalog) this.stores = new StoreController(this.bridge, this.store, this.log, this.catalog)
this.preferences = new PreferencesController(this.bridge, this.store) this.preferences = new PreferencesController(this.bridge, this.store)
this.streams = new EngineStreamController(this.bridge, this.store, this.log) this.streams = new EngineStreamController(this.bridge, this.store, this.log)
this.accounts = new AccountController(
this.bridge, this.store, this.log,
async (): Promise<void> => { await this.catalog.refresh() }
)
this.grid = new CatalogGridView(new GameCardView({ this.grid = new CatalogGridView(new GameCardView({
onInstall: (name: string): void => { void this.catalog.syncGames([name]) }, onInstall: (name: string): void => { void this.catalog.syncGames([name]) },
// The same call as an install: a sync of one name fetches whatever the catalog
// now has for it, and the engine replaces the old payload and menu entry.
onUpgrade: (name: string): void => { void this.catalog.syncGames([name]) },
onLaunch: (name: string): void => { void this.catalog.launchGame(name) }, onLaunch: (name: string): void => { void this.catalog.launchGame(name) },
onRemove: (name: string): void => { void this.catalog.removeGame(name) } onRemove: (name: string): void => { void this.catalog.removeGame(name) },
onPurchase: (name: string): void => { void this.purchase(name) },
onSignIn: (): void => { void this.accounts.signIn() }
})) }))
this.signInPanel = new SignInView({
onOpenPage: (): void => { void this.accounts.openVerificationPage() },
onCancel: (): void => { void this.accounts.cancelSignIn() }
})
this.topBar = new TopBarView({ this.topBar = new TopBarView({
onToggleNavigation: (): void => { void this.preferences.toggleNavigation() } onToggleNavigation: (): void => { void this.preferences.toggleNavigation() }
}) })
this.sideMenu = new SideMenuView({ this.sideMenu = new SideMenuView({
onSelectStore: (home: string): void => { void this.stores.selectStore(home) }, onSelectStore: (home: string): void => { void this.stores.selectStore(home) },
onRemoveStore: (home: string, name: string): void => { void this.stores.removeStore(home, name) },
onAddStore: (): void => { void this.stores.offerStores() }, onAddStore: (): void => { void this.stores.offerStores() },
onRefresh: (): void => { void this.catalog.refresh() }, onRefresh: (): void => { void this.catalog.refresh() },
onSelectCategory: (filter: CategoryFilter): void => { this.store.applyFilter(filter) }, onSelectCategory: (filter: CategoryFilter): void => { this.store.applyFilter(filter) },
onSelectLocale: (locale: string): void => { void this.preferences.selectLocale(locale) } onSelectLocale: (locale: string): void => { void this.preferences.selectLocale(locale) },
onSignIn: (): void => { void this.accounts.signIn() },
onSignOut: (): void => { void this.accounts.signOut() }
}) })
this.store.subscribe((state: AppState): void => { this.render(state) }) this.store.subscribe((state: AppState): void => { this.render(state) })
this.streams.subscribe() this.streams.subscribe()
this.accounts.subscribe()
}
/**
* Buying happens in a browser.
*
* A checkout rebuilt in this window would be a second place to get card handling
* wrong, and the store's own pages already do it. What this side owes afterwards is
* a refresh, which the Refresh button is for.
*/
private async purchase (name: string): Promise<void> {
const url = this.store.readState().games
.find((candidate): boolean => candidate.name === name)?.purchaseUrl ?? null
if (url === null) return
await this.bridge.openUrl(url)
} }
/** Decides what the window is showing, then hands over to the views. */ /** Decides what the window is showing, then hands over to the views. */
@@ -88,6 +124,7 @@ export class RendererApplication {
} }
document.body.classList.toggle('nav-closed', !state.navigationOpen) document.body.classList.toggle('nav-closed', !state.navigationOpen)
this.signInPanel.render(state)
this.topBar.render(state) this.topBar.render(state)
this.sideMenu.render(state) this.sideMenu.render(state)
this.log.render(state) this.log.render(state)
@@ -0,0 +1,85 @@
import type { BridgeApi } from '../../shared/contracts/BridgeApi'
import type { SignInFinishedDto } from '../../shared/contracts/dto/AccountDto'
import type { AppStore } from '../state/AppStore'
import type { LogDrawerView } from '../views/LogDrawerView'
/**
* Signing in and out, from the window's side.
*
* Two halves that do not meet: `signIn` puts a code on screen and returns, and the
* answer arrives later on the sign-in stream because the person is not here while it
* happens, they are in a browser. Nothing waits on anything.
*
* A sign-in that succeeds refreshes the catalog rather than patching the cards, since
* every entitlement in the listing was read without a credential and is now stale.
*/
export class AccountController {
public constructor (
private readonly bridge: BridgeApi,
private readonly store: AppStore,
private readonly log: LogDrawerView,
private readonly onSignedIn: () => Promise<void>
) {}
public subscribe (): void {
this.bridge.onSignInFinished((result: SignInFinishedDto): void => {
this.store.applySignIn(null)
this.store.applyAccount(result.account)
this.log.appendLine(this.describeOutcome(result))
// Only a successful sign-in changes what the catalog would say. The other three
// leave it exactly as it was, and re-reading it would be a pointless wait.
if (result.outcome === 'signedIn') void this.onSignedIn()
})
}
public async signIn (): Promise<void> {
try {
const prompt = await this.bridge.beginSignIn()
this.store.applySignIn(prompt)
// Opened for them rather than waiting to be clicked: the browser is where the
// rest of this happens, and the code on screen is no use until it is open.
await this.bridge.openUrl(prompt.verificationUrl)
} catch (error: unknown) {
this.store.applySignIn(null)
this.log.appendLine(`${this.store.readState().messages.signInFailed} ${describe(error)}`)
}
}
/** Re-open the page for somebody who closed the tab before typing the code. */
public async openVerificationPage (): Promise<void> {
const prompt = this.store.readState().signIn
if (prompt === null) return
await this.bridge.openUrl(prompt.verificationUrl)
}
public async cancelSignIn (): Promise<void> {
this.store.applySignIn(null)
await this.bridge.cancelSignIn()
}
public async signOut (): Promise<void> {
try {
this.store.applyAccount(await this.bridge.signOut())
// The listing was read as somebody; it has to be read again as nobody, or every
// owned title keeps its Install button until the next refresh.
await this.onSignedIn()
} catch (error: unknown) {
this.log.appendLine(describe(error))
}
}
private describeOutcome (result: SignInFinishedDto): string {
const messages = this.store.readState().messages
switch (result.outcome) {
case 'signedIn': return messages.signInDone
case 'denied': return messages.signInDenied
case 'expired': return messages.signInExpired
case 'cancelled': return messages.signInCancelled
}
}
}
function describe (error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
@@ -19,7 +19,7 @@ export class CatalogController {
public async refresh (): Promise<void> { public async refresh (): Promise<void> {
try { try {
const listing: CatalogListingDto = await this.bridge.listGames() const listing: CatalogListingDto = await this.bridge.listGames()
this.store.applyCatalog(listing.games, listing.paths) this.store.applyCatalog(listing.games, listing.paths, listing.account)
for (const reason of listing.skipped) this.log.appendLine(`skipped ${reason}`) for (const reason of listing.skipped) this.log.appendLine(`skipped ${reason}`)
} catch (error: unknown) { } catch (error: unknown) {
this.reportFailure(error) this.reportFailure(error)
+79 -3
View File
@@ -41,11 +41,30 @@ export class StoreController {
const messages = state.messages const messages = state.messages
const result = await this.bridge.listRegistryStores() const result = await this.bridge.listRegistryStores()
// A typed address works whatever the registry said — that is the point of it — so
// it is attached to every one of the three outcomes below, including the two that
// used to be dead ends.
const custom = {
label: messages.customTitle,
hint: messages.customHint,
actionLabel: messages.customAction,
perform: (catalogUrl: string): void => { void this.installCatalog(catalogUrl) }
}
// Only where there is something to go back to. On a machine with no store the grid
// behind this screen is empty, and "Cancel" would lead nowhere. Spread rather than
// an `undefined` value: the strict optional-property rule treats "absent" and
// "present but undefined" as different things, and here they genuinely are.
const cancel = state.currentStore === null
? {}
: { cancel: { label: messages.cancel, perform: (): void => { this.store.applyGate(null) } } }
if (result.error !== null) { if (result.error !== null) {
this.store.applyGate({ this.store.applyGate({
title: messages.registryFailed, title: messages.registryFailed,
body: `${result.sourceUrl}\n\n${result.error}`, body: `${result.sourceUrl}\n\n${result.error}`,
action: { label: messages.registryRetry, perform: (): void => { void this.offerStores() } } action: { label: messages.registryRetry, perform: (): void => { void this.offerStores() } },
custom,
...cancel
}) })
return return
} }
@@ -53,7 +72,9 @@ export class StoreController {
if (result.stores.length === 0) { if (result.stores.length === 0) {
this.store.applyGate({ this.store.applyGate({
title: messages.setupTitle, title: messages.setupTitle,
body: `${messages.registryEmpty}\n\n${result.sourceUrl}` body: `${messages.registryEmpty}\n\n${result.sourceUrl}`,
custom,
...cancel
}) })
return return
} }
@@ -67,10 +88,65 @@ export class StoreController {
if (chosen !== null) void this.installStore(chosen) if (chosen !== null) void this.installStore(chosen)
} }
}, },
choices: result.stores choices: result.stores,
custom,
...cancel
}) })
} }
/**
* Take a store off this machine, with what that costs stated first.
*
* The confirmation names the number of installed titles, because that is the part
* somebody would not otherwise expect: removing a store uninstalls everything it
* put here. Leaving them behind would be worse the store's state file is the only
* record of which files belong to it, so orphans would be permanent.
*/
public async removeStore (home: string, name: string): Promise<void> {
const state = this.store.readState()
const messages = state.messages
const installed = state.currentStore?.home === home
? state.games.filter((game): boolean => game.installed).length
: null
const question = installed === null
? messages.removeStoreConfirm.replace('%{store}', name)
: messages.removeStoreConfirmGames
.replace('%{store}', name)
.replace('%{count}', String(installed))
if (!window.confirm(question)) return
try {
await this.bridge.removeStore(home)
this.store.applyAppState(await this.bridge.readState())
const remaining = this.store.readState().currentStore
this.store.applyGate(null)
if (remaining === null) {
await this.offerStores()
return
}
await this.catalog.refresh()
} catch (error: unknown) {
this.log.appendLine(error instanceof Error ? error.message : String(error))
}
}
private async installCatalog (catalogUrl: string): Promise<void> {
const messages = this.store.readState().messages
this.store.applyProgress({ total: 0, done: 0, label: messages.setupWorking })
try {
await this.bridge.installCatalog(catalogUrl)
this.store.applyAppState(await this.bridge.readState())
this.store.applyGate(null)
await this.catalog.refresh()
await this.catalog.syncGames([])
} catch (error: unknown) {
this.log.appendLine(error instanceof Error ? error.message : String(error))
} finally {
this.store.applyProgress(null)
}
}
private async installStore (chosen: RegistryStoreDto): Promise<void> { private async installStore (chosen: RegistryStoreDto): Promise<void> {
const messages = this.store.readState().messages const messages = this.store.readState().messages
this.store.applyProgress({ total: 0, done: 0, label: messages.setupWorking }) this.store.applyProgress({ total: 0, done: 0, label: messages.setupWorking })
+55 -1
View File
@@ -33,7 +33,17 @@
<section class="side-block"> <section class="side-block">
<h2 class="side-head" id="head-stores"></h2> <h2 class="side-head" id="head-stores"></h2>
<div class="store-list" id="store-list"></div> <div class="store-list" id="store-list"></div>
<button id="add-store" class="btn btn-ghost btn-wide"></button> </section>
<!--
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>
<section class="side-block side-cats"> <section class="side-block side-cats">
@@ -55,6 +65,17 @@
<path d="M13.5 2v3h-3" /> <path d="M13.5 2v3h-3" />
</svg> </svg>
</button> </button>
<!--
Adding a store sits beside Refresh rather than under the store list: both
are actions on the whole store rather than on one of them, and a full-width
button under the list read as a third store.
-->
<button id="add-store" class="icon-btn">
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M8 3v10" />
<path d="M3 8h10" />
</svg>
</button>
<!-- <!--
The select is still a real `<select>`, stretched over the icon and invisible: 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 the native dropdown knows how to open upward in a cramped window and is
@@ -74,6 +95,22 @@
</aside> </aside>
<div class="content"> <div class="content">
<!--
The code to type into a browser. A panel over the grid rather than a screen of
its own: the catalog is still there and still readable, and the sign-in is
something happening elsewhere that this window is only reporting on.
-->
<section id="signin" class="signin" hidden>
<h2 id="signin-title"></h2>
<p id="signin-body"></p>
<p class="signin-code" id="signin-code"></p>
<p class="signin-waiting" id="signin-waiting"></p>
<div class="signin-actions">
<button id="signin-open" class="btn btn-secondary"></button>
<button id="signin-cancel" class="btn btn-ghost"></button>
</div>
</section>
<!-- Shown instead of the grid when there is nothing to drive yet. --> <!-- Shown instead of the grid when there is nothing to drive yet. -->
<section id="gate" class="gate" hidden> <section id="gate" class="gate" hidden>
<h1 id="gate-title"></h1> <h1 id="gate-title"></h1>
@@ -84,8 +121,25 @@
<select id="gate-select" class="select"></select> <select id="gate-select" class="select"></select>
</label> </label>
<button id="gate-action" class="btn btn-primary" hidden></button> <button id="gate-action" class="btn btn-primary" hidden></button>
<button id="gate-cancel" class="btn btn-ghost" hidden></button>
<a id="gate-link" class="link" href="#" hidden></a> <a id="gate-link" class="link" href="#" hidden></a>
</div> </div>
<!--
A catalog the registry does not list. Its own row under the picker rather
than a third option inside it: choosing from a list and typing an address are
different gestures, and a <select> entry that turns into a text field is a
control that lies about what it is.
-->
<div id="gate-custom" class="gate-custom" hidden>
<span id="gate-custom-label" class="gate-custom-label"></span>
<div class="gate-custom-row">
<input id="gate-custom-url" class="input" type="url" spellcheck="false"
autocapitalize="off" autocorrect="off">
<button id="gate-custom-action" class="btn btn-secondary"></button>
</div>
<p id="gate-custom-hint" class="gate-custom-hint"></p>
</div>
</section> </section>
<main id="grid" class="grid" hidden></main> <main id="grid" class="grid" hidden></main>
+36 -4
View File
@@ -1,3 +1,4 @@
import type { AccountDto, SignInPromptDto } from '../../shared/contracts/dto/AccountDto'
import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto' import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto'
import type { GameDto } from '../../shared/contracts/dto/GameDto' import type { GameDto } from '../../shared/contracts/dto/GameDto'
import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto' import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto'
@@ -31,6 +32,10 @@ export interface AppState {
readonly progress: SyncProgress | null readonly progress: SyncProgress | null
/** Non-null while the setup screen is up, which is also what hides the grid. */ /** Non-null while the setup screen is up, which is also what hides the grid. */
readonly gate: GatePresentation | null readonly gate: GatePresentation | null
/** Where this machine stands with the open store. */
readonly account: AccountDto
/** Non-null while a code is on screen waiting to be typed into a browser. */
readonly signIn: SignInPromptDto | null
} }
const INITIAL_STATE: AppState = { const INITIAL_STATE: AppState = {
@@ -47,7 +52,9 @@ const INITIAL_STATE: AppState = {
filter: ALL_CATEGORIES, filter: ALL_CATEGORIES,
busy: false, busy: false,
progress: null, progress: null,
gate: null gate: null,
account: { signInAvailable: false, signedIn: false },
signIn: null
} }
export type AppStateListener = (state: AppState) => void export type AppStateListener = (state: AppState) => void
@@ -96,13 +103,38 @@ export class AppStore {
this.notify() this.notify()
} }
public applyCatalog (games: readonly GameDto[], paths: StorePathsDto | null): void { public applyCatalog (
this.state = { ...this.state, games, paths: paths ?? this.state.paths } games: readonly GameDto[],
paths: StorePathsDto | null,
account: AccountDto
): void {
this.state = { ...this.state, games, paths: paths ?? this.state.paths, account }
this.notify()
}
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() this.notify()
} }
public applySelectedStore (store: InstalledStoreDto): void { public applySelectedStore (store: InstalledStoreDto): void {
this.state = { ...this.state, currentStore: store, games: [], paths: null, filter: ALL_CATEGORIES } // A new store means a new account: whether we are signed in is per store, and
// carrying the old answer over would show somebody as signed in to a shop they
// have never visited.
this.state = {
...this.state,
currentStore: store,
games: [],
paths: null,
filter: ALL_CATEGORIES,
account: { signInAvailable: false, signedIn: false },
signIn: null
}
this.notify() this.notify()
} }
+10 -2
View File
@@ -46,6 +46,12 @@ function matchesGroup (game: GameDto, group: string): boolean {
return game.updateAvailable return game.updateAvailable
case 'available': case 'available':
return game.installable && !game.installed return game.installable && !game.installed
case 'owned':
// Owning something is not the same as having installed it — the point of the
// category is finding what you paid for and have not put on this machine yet.
return game.accessVerdict === 'entitled'
case 'purchasable':
return game.accessVerdict === 'purchasable'
case 'unsupported': case 'unsupported':
return !game.installable return !game.installable
default: default:
@@ -57,8 +63,8 @@ function matchesGroup (game: GameDto, group: string): boolean {
* The categories, built from what the catalog actually contains. * The categories, built from what the catalog actually contains.
* *
* There is no genre in a WarpEngine catalog, so the useful axes are the state of a * There is no genre in a WarpEngine catalog, so the useful axes are the state of a
* title on this machine, the platform it was built with, and whether it runs here or * title on this machine, what this person may have of it, the platform it was built
* in a browser. Empty axes are left out rather than shown as zeroes, and an axis with * with, and whether it runs here or in a browser. Empty axes are left out rather than shown as zeroes, and an axis with
* a single value is left out too a filter that changes nothing is noise. * a single value is left out too a filter that changes nothing is noise.
*/ */
export function buildCategorySections ( export function buildCategorySections (
@@ -73,6 +79,8 @@ export function buildCategorySections (
{ kind: 'group', value: 'installed', label: messages.catInstalled, count: count((game: GameDto): boolean => game.installed) }, { kind: 'group', value: 'installed', label: messages.catInstalled, count: count((game: GameDto): boolean => game.installed) },
{ kind: 'group', value: 'updates', label: messages.catUpdates, count: count((game: GameDto): boolean => game.updateAvailable) }, { kind: 'group', value: 'updates', label: messages.catUpdates, count: count((game: GameDto): boolean => game.updateAvailable) },
{ kind: 'group', value: 'available', label: messages.catAvailable, count: count((game: GameDto): boolean => game.installable && !game.installed) }, { kind: 'group', value: 'available', label: messages.catAvailable, count: count((game: GameDto): boolean => game.installable && !game.installed) },
{ kind: 'group', value: 'owned', label: messages.catOwned, count: count((game: GameDto): boolean => game.accessVerdict === 'entitled') },
{ kind: 'group', value: 'purchasable', label: messages.catPurchasable, count: count((game: GameDto): boolean => game.accessVerdict === 'purchasable') },
{ kind: 'group', value: 'unsupported', label: messages.catUnsupported, count: count((game: GameDto): boolean => !game.installable) } { kind: 'group', value: 'unsupported', label: messages.catUnsupported, count: count((game: GameDto): boolean => !game.installable) }
] ]
sections.push({ sections.push({
+16
View File
@@ -11,6 +11,14 @@ export interface GateLink {
readonly url: string readonly url: string
} }
/** A catalog typed rather than chosen. Absent where typing one makes no sense. */
export interface GateCustom {
readonly label: string
readonly hint: string
readonly actionLabel: string
readonly perform: (catalogUrl: string) => void
}
/** /**
* What the gate is showing. * What the gate is showing.
* *
@@ -24,4 +32,12 @@ export interface GatePresentation {
readonly action?: GateAction readonly action?: GateAction
readonly link?: GateLink readonly link?: GateLink
readonly choices?: readonly RegistryStoreDto[] readonly choices?: readonly RegistryStoreDto[]
readonly custom?: GateCustom
/**
* A way back, shown only when there is somewhere to go back *to*.
*
* Without it the picker was a trap: opening it with a store already installed left
* no way to reach the grid again short of installing something.
*/
readonly cancel?: GateAction
} }
+181 -9
View File
@@ -113,26 +113,44 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
} }
.nav-toggle:hover { color: var(--ink); border-color: #3a4757; } .nav-toggle:hover { color: var(--ink); border-color: #3a4757; }
/* Store switcher: one row per store on this machine, the open one marked. */ /*
* Store switcher: one row per store on this machine, the open one marked.
*
* The row is a wrapper holding two buttons open, and remove rather than being a
* button itself: a button inside a button is invalid markup, and the inner click would
* reach the outer handler anyway. So the framing lives on the wrapper and the padding
* on the part that is actually clicked, or the click target would be smaller than the
* thing it looks like.
*/
.store-list { display: flex; flex-direction: column; gap: 4px; } .store-list { display: flex; flex-direction: column; gap: 4px; }
.store-row { .store-row {
font: inherit; display: flex;
text-align: left; align-items: stretch;
color: var(--ink); color: var(--ink);
background: transparent; background: transparent;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 8px; border-radius: 8px;
}
.store-row:hover { background: var(--panel-2); }
.store-row.is-active {
background: var(--panel-2);
border-color: #2f5a49;
}
.store-row-open {
flex: 1;
min-width: 0;
font: inherit;
text-align: left;
color: inherit;
background: transparent;
border: none;
border-radius: 8px 0 0 8px;
padding: 6px 10px; padding: 6px 10px;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1px; gap: 1px;
} }
.store-row:hover:not(:disabled) { background: var(--panel-2); }
.store-row.is-active {
background: var(--panel-2);
border-color: #2f5a49;
}
.store-row .store-row-name { font-weight: 600; } .store-row .store-row-name { font-weight: 600; }
.store-row .store-row-id { .store-row .store-row-id {
font-size: 11px; font-size: 11px;
@@ -141,7 +159,30 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.store-row:disabled { opacity: .55; cursor: default; } /*
* Remove appears on hover or focus only. It is destructive, and it sits in a list
* whose ordinary use is switching stores it should not be under the pointer of
* every routine click. :focus-within is what keeps it reachable by keyboard.
*/
.store-row-remove {
flex: 0 0 auto;
display: flex;
align-items: center;
padding: 0 8px;
border: none;
border-radius: 0 8px 8px 0;
background: transparent;
color: var(--ink-dim);
cursor: pointer;
opacity: 0;
transition: opacity 120ms, color 120ms;
}
.store-row:hover .store-row-remove,
.store-row:focus-within .store-row-remove { opacity: 1; }
.store-row-remove:hover { color: var(--warn); }
.store-row-open:disabled { opacity: .55; cursor: default; }
.store-row-remove:disabled { opacity: 0; cursor: default; }
.icon-trash { width: 14px; height: 14px; }
/* Categories: what the catalog is filtered down to. */ /* Categories: what the catalog is filtered down to. */
.cats { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; min-height: 0; } .cats { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; min-height: 0; }
@@ -259,6 +300,13 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
.btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; } .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
.btn-primary:hover:not(:disabled) { background: #45cd9b; } .btn-primary:hover:not(:disabled) { background: #45cd9b; }
.btn-ghost { background: transparent; color: var(--ink-dim); } .btn-ghost { background: transparent; color: var(--ink-dim); }
/*
* The second-choice button: present and pressable, but not the one the eye lands on.
* Used where a card offers signing in rather than installing, and beside the typed
* catalog address both are real actions that are not the primary one.
*/
.btn-secondary { background: transparent; border-color: var(--line); color: var(--ink); }
.btn-secondary:hover:not(:disabled) { background: var(--panel-2); border-color: #3a4757; }
.btn-tiny { padding: 3px 9px; font-size: 12px; font-weight: 500; } .btn-tiny { padding: 3px 9px; font-size: 12px; font-weight: 500; }
.select { .select {
font: inherit; font: inherit;
@@ -282,6 +330,34 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
.gate-actions { display: flex; gap: 14px; justify-content: center; align-items: center; flex-wrap: wrap; } .gate-actions { display: flex; gap: 14px; justify-content: center; align-items: center; flex-wrap: wrap; }
.gate-choice { display: inline-flex; align-items: center; gap: 8px; color: var(--ink-dim); font-size: 13px; } .gate-choice { display: inline-flex; align-items: center; gap: 8px; color: var(--ink-dim); font-size: 13px; }
/* --- signing in --------------------------------------------------------- */
/*
* The code, while somebody carries it to a browser. A band across the top of the
* content rather than a screen of its own: the sign-in is happening elsewhere, and
* there is no reason the catalog should stop being readable while it does.
*/
.signin {
margin: 18px 18px 0;
padding: 18px 20px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel);
text-align: center;
}
.signin h2 { margin: 0 0 6px; font-size: 16px; }
.signin p { margin: 0 0 10px; color: var(--ink-dim); font-size: 13px; }
/* The one thing on screen somebody has to copy by eye, so: large, spaced, and
selectable a code that cannot be highlighted is a code that has to be retyped. */
.signin-code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 28px;
letter-spacing: 0.22em;
color: var(--ink);
user-select: text;
}
.signin-waiting { font-size: 12px; }
.signin-actions { display: flex; gap: 10px; justify-content: center; }
/* --- the grid ----------------------------------------------------------- */ /* --- the grid ----------------------------------------------------------- */
.grid { .grid {
flex: 1; flex: 1;
@@ -300,6 +376,44 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
align-content: start; align-content: start;
} }
.empty { margin: auto; color: var(--ink-dim); } .empty { margin: auto; color: var(--ink-dim); }
/* A title somebody has not bought is not a broken one: the dimming and the dashed
border belong to what this *machine* cannot do, and there is nothing wrong with
the machine here. */
.card.is-purchasable { opacity: 1; }
.side-quiet-text { color: var(--ink-dim); font-size: 12px; margin: 0 0 8px; }
/* --- the gate's typed-address row ---------------------------------------- */
.gate-custom {
margin: 22px auto 0;
max-width: 460px;
padding-top: 18px;
border-top: 1px solid var(--line);
text-align: left;
}
.gate-custom-label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--ink-dim);
margin-bottom: 8px;
}
.gate-custom-row { display: flex; gap: 8px; }
.gate-custom-row .input { flex: 1; min-width: 0; }
.gate-custom-hint {
margin: 8px 0 0;
font-size: 11.5px;
color: var(--ink-dim);
line-height: 1.5;
}
.input {
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 8px;
color: var(--ink);
padding: 8px 10px;
font-size: 13px;
}
.input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.gate { overflow-y: auto; } .gate { overflow-y: auto; }
.card { .card {
@@ -345,6 +459,14 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
} }
.badge-app { color: var(--accent); border-color: #2f5a49; } .badge-app { color: var(--accent); border-color: #2f5a49; }
.badge-web { color: var(--warn); border-color: #5a4a2f; } .badge-web { color: var(--warn); border-color: #5a4a2f; }
/* A price reads as a fact rather than a warning: same weight as the mode badges,
filled rather than outlined, so it is findable while scanning a row of cards. */
.badge-price {
color: var(--ink);
background: var(--panel-2);
border-color: var(--line);
}
.badge-owned { color: var(--accent); border-color: #2f5a49; }
.version { font-size: 12px; color: var(--ink-dim); margin-left: auto; } .version { font-size: 12px; color: var(--ink-dim); margin-left: auto; }
.desc { .desc {
margin: 0; margin: 0;
@@ -357,6 +479,56 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
} }
.actions { display: flex; gap: 8px; margin-top: auto; } .actions { display: flex; gap: 8px; margin-top: auto; }
/* --- the card's actions menu -------------------------------------------- */
/*
* A <details> holding the two actions that are not the card's headline. The card is the
* positioning context, and the panel is pinned to the button's right edge so it opens
* inward rather than off the side of the grid.
*/
.menu { position: relative; margin-left: auto; }
.menu-toggle { list-style: none; }
/* Safari and Chrome each draw their own marker on a summary; both have to go. */
.menu-toggle::-webkit-details-marker { display: none; }
.menu-toggle::marker { content: ''; }
.menu[open] .menu-toggle { color: var(--ink); background: var(--panel-2); border-color: var(--line); }
.menu-items {
position: absolute;
right: 0;
bottom: calc(100% + 6px);
z-index: 20;
min-width: 148px;
display: flex;
flex-direction: column;
padding: 4px;
gap: 2px;
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 10px;
box-shadow: 0 10px 28px rgb(0 0 0 / .45);
}
.menu-item {
font: inherit;
font-size: 13px;
text-align: left;
padding: 7px 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--ink);
cursor: pointer;
white-space: nowrap;
}
.menu-item:hover:not(:disabled) { background: #2b3746; }
.menu-item:disabled { color: var(--ink-dim); opacity: .5; cursor: default; }
.icon-dots { fill: currentColor; stroke: none; }
/* An installed version with a newer one behind it: the arrow carries the news, so the
colour only has to make it findable in a grid. */
.version.has-update { color: var(--accent); font-weight: 600; }
/* --- log ---------------------------------------------------------------- /* --- log ----------------------------------------------------------------
No permanent footer: the panel is in the flow only while it is open, and the No permanent footer: the panel is in the flow only while it is open, and the
switch for it sits in the side menu with everything else that is not a title. */ switch for it sits in the side menu with everything else that is not a title. */
+20 -1
View File
@@ -9,7 +9,26 @@ export class CatalogGridView {
private readonly grid = requireElement('grid', HTMLElement) private readonly grid = requireElement('grid', HTMLElement)
private readonly empty = requireElement('empty', HTMLElement) private readonly empty = requireElement('empty', HTMLElement)
public constructor (private readonly cards: GameCardView) {} public constructor (private readonly cards: GameCardView) {
this.closeMenusOnOutsideClick()
}
/**
* One listener for every card's actions menu.
*
* A `<details>` does not close when the pointer goes elsewhere, and a card cannot own
* this: cards are rebuilt on every render, so a listener per card would be a listener
* per render. The grid is created once, which makes it the right place for it.
*/
private closeMenusOnOutsideClick (): void {
document.addEventListener('click', (event: MouseEvent): void => {
const target = event.target
const clicked = target instanceof Node ? target : null
for (const menu of this.grid.querySelectorAll('details.menu[open]')) {
if (clicked === null || !menu.contains(clicked)) menu.removeAttribute('open')
}
})
}
public render (state: AppState): void { public render (state: AppState): void {
const shown = state.games.filter((game: GameDto): boolean => matchesFilter(game, state.filter)) const shown = state.games.filter((game: GameDto): boolean => matchesFilter(game, state.filter))
+158 -19
View File
@@ -4,15 +4,22 @@ import { createElement } from '../dom/Dom'
export interface GameCardViewCallbacks { export interface GameCardViewCallbacks {
readonly onInstall: (name: string) => void readonly onInstall: (name: string) => void
readonly onUpgrade: (name: string) => void
readonly onLaunch: (name: string) => void readonly onLaunch: (name: string) => void
readonly onRemove: (name: string) => void readonly onRemove: (name: string) => void
/** Opens the catalog's own purchase page in the person's browser. */
readonly onPurchase: (name: string) => void
readonly onSignIn: () => void
} }
/** /**
* One card. * One card.
* *
* A card is a function of a title and the strings: it holds no state of its own, so * A card is a function of a title and the strings: it holds no state of its own, so the
* the grid can throw the lot away and rebuild after every listing. * grid can throw the lot away and rebuild after every listing. The one exception is the
* actions menu, whose open/closed state lives in a `<details>` element and being
* thrown away is exactly what should happen to an open menu when the catalog changes
* under it.
*/ */
export class GameCardView { export class GameCardView {
public constructor (private readonly callbacks: GameCardViewCallbacks) {} public constructor (private readonly callbacks: GameCardViewCallbacks) {}
@@ -21,6 +28,7 @@ export class GameCardView {
const card = createElement('article', 'card') const card = createElement('article', 'card')
if (game.installed) card.classList.add('is-installed') if (game.installed) card.classList.add('is-installed')
if (!game.installable) card.classList.add('is-unavailable') if (!game.installable) card.classList.add('is-unavailable')
if (game.accessVerdict === 'purchasable') card.classList.add('is-purchasable')
card.appendChild(this.createArt(game)) card.appendChild(this.createArt(game))
card.appendChild(this.createBody(game, messages, busy)) card.appendChild(this.createBody(game, messages, busy))
return card return card
@@ -71,13 +79,41 @@ export class GameCardView {
meta.appendChild(badge) meta.appendChild(badge)
} }
meta.appendChild(createElement('span', 'badge badge-plain', game.platform)) meta.appendChild(createElement('span', 'badge badge-plain', game.platform))
meta.appendChild(createElement('span', 'version', // The price is where the mode badge is rather than down by the button: what a title
game.installed && game.installedVersion !== null // costs is something a person scans a grid for, and a number that only appears
? `${game.installedVersion} · ${messages.installed}` // beside a button is a number they have to hunt for card by card.
: game.version)) if (game.priceLabel !== null && game.accessVerdict !== 'entitled') {
meta.appendChild(createElement('span', 'badge badge-price', game.priceLabel))
}
if (game.accessVerdict === 'entitled') {
const owned = createElement('span', 'badge badge-owned', messages.owned)
owned.title = messages.ownedHint
meta.appendChild(owned)
}
meta.appendChild(this.createVersion(game, messages))
return meta return meta
} }
/**
* The version, and what the catalog has that this machine does not.
*
* Both numbers, when they differ: which version is installed is the thing a person
* came to the card to find out, and "there is a newer one" is only meaningful next to
* it. Where they agree, the second number would be noise.
*/
private createVersion (game: GameDto, messages: MessageBundle): HTMLElement {
if (!game.installed || game.installedVersion === null) {
return createElement('span', 'version', game.version)
}
if (!game.updateAvailable) {
return createElement('span', 'version', `${game.installedVersion} · ${messages.installed}`)
}
const version = createElement('span', 'version has-update',
`${game.installedVersion}${game.version}`)
version.title = messages.updateAvailable
return version
}
private createActions (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement { private createActions (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
const actions = createElement('div', 'actions') const actions = createElement('div', 'actions')
@@ -89,25 +125,128 @@ export class GameCardView {
return actions return actions
} }
const primary = createElement('button', 'btn btn-primary') // Not yours yet: the card sells rather than installs. Deliberately a live button
primary.disabled = busy // 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.textContent = game.mode === 'web' ? messages.open : messages.play
primary.disabled = busy || !game.launchable primary.disabled = busy || !game.launchable
primary.addEventListener('click', (): void => { this.callbacks.onLaunch(game.name) }) primary.addEventListener('click', (): void => { this.callbacks.onLaunch(game.name) })
} else { return primary
primary.textContent = game.updateAvailable ? messages.update : messages.install
primary.addEventListener('click', (): void => { this.callbacks.onInstall(game.name) })
} }
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) * The three-dot menu: upgrade, and uninstall.
remove.disabled = busy *
remove.addEventListener('click', (): void => { this.callbacks.onRemove(game.name) }) * A `<details>` rather than a scripted popover, so the open state is the DOM's and the
actions.appendChild(remove) * keyboard works without being taught to. Upgrade is present but disabled when there
* is nothing newer a menu whose items appear and disappear makes a person hunt for
* the one they used last time, and "greyed out" already says "not now".
*/
private createMenu (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
const menu = createElement('details', 'menu')
const toggle = createElement('summary', 'icon-btn menu-toggle')
toggle.title = messages.moreActions
toggle.setAttribute('aria-label', messages.moreActions)
toggle.appendChild(this.createDotsIcon())
menu.appendChild(toggle)
const items = createElement('div', 'menu-items')
items.appendChild(this.createMenuItem(messages.upgrade, busy || !game.updateAvailable,
(): void => { this.callbacks.onUpgrade(game.name) }))
items.appendChild(this.createMenuItem(messages.uninstall, busy,
(): void => { this.callbacks.onRemove(game.name) }))
menu.appendChild(items)
return menu
}
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
} }
} }
+43 -1
View File
@@ -1,7 +1,7 @@
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto' import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
import { createElement, requireElement, setHidden, setText } from '../dom/Dom' import { createElement, requireElement, setHidden, setText } from '../dom/Dom'
import type { MessageBundle } from '../../shared/i18n/MessageBundle' import type { MessageBundle } from '../../shared/i18n/MessageBundle'
import type { GateLink, GatePresentation } from '../state/GatePresentation' import type { GateCustom, GateLink, GatePresentation } from '../state/GatePresentation'
/** /**
* The screen shown instead of the grid when there is nothing to drive: no Python, no * The screen shown instead of the grid when there is nothing to drive: no Python, no
@@ -15,7 +15,13 @@ export class GateView {
private readonly choiceLabel = requireElement('gate-choice-label', HTMLElement) private readonly choiceLabel = requireElement('gate-choice-label', HTMLElement)
private readonly select = requireElement('gate-select', HTMLSelectElement) private readonly select = requireElement('gate-select', HTMLSelectElement)
private readonly button = requireElement('gate-action', HTMLButtonElement) private readonly button = requireElement('gate-action', HTMLButtonElement)
private readonly cancel = requireElement('gate-cancel', HTMLButtonElement)
private readonly link = requireElement('gate-link', HTMLAnchorElement) private readonly link = requireElement('gate-link', HTMLAnchorElement)
private readonly custom = requireElement('gate-custom', HTMLElement)
private readonly customLabel = requireElement('gate-custom-label', HTMLElement)
private readonly customUrl = requireElement('gate-custom-url', HTMLInputElement)
private readonly customAction = requireElement('gate-custom-action', HTMLButtonElement)
private readonly customHint = requireElement('gate-custom-hint', HTMLElement)
public constructor (private readonly onOpenUrl: (url: string) => void) {} public constructor (private readonly onOpenUrl: (url: string) => void) {}
@@ -25,6 +31,8 @@ export class GateView {
setText(this.body, presentation.body) setText(this.body, presentation.body)
this.renderChoices(presentation.choices ?? [], messages) this.renderChoices(presentation.choices ?? [], messages)
this.renderAction(presentation) this.renderAction(presentation)
this.renderCancel(presentation)
this.renderCustom(presentation.custom ?? null)
this.renderLink(presentation.link ?? null) this.renderLink(presentation.link ?? null)
} }
@@ -57,6 +65,40 @@ export class GateView {
} }
} }
private renderCancel (presentation: GatePresentation): void {
const cancel = presentation.cancel
setHidden(this.cancel, cancel === undefined)
if (cancel === undefined) return
setText(this.cancel, cancel.label)
this.cancel.onclick = (): void => { cancel.perform(null) }
}
/**
* The typed-address row.
*
* Enter submits as well as the button, because a single text field with a button
* beside it is a form, and a form that ignores Enter is a small daily annoyance.
*/
private renderCustom (custom: GateCustom | null): void {
setHidden(this.custom, custom === null)
if (custom === null) return
setText(this.customLabel, custom.label)
setText(this.customHint, custom.hint)
setText(this.customAction, custom.actionLabel)
const submit = (): void => {
const value = this.customUrl.value.trim()
if (value.length === 0) return
custom.perform(value)
}
this.customAction.onclick = submit
this.customUrl.onkeydown = (event: KeyboardEvent): void => {
if (event.key !== 'Enter') return
event.preventDefault()
submit()
}
}
private renderLink (link: GateLink | null): void { private renderLink (link: GateLink | null): void {
setHidden(this.link, link === null) setHidden(this.link, link === null)
if (link === null) return if (link === null) return
+84 -6
View File
@@ -8,10 +8,13 @@ import type { AppState } from '../state/AppStore'
export interface SideMenuViewCallbacks { export interface SideMenuViewCallbacks {
readonly onSelectStore: (home: string) => void readonly onSelectStore: (home: string) => void
readonly onRemoveStore: (home: string, name: string) => void
readonly onAddStore: () => void readonly onAddStore: () => void
readonly onRefresh: () => void readonly onRefresh: () => void
readonly onSelectCategory: (filter: CategoryFilter) => void readonly onSelectCategory: (filter: CategoryFilter) => void
readonly onSelectLocale: (locale: string) => void readonly onSelectLocale: (locale: string) => void
readonly onSignIn: () => void
readonly onSignOut: () => void
} }
/** /**
@@ -26,6 +29,10 @@ export class SideMenuView {
private readonly addStore = requireElement('add-store', HTMLButtonElement) private readonly addStore = requireElement('add-store', HTMLButtonElement)
private readonly refresh = requireElement('refresh', HTMLButtonElement) private readonly refresh = requireElement('refresh', HTMLButtonElement)
private readonly categories = requireElement('cats', HTMLElement) private readonly categories = requireElement('cats', HTMLElement)
private readonly accountBlock = requireElement('account-block', HTMLElement)
private readonly accountHead = requireElement('head-account', HTMLElement)
private readonly accountState = requireElement('account-state', HTMLElement)
private readonly accountAction = requireElement('account-action', HTMLButtonElement)
private readonly locale = requireElement('locale', HTMLSelectElement) private readonly locale = requireElement('locale', HTMLSelectElement)
/** The square around the select: it is what a pointer hovers, so the tooltip is its. */ /** The square around the select: it is what a pointer hovers, so the tooltip is its. */
private readonly localeControl = requireElement('locale-control', HTMLElement) private readonly localeControl = requireElement('locale-control', HTMLElement)
@@ -34,12 +41,26 @@ export class SideMenuView {
this.addStore.addEventListener('click', callbacks.onAddStore) this.addStore.addEventListener('click', callbacks.onAddStore)
this.refresh.addEventListener('click', callbacks.onRefresh) this.refresh.addEventListener('click', callbacks.onRefresh)
this.locale.addEventListener('change', (): void => { callbacks.onSelectLocale(this.locale.value) }) this.locale.addEventListener('change', (): void => { callbacks.onSelectLocale(this.locale.value) })
this.accountAction.addEventListener('click', (): void => {
if (this.signedIn) this.callbacks.onSignOut()
else this.callbacks.onSignIn()
})
} }
/**
* Which of the two the one button does.
*
* Read at click time rather than rebound on every render: a listener replaced under
* a pointer that is already down is a click that goes nowhere.
*/
private signedIn = false
public render (state: AppState): void { public render (state: AppState): void {
setText(this.storesHead, state.messages.stores) setText(this.storesHead, state.messages.stores)
setText(this.categoriesHead, state.messages.categories) setText(this.categoriesHead, state.messages.categories)
setText(this.addStore, state.messages.addStore) // Add is an icon now, beside Refresh: naming it is all the view does, and writing
// text into it would replace the glyph.
describeControl(this.addStore, state.messages.addStoreHint)
// Refresh and the language picker are icons: naming them is all the view does, and // Refresh and the language picker are icons: naming them is all the view does, and
// writing text into them would replace the glyph. // writing text into them would replace the glyph.
describeControl(this.refresh, state.messages.refresh) describeControl(this.refresh, state.messages.refresh)
@@ -49,6 +70,7 @@ export class SideMenuView {
this.localeControl.title = state.messages.language this.localeControl.title = state.messages.language
this.renderStores(state) this.renderStores(state)
this.renderAccount(state)
this.renderCategories(state) this.renderCategories(state)
this.renderLocales(state) this.renderLocales(state)
this.renderEnabled(state) this.renderEnabled(state)
@@ -65,19 +87,55 @@ export class SideMenuView {
.map((store: InstalledStoreDto): string => store.id)) .map((store: InstalledStoreDto): string => store.id))
this.storeList.replaceChildren(...state.stores.map((store: InstalledStoreDto): HTMLElement => { this.storeList.replaceChildren(...state.stores.map((store: InstalledStoreDto): HTMLElement => {
const row = createElement('button', 'store-row') // A row is a button *and* carries one; nesting them would be invalid markup and
// the inner click would reach the outer handler anyway. So the row is a wrapper
// with two buttons in it: switch, and remove.
const row = createElement('div', 'store-row')
if (store.home === activeHome) row.classList.add('is-active') if (store.home === activeHome) row.classList.add('is-active')
row.appendChild(createElement('span', 'store-row-name', store.name))
row.appendChild(createElement('span', 'store-row-id', const open = createElement('button', 'store-row-open')
open.appendChild(createElement('span', 'store-row-name', store.name))
open.appendChild(createElement('span', 'store-row-id',
ambiguousIds.has(store.id) ? store.home : store.id)) ambiguousIds.has(store.id) ? store.home : store.id))
row.title = store.home open.title = store.home
row.addEventListener('click', (): void => { open.addEventListener('click', (): void => {
if (store.home !== activeHome) this.callbacks.onSelectStore(store.home) if (store.home !== activeHome) this.callbacks.onSelectStore(store.home)
}) })
row.appendChild(open)
const remove = createElement('button', 'store-row-remove')
remove.appendChild(createTrashIcon())
describeControl(remove, state.messages.removeStore)
remove.addEventListener('click', (): void => {
this.callbacks.onRemoveStore(store.home, store.name)
})
row.appendChild(remove)
return row return row
})) }))
} }
/**
* The account block, or nothing at all.
*
* Hidden outright where the catalog offers no sign-in which is most of them. A
* disabled "Sign in" would be telling somebody about a door that is not there.
*/
private renderAccount (state: AppState): void {
this.accountBlock.hidden = !state.account.signInAvailable
if (!state.account.signInAvailable) return
this.signedIn = state.account.signedIn
setText(this.accountHead, state.messages.account)
setText(this.accountState, state.account.signedIn ? state.messages.signedIn : '')
setText(this.accountAction, state.account.signedIn
? state.messages.signOut
: state.messages.signIn)
// A sign-in already under way has its own panel with a cancel on it; a second
// "Sign in" here would start a second flow behind the first one's code.
this.accountAction.disabled = state.busy || state.signIn !== null
}
private renderCategories (state: AppState): void { private renderCategories (state: AppState): void {
const sections = buildCategorySections(state.games, state.messages) const sections = buildCategorySections(state.games, state.messages)
const nodes: HTMLElement[] = [] const nodes: HTMLElement[] = []
@@ -128,6 +186,26 @@ export class SideMenuView {
* `title` is the tooltip a mouse finds and `aria-label` is what a screen reader reads; * `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. * an icon button needs both, and they are the same sentence.
*/ */
/**
* The remove glyph: a lid and a bin.
*
* Drawn rather than a character, for the same reason the other two icons are a font
* that lacks the symbol shows a box, and this button has no text to fall back on.
*/
function createTrashIcon (): SVGSVGElement {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('class', 'icon icon-trash')
svg.setAttribute('viewBox', '0 0 16 16')
svg.setAttribute('aria-hidden', 'true')
svg.setAttribute('focusable', 'false')
for (const d of ['M3 4.5h10', 'M6.5 4.5V3h3v1.5', 'M4.5 4.5 5 13h6l.5-8.5', 'M6.8 7v3.5', 'M9.2 7v3.5']) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', d)
svg.appendChild(path)
}
return svg
}
function describeControl (element: HTMLElement, name: string): void { function describeControl (element: HTMLElement, name: string): void {
element.title = name element.title = name
element.setAttribute('aria-label', 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
}
}
+61 -22
View File
@@ -3,9 +3,9 @@ import path from 'node:path'
import { GameDtoMapper } from '../application/mappers/GameDtoMapper' import { GameDtoMapper } from '../application/mappers/GameDtoMapper'
import type { CatalogListing } from '../domain/models/CatalogListing' import type { CatalogListing } from '../domain/models/CatalogListing'
import type { InstalledStore } from '../domain/models/InstalledStore' import type { InstalledStore } from '../domain/models/InstalledStore'
import type { RegistryStore } from '../domain/models/RegistryStore'
import { DESKTOP_STORE_ENGINE } from '../domain/models/StoreEngine' import { DESKTOP_STORE_ENGINE } from '../domain/models/StoreEngine'
import { deriveStoreId } from '../domain/models/StoreIdentity' import { deriveStoreId } from '../domain/models/StoreIdentity'
import type { CredentialRepository } from '../domain/ports/CredentialRepository'
import { NativeStoreCatalogGateway } from '../infrastructure/engine/NativeStoreCatalogGateway' import { NativeStoreCatalogGateway } from '../infrastructure/engine/NativeStoreCatalogGateway'
import { HttpTextClient } from '../infrastructure/http/HttpTextClient' import { HttpTextClient } from '../infrastructure/http/HttpTextClient'
import { FileSystemInstalledStoreRepository } from '../infrastructure/repositories/FileSystemInstalledStoreRepository' import { FileSystemInstalledStoreRepository } from '../infrastructure/repositories/FileSystemInstalledStoreRepository'
@@ -27,12 +27,18 @@ import { LOCALES } from '../shared/i18n/MessageBundle'
* *
* npm run smoke the store on this machine * npm run smoke the store on this machine
* SMOKE_HOME=/path/to/store-home npm run smoke a sandbox store * SMOKE_HOME=/path/to/store-home npm run smoke a sandbox store
* SMOKE_TOKEN=<bearer> npm run smoke as a signed-in person
*
* `SMOKE_TOKEN` exists because the signed-in path is otherwise untestable here: the
* real credential store is the OS keychain, reached through Electron, and there is no
* Electron in this process. Without it a gated catalog can only ever be read as an
* anonymous caller, and "owned" and "not owned" never happen.
*/ */
class SmokeTest { class SmokeTest {
private failed = false private failed = false
private readonly stores = new FileSystemInstalledStoreRepository() private readonly stores = new FileSystemInstalledStoreRepository()
private readonly catalogGateway = new NativeStoreCatalogGateway() private readonly catalogGateway = new NativeStoreCatalogGateway(envCredentials())
private readonly httpClient = new HttpTextClient() private readonly httpClient = new HttpTextClient()
private readonly registry = new HttpStoreRegistryRepository(this.httpClient) private readonly registry = new HttpStoreRegistryRepository(this.httpClient)
private readonly gameMapper = new GameDtoMapper() private readonly gameMapper = new GameDtoMapper()
@@ -66,34 +72,17 @@ class SmokeTest {
return return
} }
this.reportOk('registry', `${String(stores.length)} store(s) from ${this.registry.sourceUrl}`) this.reportOk('registry', `${String(stores.length)} store(s) from ${this.registry.sourceUrl}`)
// The slug is worth printing: it names the store home and the games subfolder, and
// it is derived here rather than told to us, so a wrong catalog URL shows up as a
// wrong folder name before anything is installed.
for (const store of stores) { for (const store of stores) {
this.reportOk(` ${store.name}`, `${store.catalogUrl} · ${deriveStoreId(store)}`) this.reportOk(` ${store.name}`, `${store.catalogUrl} · ${deriveStoreId(store)}`)
await this.checkStoreConfig(store)
} }
} catch (error: unknown) { } catch (error: unknown) {
this.reportBad('registry', `${this.registry.sourceUrl}: ${this.describe(error)}`) this.reportBad('registry', `${this.registry.sourceUrl}: ${this.describe(error)}`)
} }
} }
/**
* A store needs no repository, and a repository needs no config.json: either way
* the engine's defaults carry it. So both absences are reported, not failed.
*/
private async checkStoreConfig (store: RegistryStore): Promise<void> {
if (store.storeRepositoryUrl === null) {
this.reportOk(' config', 'no repository — the engine defaults would be used')
return
}
const url = `${store.storeRepositoryUrl.replace(/\/+$/, '')}/raw/branch/master/config.json`
try {
const config: unknown = JSON.parse(await this.httpClient.readText(url))
const sections = typeof config === 'object' && config !== null ? Object.keys(config).length : 0
this.reportOk(' config.json', `${String(sections)} sections`)
} catch (error: unknown) {
this.reportOk(' config.json', `absent (${this.describe(error)}) — defaults would be used`)
}
}
private findStore (): InstalledStore | null { private findStore (): InstalledStore | null {
const sandbox = process.env['SMOKE_HOME'] const sandbox = process.env['SMOKE_HOME']
if (sandbox !== undefined && sandbox.length > 0) { if (sandbox !== undefined && sandbox.length > 0) {
@@ -141,10 +130,44 @@ class SmokeTest {
} }
const games = this.gameMapper.toDtoList(listing.games, listing.paths?.catalogBaseUrl ?? '') const games = this.gameMapper.toDtoList(listing.games, listing.paths?.catalogBaseUrl ?? '')
this.reportListing(games) this.reportListing(games)
this.reportAccess(listing, games)
if (logLines.length > 0) this.reportOk('stderr log', `${String(logLines.length)} lines (kept off stdout)`) if (logLines.length > 0) this.reportOk('stderr log', `${String(logLines.length)} lines (kept off stdout)`)
} }
/**
* What the catalog said about who may have what.
*
* The load-bearing case is the boring one: an older engine says nothing, every title
* comes back `open`, and the client behaves exactly as it did before any of this
* existed. A gated catalog is where the rest of it starts mattering.
*/
private reportAccess (listing: CatalogListing, games: readonly GameDto[]): void {
const account = listing.account
this.reportOk('sign-in', account.signInAvailable
? (account.signedIn ? 'offered, and signed in' : 'offered, not signed in')
: 'not offered by this catalog')
const counts = new Map<string, number>()
for (const game of games) {
counts.set(game.accessVerdict, (counts.get(game.accessVerdict) ?? 0) + 1)
}
const summary = [...counts.entries()]
.map(([verdict, count]: readonly [string, number]): string => `${verdict}:${String(count)}`)
.join(', ')
this.reportOk('access', summary)
// A price with nothing to click, or a purchase button with no price, is a card
// somebody cannot act on.
const unbuyable = games.filter((game: GameDto): boolean =>
game.accessVerdict === 'purchasable' && game.purchaseUrl === null)
if (unbuyable.length > 0) {
this.reportBad('purchase links', `${String(unbuyable.length)} priced titles have nowhere to buy them`)
} else if ((counts.get('purchasable') ?? 0) > 0) {
this.reportOk('purchase links', 'every priced title has one')
}
}
private reportListing (games: readonly GameDto[]): void { private reportListing (games: readonly GameDto[]): void {
const installable = games.filter((game: GameDto): boolean => game.installable) const installable = games.filter((game: GameDto): boolean => game.installable)
const native = installable.filter((game: GameDto): boolean => game.mode === 'app').length const native = installable.filter((game: GameDto): boolean => game.mode === 'app').length
@@ -203,3 +226,19 @@ void new SmokeTest().run().then(
process.exitCode = 1 process.exitCode = 1
} }
) )
/**
* The token from the environment, for every store.
*
* Deliberately not per store: this is a test harness pointed at one catalog at a time,
* and a keyed map here would be ceremony around a single value. Nothing writes a
* smoke run must not leave a credential behind on the machine that ran it.
*/
function envCredentials (): CredentialRepository {
const token = process.env['SMOKE_TOKEN'] ?? ''
return {
readToken: (): string | null => (token.length > 0 ? token : null),
writeToken: (storeId: string, value: string): void => { void storeId; void value },
clearToken: (storeId: string): void => { void storeId }
}
}
+191
View File
@@ -0,0 +1,191 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { StoreProvisioningService } from '../application/services/StoreProvisioningService'
import { StoreSelectionService } from '../application/services/StoreSelectionService'
import { PreferencesService } from '../application/services/PreferencesService'
import type { InstalledStore } from '../domain/models/InstalledStore'
import type { ApplicationEnvironment } from '../domain/ports/ApplicationEnvironment'
import type { StoreRegistryRepository } from '../domain/ports/StoreRegistryRepository'
import { NativeStoreCatalogGateway } from '../infrastructure/engine/NativeStoreCatalogGateway'
import { FileSystemInstalledStoreRepository } from '../infrastructure/repositories/FileSystemInstalledStoreRepository'
import { NativeStoreEngineInstaller } from '../infrastructure/repositories/NativeStoreEngineInstaller'
import { JsonFilePreferencesRepository } from '../infrastructure/repositories/JsonFilePreferencesRepository'
import { asRecord } from '../infrastructure/json/JsonRecord'
/**
* Adding a store and taking it away again, in a sandbox.
*
* This exists because removal is the only code in the application that deletes a
* directory tree, and the path it deletes is named by the window the least trusted
* thing here. A mistake in it is not a rendering glitch; it is somebody's files. The
* smoke test cannot cover it, since it runs against the real machine and would have to
* delete a real store to prove anything.
*
* Everything happens under a temporary root: `STORE_ROOT` is what the repository looks
* at first, so nothing outside it is even visible to this run.
*
* npm run storetest
*/
class StoreLifecycleTest {
private failed = false
private readonly root = fs.mkdtempSync(path.join(os.tmpdir(), 'warp-store-lifecycle-'))
private readonly stores: FileSystemInstalledStoreRepository
private readonly provisioning: StoreProvisioningService
private readonly selection: StoreSelectionService
public constructor () {
// Before the repository is built: the roots are read from the environment, and
// this is what keeps the run inside the sandbox.
process.env['STORE_ROOT'] = this.root
this.stores = new FileSystemInstalledStoreRepository()
const preferences = new PreferencesService(
new JsonFilePreferencesRepository(sandboxEnvironment(this.root)), sandboxEnvironment(this.root)
)
this.selection = new StoreSelectionService(this.stores, preferences)
this.provisioning = new StoreProvisioningService(
emptyRegistry(),
new NativeStoreEngineInstaller(),
this.stores,
this.selection,
new NativeStoreCatalogGateway(undefined, this.stores)
)
}
public async run (): Promise<number> {
console.log('warp-engine-client store lifecycle test')
console.log(` .. sandbox: ${this.root}`)
try {
const store = await this.checkAddByUrl()
await this.checkRemove(store)
await this.checkRemoveRefusesAnythingElse()
await this.checkNormalisation()
} catch (error: unknown) {
this.report(false, 'run', error instanceof Error ? error.message : String(error))
} finally {
fs.rmSync(this.root, { recursive: true, force: true })
}
return this.failed ? 1 : 0
}
/** A typed address becomes a store, with its identity derived from the URL. */
private async checkAddByUrl (): Promise<InstalledStore> {
const store = await this.provisioning.installCatalog('orbit.teletypegames.org')
// https assumed, name from the host, id from the host's first label — none of it
// supplied, all of it derived, which is what makes a bare address enough to type.
this.report(store.id === 'orbit', 'id from the host', store.id)
this.report(store.name === 'orbit.teletypegames.org', 'name from the host', store.name)
this.report(fs.existsSync(store.configPath), 'config written', store.configPath)
this.report(
readBaseUrl(store.configPath) === 'https://orbit.teletypegames.org',
'https assumed', readBaseUrl(store.configPath)
)
this.report(store.home.startsWith(this.root), 'inside the sandbox', store.home)
this.report(this.stores.findAll().length === 1, 'found by a scan', String(this.stores.findAll().length))
return store
}
/** And it goes away again, home and all. */
private async checkRemove (store: InstalledStore): Promise<void> {
await this.provisioning.removeStore(store)
this.report(!fs.existsSync(store.home), 'home removed', store.home)
this.report(this.stores.findAll().length === 0, 'gone from the scan')
this.report(this.selection.findCurrentStore() === null, 'nothing open afterwards')
}
/**
* The guard: a path that is not a store home on this machine is refused.
*
* This is the one that matters. The window names what to remove, so the only thing
* standing between a typo or something worse and `rm -rf` is that the path has to
* resolve to a store a scan actually found.
*/
private async checkRemoveRefusesAnythingElse (): Promise<void> {
const bystander = path.join(this.root, 'not-a-store')
fs.mkdirSync(bystander, { recursive: true })
fs.writeFileSync(path.join(bystander, 'keep-me.txt'), 'important')
const fake: InstalledStore = {
id: 'fake', name: 'fake', home: bystander,
configPath: path.join(bystander, 'config.json'), engine: 'desktop'
}
let refused = false
try {
await this.provisioning.removeStore(fake)
} catch {
refused = true
}
this.report(refused, 'refuses a path that is not a store')
this.report(fs.existsSync(path.join(bystander, 'keep-me.txt')), 'left the bystander alone')
}
/**
* What is simply not an address is refused before anything is written.
*
* Refusing early is the point: a store home created for an unusable URL is a
* directory somebody has to find and delete by hand, and they would have no idea
* why it was there.
*/
private async checkNormalisation (): Promise<void> {
const accepted: string[] = []
for (const value of [ '', ' ', 'http://', 'not a url at all' ]) {
try {
const store = await this.provisioning.installCatalog(value)
accepted.push(`${JSON.stringify(value)} -> ${store.home}`)
} catch {
// Refused, which is what should happen.
}
}
this.report(accepted.length === 0, 'refuses what is not an address', accepted.join(', '))
this.report(this.stores.findAll().length === 0, 'and wrote nothing while refusing')
}
private report (passed: boolean, what: string, detail: string = ''): void {
if (!passed) this.failed = true
console.log(` ${passed ? 'ok ' : 'FAIL'} ${what}${detail === '' ? '' : `: ${detail}`}`)
}
}
/** A registry with nothing in it: this test is about the typed path, not the listed one. */
function emptyRegistry (): StoreRegistryRepository {
return {
sourceUrl: 'about:blank',
listStores: (): Promise<readonly []> => Promise.resolve([])
}
}
function sandboxEnvironment (root: string): ApplicationEnvironment {
return {
readVersion: (): string => '0.0.0-test',
readSystemLocale: (): string => 'en',
resolveUserDataPath: (fileName: string): string => path.join(root, fileName)
}
}
/**
* The base URL out of the config the installer wrote.
*
* Read through an index signature rather than a typed shape: the file is snake_case
* that is the format the store repositories publish and the shell engine wrote and
* naming the key in a type here would be the one place in this codebase declaring a
* property the linter would then have to be told to allow.
*/
function readBaseUrl (configPath: string): string {
try {
const parsed: unknown = JSON.parse(fs.readFileSync(configPath, 'utf8'))
const store = asRecord(asRecord(parsed)?.['store'])
const baseUrl = store?.['base_url']
return typeof baseUrl === 'string' ? baseUrl : ''
} catch {
return ''
}
}
void new StoreLifecycleTest().run().then((code: number): void => { process.exitCode = code })
+12
View File
@@ -1,3 +1,4 @@
import type { AccountDto, SignInFinishedDto, SignInPromptDto } from './dto/AccountDto'
import type { AppStateDto } from './dto/AppStateDto' import type { AppStateDto } from './dto/AppStateDto'
import type { CatalogListingDto } from './dto/CatalogListingDto' import type { CatalogListingDto } from './dto/CatalogListingDto'
import type { InstalledStoreDto } from './dto/InstalledStoreDto' import type { InstalledStoreDto } from './dto/InstalledStoreDto'
@@ -30,9 +31,19 @@ export interface BridgeApi {
removeGame: (name: string) => Promise<void> removeGame: (name: string) => Promise<void>
launchGame: (name: string) => Promise<boolean> launchGame: (name: string) => Promise<boolean>
readAccount: () => Promise<AccountDto>
/** Answers with the code to show; how it ended arrives on `onSignInFinished`. */
beginSignIn: () => Promise<SignInPromptDto>
cancelSignIn: () => Promise<void>
signOut: () => Promise<AccountDto>
listRegistryStores: () => Promise<RegistryResultDto> listRegistryStores: () => Promise<RegistryResultDto>
installStore: (store: RegistryStoreDto) => Promise<InstalledStoreDto> installStore: (store: RegistryStoreDto) => Promise<InstalledStoreDto>
/** A catalog the registry does not list; the name is derived from the address. */
installCatalog: (catalogUrl: string) => Promise<InstalledStoreDto>
selectStore: (home: string) => Promise<StoreSelectionDto> selectStore: (home: string) => Promise<StoreSelectionDto>
/** Uninstall everything the store put here, then remove the store itself. */
removeStore: (home: string) => Promise<void>
openFolder: (directory: string) => Promise<boolean> openFolder: (directory: string) => Promise<boolean>
openUrl: (url: string) => Promise<boolean> openUrl: (url: string) => Promise<boolean>
@@ -40,6 +51,7 @@ export interface BridgeApi {
onLog: (listener: StreamListener<string>) => void onLog: (listener: StreamListener<string>) => void
onSyncEvent: (listener: StreamListener<SyncEventDto>) => void onSyncEvent: (listener: StreamListener<SyncEventDto>) => void
onBusyChanged: (listener: StreamListener<boolean>) => void onBusyChanged: (listener: StreamListener<boolean>) => void
onSignInFinished: (listener: StreamListener<SignInFinishedDto>) => void
} }
/** The name the bridge is published under on `window`. */ /** The name the bridge is published under on `window`. */
+10 -1
View File
@@ -19,14 +19,23 @@ export const IPC_CHANNELS = {
catalogRemoveGame: 'catalog:removeGame', catalogRemoveGame: 'catalog:removeGame',
catalogLaunchGame: 'catalog:launchGame', catalogLaunchGame: 'catalog:launchGame',
accountRead: 'account:read',
accountBeginSignIn: 'account:beginSignIn',
accountCancelSignIn: 'account:cancelSignIn',
accountSignOut: 'account:signOut',
storeListRegistry: 'store:listRegistry', storeListRegistry: 'store:listRegistry',
storeInstallStore: 'store:installStore', storeInstallStore: 'store:installStore',
storeInstallCatalog: 'store:installCatalog',
storeSelectStore: 'store:selectStore', storeSelectStore: 'store:selectStore',
storeRemoveStore: 'store:removeStore',
/** Main to renderer, one way. */ /** Main to renderer, one way. */
streamLog: 'stream:log', streamLog: 'stream:log',
streamSyncEvent: 'stream:syncEvent', streamSyncEvent: 'stream:syncEvent',
streamBusyChanged: 'stream:busyChanged' streamBusyChanged: 'stream:busyChanged',
/** How a sign-in ended, once the browser half is done. */
streamSignInFinished: 'stream:signInFinished'
} as const } as const
export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS] export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]
+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
}
@@ -1,3 +1,4 @@
import type { AccountDto } from './AccountDto'
import type { GameDto } from './GameDto' import type { GameDto } from './GameDto'
import type { StorePathsDto } from './StorePathsDto' import type { StorePathsDto } from './StorePathsDto'
@@ -6,4 +7,12 @@ export interface CatalogListingDto {
readonly games: readonly GameDto[] readonly games: readonly GameDto[]
readonly skipped: readonly string[] readonly skipped: readonly string[]
readonly paths: StorePathsDto | null 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
} }
+18
View File
@@ -1,6 +1,19 @@
/** How a title runs: unpacked on this machine, or served as a web build. */ /** How a title runs: unpacked on this machine, or served as a web build. */
export type GameModeDto = 'app' | 'web' export type GameModeDto = 'app' | 'web'
/**
* 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. */ /** Why a title cannot be installed on this machine. */
export type UnavailableReasonDto = 'platformOff' | 'hostAsset' | 'noAsset' | 'vetoed' export type UnavailableReasonDto = 'platformOff' | 'hostAsset' | 'noAsset' | 'vetoed'
@@ -30,4 +43,9 @@ export interface GameDto {
readonly installable: boolean readonly installable: boolean
readonly unavailableReason: UnavailableReasonDto | null readonly unavailableReason: UnavailableReasonDto | null
readonly unavailableDetail: string | 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 { export interface RegistryStoreDto {
readonly name: string readonly name: string
readonly catalogUrl: string readonly catalogUrl: string
/** Null when the store has no repository of its own; the engine's defaults are then used. */ /** Derived from the catalog host, or the name — what the store will be called on disk. */
readonly storeRepositoryUrl: string | null
/** Derived from the repository, the catalog host or the name — what the store will be called on disk. */
readonly storeId: string readonly storeId: string
} }
+35 -4
View File
@@ -6,12 +6,13 @@
*/ */
export const ENGLISH_MESSAGES = { export const ENGLISH_MESSAGES = {
appName: 'WarpEngine Client', appName: 'WarpEngine Client',
refresh: 'Refresh', refresh: 'Refresh the catalog',
install: 'Install', install: 'Install',
update: 'Update', upgrade: 'Upgrade',
play: 'Play', play: 'Play',
open: 'Open', open: 'Open',
remove: 'Remove', uninstall: 'Uninstall',
moreActions: 'More actions',
installed: 'installed', installed: 'installed',
native: 'native', native: 'native',
hosted: 'hosted', hosted: 'hosted',
@@ -24,12 +25,22 @@ export const ENGLISH_MESSAGES = {
menu: 'Menu', menu: 'Menu',
stores: 'Stores', stores: 'Stores',
addStore: 'Add a store…', addStore: 'Add a store…',
cancel: 'Cancel',
addStoreHint: 'Add a store',
customTitle: 'Or a catalog of your own',
customHint: 'Any WarpEngine catalog. The name is taken from the address; https is assumed when you leave the scheme out.',
customAction: 'Add',
removeStore: 'Remove this store',
removeStoreConfirm: 'Remove %{store}? Its settings and its record of what it installed are deleted.',
removeStoreConfirmGames: 'Remove %{store}? This uninstalls the %{count} title(s) it put on this machine and deletes its menu entries.',
switchFailed: 'That store could not be opened', switchFailed: 'That store could not be opened',
categories: 'Categories', categories: 'Categories',
catAll: 'Everything', catAll: 'Everything',
catInstalled: 'Installed', catInstalled: 'Installed',
catUpdates: 'Updates', catUpdates: 'Updates',
catAvailable: 'Not installed', catAvailable: 'Not installed',
catOwned: 'Owned',
catPurchasable: 'To buy',
catUnsupported: 'Not for this machine', catUnsupported: 'Not for this machine',
catPlatform: 'Platform', catPlatform: 'Platform',
catMode: 'Kind', catMode: 'Kind',
@@ -51,7 +62,27 @@ export const ENGLISH_MESSAGES = {
failed: 'failed', failed: 'failed',
removed: 'removed', removed: 'removed',
upToDate: 'Everything is up to date.', upToDate: 'Everything is up to date.',
of: 'of' of: 'of',
owned: 'owned',
ownedHint: 'This account owns it — install it on this machine',
purchase: 'Buy',
purchaseHint: 'Opens the store page in your browser',
signInToInstall: 'Sign in to install',
signInToInstallHint: 'This title needs an account; the catalog will say whether you own it',
account: 'Account',
signIn: 'Sign in…',
signOut: 'Sign out',
signedIn: 'Signed in',
signInTitle: 'Sign in to %{store}',
signInBody: 'Your browser is opening the sign-in page. Type this code there:',
signInOpenAgain: 'Open the page again',
signInWaiting: 'Waiting for you to approve it…',
signInCancel: 'Cancel',
signInDone: 'Signed in.',
signInDenied: 'That sign-in was refused.',
signInExpired: 'That code expired. Try again.',
signInCancelled: 'Sign-in cancelled.',
signInFailed: 'Signing in did not work.'
} as const } as const
/** Every string the window can show, by key. */ /** Every string the window can show, by key. */
+35 -4
View File
@@ -6,12 +6,13 @@ import type { MessageBundle } from './MessageBundle'
*/ */
export const HUNGARIAN_MESSAGES: MessageBundle = { export const HUNGARIAN_MESSAGES: MessageBundle = {
appName: 'WarpEngine Client', appName: 'WarpEngine Client',
refresh: 'Frissítés', refresh: 'Katalógus frissítése',
install: 'Telepítés', install: 'Telepítés',
update: 'Frissítés', upgrade: 'Frissítés',
play: 'Indítás', play: 'Indítás',
open: 'Megnyitás', open: 'Megnyitás',
remove: 'Eltávolítás', uninstall: 'Eltávolítás',
moreActions: 'További műveletek',
installed: 'telepítve', installed: 'telepítve',
native: 'natív', native: 'natív',
hosted: 'hosztolt', hosted: 'hosztolt',
@@ -24,12 +25,22 @@ export const HUNGARIAN_MESSAGES: MessageBundle = {
menu: 'Menü', menu: 'Menü',
stores: 'Store-ok', stores: 'Store-ok',
addStore: 'Store hozzáadása…', addStore: 'Store hozzáadása…',
cancel: 'Mégsem',
addStoreHint: 'Bolt hozzáadása',
customTitle: 'Vagy egy saját katalógus',
customHint: 'Bármelyik WarpEngine-katalógus. A nevet a címből vesszük; séma nélkül https-t feltételezünk.',
customAction: 'Hozzáadás',
removeStore: 'Bolt eltávolítása',
removeStoreConfirm: 'Eltávolítod a(z) %{store} boltot? A beállításai és a nyilvántartása a telepítettekről törlődik.',
removeStoreConfirmGames: 'Eltávolítod a(z) %{store} boltot? Ezzel eltávolul az a(z) %{count} cím is, amit erre a gépre tett, a menüelemeikkel együtt.',
switchFailed: 'Ez a store nem nyitható meg', switchFailed: 'Ez a store nem nyitható meg',
categories: 'Kategóriák', categories: 'Kategóriák',
catAll: 'Minden', catAll: 'Minden',
catInstalled: 'Telepítve', catInstalled: 'Telepítve',
catUpdates: 'Frissítés', catUpdates: 'Frissítés',
catAvailable: 'Nincs telepítve', catAvailable: 'Nincs telepítve',
catOwned: 'Birtokolt',
catPurchasable: 'Megvehető',
catUnsupported: 'Erre a gépre nem', catUnsupported: 'Erre a gépre nem',
catPlatform: 'Platform', catPlatform: 'Platform',
catMode: 'Fajta', catMode: 'Fajta',
@@ -51,5 +62,25 @@ export const HUNGARIAN_MESSAGES: MessageBundle = {
failed: 'hiba', failed: 'hiba',
removed: 'eltávolítva', removed: 'eltávolítva',
upToDate: 'Minden naprakész.', upToDate: 'Minden naprakész.',
of: '/' of: '/',
owned: 'birtokolt',
ownedHint: 'Ez a fiók birtokolja — telepítheted erre a gépre',
purchase: 'Megvásárlás',
purchaseHint: 'Megnyitja a bolt oldalát a böngésződben',
signInToInstall: 'Belépés a telepítéshez',
signInToInstallHint: 'Ehhez a címhez fiók kell; a katalógus akkor mondja meg, birtoklod-e',
account: 'Fiók',
signIn: 'Belépés…',
signOut: 'Kilépés',
signedIn: 'Belépve',
signInTitle: 'Belépés ide: %{store}',
signInBody: 'Megnyílik a böngésződ a belépő oldallal. Írd be ott ezt a kódot:',
signInOpenAgain: 'Oldal újranyitása',
signInWaiting: 'Várunk a jóváhagyásra…',
signInCancel: 'Mégsem',
signInDone: 'Beléptél.',
signInDenied: 'A belépést elutasították.',
signInExpired: 'A kód lejárt. Próbáld újra.',
signInCancelled: 'Belépés megszakítva.',
signInFailed: 'A belépés nem sikerült.'
} }