Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31da869800 | ||
|
|
6285d93790 | ||
|
|
255c588cbd | ||
|
|
3d42355189 | ||
|
|
26c7aa9be1 | ||
|
|
e35a72336a |
@@ -36,7 +36,7 @@ BUILDER_ARGS := $(if $(STORES_API),-- --config.extraMetadata.warpEngine.registry
|
||||
|
||||
.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 icons test \
|
||||
dist dist-mac dist-win dist-linux release publish clean distclean version
|
||||
|
||||
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
|
||||
# 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
|
||||
npm start
|
||||
@@ -83,7 +83,13 @@ smoke: ## Drive the store bridge with no window at all
|
||||
uitest: ## Load the window once and report what rendered
|
||||
npm run uitest
|
||||
|
||||
test: smoke uitest ## Both checks
|
||||
icons: ## Render every icon format from resources/icon.svg
|
||||
npm run icons
|
||||
|
||||
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
|
||||
npm run dist $(BUILDER_ARGS)
|
||||
|
||||
@@ -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
|
||||
result verifies as `valid on disk`.
|
||||
|
||||
## Signing in, and titles that cost money
|
||||
|
||||
**Nothing in this client knows anything about a particular store.** What a title costs,
|
||||
whether it needs an account, where to buy it and where to sign in all arrive from the
|
||||
catalog's own server — WarpEngine 0.5 answers `GET /api/service` with what it offers, and
|
||||
puts an `access` block on every catalog entry. A client that carried those facts would
|
||||
work for exactly one shop; this one asks.
|
||||
|
||||
Where the server offers no sign-in — every WarpEngine before 0.5, and any store that
|
||||
sells nothing — the window shows none, and behaves exactly as it always did.
|
||||
|
||||
Where it does:
|
||||
|
||||
- the side menu grows an **Account** block: *Sign in…*, and *Sign out* once you are;
|
||||
- signing in shows a **short code**. Your browser opens on the store's own page and you
|
||||
type the code there; approving it signs this device in. Nothing is typed into this
|
||||
window, and no password ever reaches it — that is the whole reason for the detour;
|
||||
- the token is kept in the **OS keychain** (Keychain, libsecret, DPAPI) through
|
||||
Electron's `safeStorage`, one per store. Where no keychain is available it is not
|
||||
stored at all rather than written out in the clear: the cost is signing in again next
|
||||
run.
|
||||
|
||||
On a card, what you may do with a title is separate from what this machine can run:
|
||||
|
||||
- **owned** or free → *Install*, as before;
|
||||
- **not owned** → the **price** on the card and a **Buy** button, which opens the store's
|
||||
page in your browser. Buying happens there, not here — a checkout rebuilt in this
|
||||
window would be a second place to get card handling wrong. **Refresh** afterwards and
|
||||
the card becomes an *Install*;
|
||||
- **signed out, catalog gates it** → *Sign in to install*, because the catalog cannot say
|
||||
whether it is yours until it knows who is asking.
|
||||
|
||||
Two new categories go with it: **Owned** and **To buy**. Owning something is not the
|
||||
same as having installed it, which is the point of the first one.
|
||||
|
||||
A title nobody has bought is **not** dimmed. That treatment belongs to what this
|
||||
*machine* cannot do — an unsupported platform, no build for this architecture — and
|
||||
there is nothing wrong with the machine here.
|
||||
|
||||
## Which store it installs
|
||||
|
||||
On first run the client fetches the registry and offers what it finds. One store
|
||||
@@ -120,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
|
||||
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
|
||||
the id would not tell them apart. **Add a store…** brings up the registry
|
||||
picker, the same one the first run offers.
|
||||
- **Actions** holds **Refresh**, which re-reads the catalog. Titles are installed
|
||||
the id would not tell them apart. Hovering a row shows a **bin**, which takes that
|
||||
store off the machine — see below.
|
||||
- **+**, 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.
|
||||
- **Categories** narrows the grid, one category at a time, with the count next to
|
||||
each: *Everything*, *Installed*, *Updates*, *Not installed*, then a row per
|
||||
**platform** (`godot`, `tic80`, `love`, …) and per **kind** (native or hosted).
|
||||
Where the catalog gates anything, **Owned** and **To buy** join them.
|
||||
The axes are built from what the catalog actually contains — a platform with no
|
||||
titles is not listed, and a category that disappears under you falls back to
|
||||
*Everything* rather than leaving an empty grid. There is no genre in a
|
||||
@@ -160,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
|
||||
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
|
||||
your launcher, Dock or Start menu — the app does not have to be running to play.
|
||||
|
||||
@@ -174,9 +228,12 @@ names. `make` on its own lists everything.
|
||||
| `make build` | compile TypeScript, bundle the preload and the renderer |
|
||||
| `make typecheck` | type-check everything, emitting nothing |
|
||||
| `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 icons` | render every icon format from `resources/icon.svg` |
|
||||
| `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 |
|
||||
| `SELFTEST_SHOT=shot.png npm run uitest` | the same, and the window photographs itself into that file |
|
||||
| `make test` | both test suites |
|
||||
@@ -190,6 +247,23 @@ The npm scripts still work directly (`npm start`, `npm run dist:mac`) — the
|
||||
Makefile adds no logic of its own beyond the release step. Every script that runs the
|
||||
app builds first, so there is no way to test a stale bundle.
|
||||
|
||||
### The icon
|
||||
|
||||
`resources/icon.svg` is the source and the only file to edit; `make icons` renders the
|
||||
rest — `icon.png`, `icon.ico`, `icon.icns` and the `icons/` directory Linux packages
|
||||
want. Three committed binaries with no way to regenerate them is how an icon becomes
|
||||
something nobody dares change, so the render is a script rather than a memory.
|
||||
|
||||
It needs `rsvg-convert` (`brew install librsvg`, `apt install librsvg2-bin`). The
|
||||
`.icns` step additionally needs `iconutil`, which exists only on macOS — elsewhere it
|
||||
is skipped with a warning and the committed `.icns` stands, which is what a mac build
|
||||
uses anyway.
|
||||
|
||||
The mark is a **W with three lines running into it**: the product's initial, and what
|
||||
it is doing. It was drawn for the smallest size first — at 32px the W still reads and
|
||||
the lines survive as motion rather than as noise. A portal, a play triangle and a send
|
||||
arrow were all tried and all discarded: each already means something else.
|
||||
|
||||
### Continuous integration
|
||||
|
||||
`.woodpecker.yaml` builds the **Linux and Windows** packages, and on a tag attaches
|
||||
@@ -272,9 +346,14 @@ on its own: publishing 1.2.0 got *"invalid username, password or token"* on the
|
||||
second package while the first had just gone up with the same token, and the same
|
||||
command succeeded immediately afterwards.
|
||||
|
||||
Package names contain a space — `WarpEngine Client-1.5.0-arm64.dmg` — so the list of
|
||||
files is passed one path per line rather than as one string; splitting it on
|
||||
whitespace is what broke the first attempt at publishing 1.1.0.
|
||||
Package names have no spaces in them — `WarpEngineClient-2.3.0-arm64.dmg` — because a
|
||||
space in a release asset is a space in every `curl`, script and shell command that ever
|
||||
touches it. The app itself is still called **WarpEngine Client**: that name is what
|
||||
appears in the Dock and in `/Applications`, and only the file names were the problem.
|
||||
|
||||
The list of files is still passed one path per line rather than as one string, since a
|
||||
path given on the command line can contain a space even when a built one cannot;
|
||||
splitting it on whitespace is what broke the first attempt at publishing 1.1.0.
|
||||
|
||||
It needs `tea` installed and logged in — the devarea repo has `make tea` for that.
|
||||
Overridable: `TAG`, `REPO`, `TEA_LOGIN`, `NOTES`, `DIST`.
|
||||
@@ -292,14 +371,19 @@ own runtime.
|
||||
single-instance lock. Otherwise a copy the user already has open swallows the test
|
||||
process, which exits 0 and reads as a pass.
|
||||
|
||||
Both test scripts accept a sandbox store instead of the real one, which is how
|
||||
this repository is tested without touching a working installation:
|
||||
The test scripts accept a sandbox store instead of the real one, which is how this
|
||||
repository is tested without touching a working installation:
|
||||
|
||||
```sh
|
||||
STORE_ROOT=/tmp/sandbox-root npm start
|
||||
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
|
||||
|
||||
TypeScript, in layers, with the dependency rule pointing inward. **[STRUCTURE.md](STRUCTURE.md)
|
||||
|
||||
@@ -1,71 +1,23 @@
|
||||
# WarpEngine Client 2.2.0
|
||||
# WarpEngine Client 2.5.1
|
||||
|
||||
**A registry record is a name and a catalog.** Nothing else. The `config` field added in
|
||||
2.1.0 is gone, and so is `storeRepositoryUrl` — along with the two store repositories they
|
||||
pointed at, which have been deleted.
|
||||
**The app has an icon.** Until now every build shipped the default Electron one —
|
||||
`electron-builder` said so on every run, in a line easy to read past: *"default Electron
|
||||
icon is used, reason=application icon is not set"*. A store you install games with
|
||||
should not look like a framework demo in the Dock.
|
||||
|
||||
2.1.0 had the registry say how each store behaves. That was the wrong shape. How a store
|
||||
behaves is fixed per installed client: this application carries its own store engine and
|
||||
is the only thing that can see the machine it is running 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 is allowed to delete from — and a second authority is a way
|
||||
for the two to disagree.
|
||||
The mark is a **W with three lines running into it**: the product's initial, and what it
|
||||
is doing. It uses the window's own palette, so the icon and the application it opens are
|
||||
the same object. It was drawn for the smallest size first — at 32px the W still reads
|
||||
and the lines survive as motion rather than as noise. A portal, a play triangle and a
|
||||
send arrow were each tried and each discarded: they already mean a loading spinner, a
|
||||
media player and a submit button.
|
||||
|
||||
Keeping two stores on one machine out of each other's files needs none of it. It is a
|
||||
subfolder, and the client derives it:
|
||||
`resources/icon.svg` is the source and the only file to edit. `make icons` renders the
|
||||
`.png`, the `.ico`, the `.icns` and the Linux size directory from it — three committed
|
||||
binaries with no way to regenerate them is how an icon becomes something nobody dares
|
||||
touch.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **store id** | a slug of the catalog host — `teletypegames.org` becomes `teletypegames` |
|
||||
| **store home** | `<id>-desktop`, under the usual store root |
|
||||
| **games folder** | `<id>`, inside the OS's usual place for programs |
|
||||
| **delete boundary** | that same folder, and nothing outside it |
|
||||
The window also picks it up when run from source, where there is otherwise no icon to
|
||||
carry and a dev run looks like a different application from the one being built.
|
||||
|
||||
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. Installing the same
|
||||
store twice is idempotent rather than a way to orphan what is already there.
|
||||
|
||||
**Existing installations are untouched.** A store already on the machine is recognised by
|
||||
the `config.json` in its own home and keeps the identity written there — a store installed
|
||||
as `ttg` stays `ttg`, in `ttg-desktop`, with its games where they are. Only a *new* install
|
||||
derives its id from the catalog.
|
||||
|
||||
### Also
|
||||
|
||||
The public `/stores` page loses its desktop card, which advertised a `curl … | sh` for a
|
||||
repository that no longer exists; an ordinary computer is served by the app. The `/desktop`
|
||||
URL now lands on the app rather than on a device tab, so what someone typing it wants is
|
||||
what they get.
|
||||
|
||||
### 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 install path was measured end to end against a local registry serving exactly the new
|
||||
record shape. The slug came out `teletypegames`, 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` — what a stale registry or a
|
||||
tampering renderer might still send — changed nothing on disk, because neither field exists
|
||||
in the model any more.
|
||||
|
||||
On this machine the check also shows both halves of the compatibility claim at once: the
|
||||
registry's new slug reads `teletypegames`, while the store that was installed before today
|
||||
is still found as `ttg` in `ttg-desktop`, listing its ten installed titles from
|
||||
`Application Support/teletypegames`.
|
||||
|
||||
The site side was migrated and its specs re-run, and the frontend was built — which first
|
||||
required removing a dead `engines` list that had been failing `vue-tsc` on master, so that
|
||||
page could not be built to check at all.
|
||||
Nothing else changed: same store handling, same catalog, same sign-in.
|
||||
|
||||
@@ -59,6 +59,8 @@ src/
|
||||
infrastructure/
|
||||
engine/ the store engine: catalog, releases, install, state
|
||||
dialects/ one per WarpEngine version's catalog shape
|
||||
ServiceDescriptorClient what the catalog's server says it offers (GET /api/service)
|
||||
DeviceSignInClient the device authorization grant, client side
|
||||
launchers/ .desktop, .app bundle, .lnk — the three hosts
|
||||
archive/ ZipArchive: a zip reader over node:zlib
|
||||
files/ StoreFileSystem: atomic writes and the delete guard
|
||||
@@ -66,7 +68,8 @@ src/
|
||||
http/ HttpTextClient, StoreHttpClient, HttpStatusError
|
||||
json/ JsonRecord: reading data that came from elsewhere
|
||||
config/ BuildConfiguration: what was decided when this was packaged
|
||||
electron/ ApplicationEnvironment and GameLauncher adapters
|
||||
electron/ ApplicationEnvironment, GameLauncher, and the keychain
|
||||
credential store
|
||||
main/
|
||||
main.ts the entry point: one line of work
|
||||
ElectronApplication.ts lifecycle, single instance, self-test mode
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "warp-engine-client",
|
||||
"version": "1.2.0",
|
||||
"version": "2.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "warp-engine-client",
|
||||
"version": "1.2.0",
|
||||
"version": "2.5.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.2.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "warp-engine-client",
|
||||
"productName": "WarpEngine Client",
|
||||
"version": "2.2.0",
|
||||
"version": "2.5.1",
|
||||
"description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.",
|
||||
"license": "MIT",
|
||||
"author": "Teletype Games <games@teletype.hu>",
|
||||
@@ -21,7 +21,10 @@
|
||||
"dist": "npm run build && electron-builder",
|
||||
"dist:mac": "npm run build && electron-builder --mac",
|
||||
"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",
|
||||
"icons": "node scripts/build-icons.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.2.0",
|
||||
@@ -44,22 +47,41 @@
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
]
|
||||
],
|
||||
"artifactName": "WarpEngineClient-${version}-${arch}-mac.${ext}",
|
||||
"icon": "resources/icon.icns"
|
||||
},
|
||||
"dmg": {
|
||||
"artifactName": "WarpEngineClient-${version}-${arch}.${ext}"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis",
|
||||
"portable"
|
||||
]
|
||||
],
|
||||
"icon": "resources/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"artifactName": "WarpEngineClient-Setup-${version}-${arch}.${ext}"
|
||||
},
|
||||
"portable": {
|
||||
"artifactName": "WarpEngineClient-Portable-${version}-${arch}.${ext}"
|
||||
},
|
||||
"linux": {
|
||||
"category": "Game",
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb"
|
||||
]
|
||||
],
|
||||
"icon": "resources/icons"
|
||||
},
|
||||
"afterPack": "scripts/after-pack.js"
|
||||
"appImage": {
|
||||
"artifactName": "WarpEngineClient-${version}-${arch}.${ext}"
|
||||
},
|
||||
"afterPack": "scripts/after-pack.js",
|
||||
"directories": {
|
||||
"buildResources": "resources"
|
||||
}
|
||||
},
|
||||
"allowScripts": {
|
||||
"electron@43.4.0": true,
|
||||
|
||||
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 117 KiB |
@@ -0,0 +1,44 @@
|
||||
<!--
|
||||
The WarpEngine Client mark.
|
||||
|
||||
A W, and three lines running into it. The W is the product's initial; the lines are
|
||||
what the W is doing — motion, read left to right, which is also why they are dimmer
|
||||
than it is. Together they say "warp" without a spaceship, a portal or a play triangle,
|
||||
each of which was tried and each of which turned out to mean something else already:
|
||||
a send arrow, a loading spinner, a media player.
|
||||
|
||||
Drawn for the smallest size first. At 32px the W still reads and the lines survive as
|
||||
a stack of motion rather than as noise; anything with an outline, a ring or fine
|
||||
detail did not. The palette is the window's own, taken from src/renderer/style.css,
|
||||
so the icon and the application it opens are the same object.
|
||||
|
||||
This file is the source. `npm run icons` renders every format from it; nothing here is
|
||||
hand-edited binary.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#1e2937"/>
|
||||
<stop offset="1" stop-color="#0d1116"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0.5" cy="0.42" r="0.6">
|
||||
<stop offset="0" stop-color="#37b98a" stop-opacity="0.24"/>
|
||||
<stop offset="1" stop-color="#37b98a" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Inset by 7%: a macOS icon is a rounded tile with air around it, and the same
|
||||
shape is what Windows and the Linux menus get. -->
|
||||
<rect x="72" y="72" width="880" height="880" rx="200" fill="url(#tile)"/>
|
||||
<rect x="72" y="72" width="880" height="880" rx="200" fill="url(#glow)"/>
|
||||
<rect x="72" y="72" width="880" height="880" rx="200" fill="none" stroke="#2a3440" stroke-width="8"/>
|
||||
|
||||
<g stroke="#2c8f6c" stroke-width="48" stroke-linecap="round">
|
||||
<path d="M196 400 H286"/>
|
||||
<path d="M172 512 H274"/>
|
||||
<path d="M196 624 H286"/>
|
||||
</g>
|
||||
|
||||
<path d="M366 348 L462 676 L588 456 L714 676 L810 348" fill="none" stroke="#37b98a"
|
||||
stroke-width="82" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 639 B |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
// Render every icon format from resources/icon.svg.
|
||||
//
|
||||
// The point of this script is that the icon stays *editable*. Three committed binaries
|
||||
// with no way to regenerate them is how an icon becomes something nobody dares touch;
|
||||
// here the SVG is the source and everything else is output, so changing the mark is
|
||||
// changing one file and running this.
|
||||
//
|
||||
// npm run icons
|
||||
//
|
||||
// Needs `rsvg-convert` (brew install librsvg). The .icns additionally needs `iconutil`,
|
||||
// which only exists on macOS — on Linux that step is skipped with a warning, because CI
|
||||
// builds Linux and Windows there and the committed .icns is what a mac build uses.
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const RESOURCES = path.join(ROOT, 'resources')
|
||||
const SOURCE = path.join(RESOURCES, 'icon.svg')
|
||||
|
||||
/** Windows wants these; anything larger than 256 cannot go in an ICO as PNG anyway. */
|
||||
const ICO_SIZES = [16, 24, 32, 48, 64, 128, 256]
|
||||
|
||||
/** What macOS asks for in an iconset, with the @2x names it insists on. */
|
||||
const ICNS_ENTRIES = [
|
||||
[16, 'icon_16x16.png'], [32, 'icon_16x16@2x.png'],
|
||||
[32, 'icon_32x32.png'], [64, 'icon_32x32@2x.png'],
|
||||
[128, 'icon_128x128.png'], [256, 'icon_128x128@2x.png'],
|
||||
[256, 'icon_256x256.png'], [512, 'icon_256x256@2x.png'],
|
||||
[512, 'icon_512x512.png'], [1024, 'icon_512x512@2x.png']
|
||||
]
|
||||
|
||||
function render (size, target) {
|
||||
execFileSync('rsvg-convert', ['-w', String(size), '-h', String(size), SOURCE, '-o', target])
|
||||
}
|
||||
|
||||
/**
|
||||
* An ICO holding PNGs.
|
||||
*
|
||||
* The format allows it since Vista and every tool this project's packages reach has
|
||||
* supported it for longer than that. Writing the container by hand is a few lines and
|
||||
* saves a dependency on ImageMagick, which is not installed here and is not worth
|
||||
* making a build requirement for 22 bytes of header per image.
|
||||
*/
|
||||
function writeIco (pngs, target) {
|
||||
const header = Buffer.alloc(6)
|
||||
header.writeUInt16LE(0, 0)
|
||||
header.writeUInt16LE(1, 2) // 1 = icon
|
||||
header.writeUInt16LE(pngs.length, 4)
|
||||
|
||||
const directory = Buffer.alloc(16 * pngs.length)
|
||||
let offset = header.length + directory.length
|
||||
|
||||
pngs.forEach(({ size, data }, index) => {
|
||||
const at = index * 16
|
||||
// 0 means 256 in this field, which is the whole reason 256 is the largest size here.
|
||||
directory.writeUInt8(size >= 256 ? 0 : size, at)
|
||||
directory.writeUInt8(size >= 256 ? 0 : size, at + 1)
|
||||
directory.writeUInt8(0, at + 2) // palette: none
|
||||
directory.writeUInt8(0, at + 3) // reserved
|
||||
directory.writeUInt16LE(1, at + 4) // colour planes
|
||||
directory.writeUInt16LE(32, at + 6) // bits per pixel
|
||||
directory.writeUInt32LE(data.length, at + 8)
|
||||
directory.writeUInt32LE(offset, at + 12)
|
||||
offset += data.length
|
||||
})
|
||||
|
||||
fs.writeFileSync(target, Buffer.concat([header, directory, ...pngs.map((p) => p.data)]))
|
||||
}
|
||||
|
||||
function buildIco () {
|
||||
const temporary = fs.mkdtempSync(path.join(RESOURCES, '.ico-'))
|
||||
try {
|
||||
const pngs = ICO_SIZES.map((size) => {
|
||||
const file = path.join(temporary, `${size}.png`)
|
||||
render(size, file)
|
||||
return { size, data: fs.readFileSync(file) }
|
||||
})
|
||||
writeIco(pngs, path.join(RESOURCES, 'icon.ico'))
|
||||
console.log(` icon.ico ${ICO_SIZES.join(', ')}`)
|
||||
} finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function buildIcns () {
|
||||
const iconset = path.join(RESOURCES, 'icon.iconset')
|
||||
fs.rmSync(iconset, { recursive: true, force: true })
|
||||
fs.mkdirSync(iconset)
|
||||
try {
|
||||
for (const [size, name] of ICNS_ENTRIES) render(size, path.join(iconset, name))
|
||||
execFileSync('iconutil', ['-c', 'icns', iconset, '-o', path.join(RESOURCES, 'icon.icns')])
|
||||
console.log(' icon.icns 16 … 512@2x')
|
||||
} finally {
|
||||
fs.rmSync(iconset, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Linux takes a directory of sizes; electron-builder reads whatever is in it.
|
||||
*
|
||||
* Named `<size>x<size>.png`, which is the convention it expects and also what a
|
||||
* `.desktop` entry's icon lookup walks.
|
||||
*/
|
||||
function buildLinuxIcons () {
|
||||
const directory = path.join(RESOURCES, 'icons')
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
fs.mkdirSync(directory)
|
||||
const sizes = [16, 32, 48, 64, 128, 256, 512, 1024]
|
||||
for (const size of sizes) render(size, path.join(directory, `${size}x${size}.png`))
|
||||
console.log(` icons/ ${sizes.join(', ')}`)
|
||||
}
|
||||
|
||||
function main () {
|
||||
if (!fs.existsSync(SOURCE)) {
|
||||
console.error(`no ${path.relative(ROOT, SOURCE)} — the icon source is missing`)
|
||||
process.exit(1)
|
||||
}
|
||||
try {
|
||||
execFileSync('rsvg-convert', ['--version'], { stdio: 'ignore' })
|
||||
} catch {
|
||||
console.error('rsvg-convert is not installed (brew install librsvg / apt install librsvg2-bin)')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('rendering icons from resources/icon.svg')
|
||||
render(1024, path.join(RESOURCES, 'icon.png'))
|
||||
console.log(' icon.png 1024')
|
||||
buildLinuxIcons()
|
||||
buildIco()
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
buildIcns()
|
||||
} else {
|
||||
// Not fatal: the committed .icns is what a mac build uses, and only a Mac can make
|
||||
// one. Saying so is better than a build that quietly ships the Electron default.
|
||||
console.warn(' icon.icns skipped — iconutil is macOS only; the committed one stands')
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -53,9 +53,10 @@ api() {
|
||||
curl -fsS -X "$method" -H "$AUTH" "$FORGE$path" "$@"
|
||||
}
|
||||
|
||||
# Package names contain spaces — "WarpEngine Client Setup 1.5.0.exe" does — so the list
|
||||
# lives one path per line in a file and is read with `while IFS= read -r`. A single
|
||||
# variable looped over with $list splits on the space and uploads nothing.
|
||||
# The list lives one path per line in a file and is read with `while IFS= read -r`. The
|
||||
# built package names have no spaces in them any more, but a path given on the command
|
||||
# line still can — and a single variable looped over with $list splits on the space and
|
||||
# uploads nothing, which is a silent way to publish a release with no assets.
|
||||
LIST="$(mktemp)"
|
||||
trap 'rm -f "$LIST"' EXIT
|
||||
if [ "$#" -gt 0 ]; then
|
||||
|
||||
@@ -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
|
||||
# and a release would quietly get the previous version's files attached;
|
||||
# - the spaces. "WarpEngine Client-1.5.0-arm64.dmg" has one, so the list lives one
|
||||
# path per line in a file and is read with `while IFS= read -r`. Holding it in
|
||||
# a single variable and looping over $list splits it on the space.
|
||||
# - the spaces. The built names have none since 2.3.0 — `WarpEngineClient-2.3.0-arm64.dmg`
|
||||
# — but a path given as an argument still can, so the list stays one path per line in
|
||||
# a file, read with `while IFS= read -r`. Holding it in a single variable and looping
|
||||
# over $list splits it on the space, and publishes nothing.
|
||||
LIST="$(mktemp)"
|
||||
trap 'rm -f "$LIST"' EXIT
|
||||
if [ "$#" -gt 0 ]; then
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readAccessVerdict, type CatalogPrice } from '../../domain/models/CatalogAccess'
|
||||
import type { Game } from '../../domain/models/Game'
|
||||
import type { GameDto } from '../../shared/contracts/dto/GameDto'
|
||||
|
||||
@@ -6,10 +7,11 @@ const ABSOLUTE_URL = /^https?:\/\//
|
||||
/**
|
||||
* A title as the window may see it.
|
||||
*
|
||||
* Two decisions live here rather than in the renderer: the box art is resolved
|
||||
* against the catalog's base URL, and whether a title can be launched is answered
|
||||
* here — so the window never receives a filesystem path it could be talked into
|
||||
* opening.
|
||||
* Three decisions live here rather than in the renderer: the box art is resolved
|
||||
* against the catalog's base URL, whether a title can be launched is answered here —
|
||||
* so the window never receives a filesystem path it could be talked into opening —
|
||||
* and the catalog's access block is reduced to a verdict and a printed price, because
|
||||
* a view that had to reason about entitlement is a view with a rule in it.
|
||||
*/
|
||||
export class GameDtoMapper {
|
||||
public toDto (game: Game, catalogBaseUrl: string): GameDto {
|
||||
@@ -29,7 +31,10 @@ export class GameDtoMapper {
|
||||
launchable: this.isLaunchable(game),
|
||||
installable: game.installable,
|
||||
unavailableReason: game.unavailableReason,
|
||||
unavailableDetail: game.unavailableDetail
|
||||
unavailableDetail: game.unavailableDetail,
|
||||
accessVerdict: readAccessVerdict(game.access),
|
||||
priceLabel: formatPrice(game.access?.price ?? null),
|
||||
purchaseUrl: game.access?.purchaseUrl ?? null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,3 +54,25 @@ export class GameDtoMapper {
|
||||
return game.menuEntryPath !== null || game.executablePath !== null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A price as a person reads it, in the currency the catalog named.
|
||||
*
|
||||
* `Intl` with the *catalog's* currency and the system locale: the store decides what it
|
||||
* charges in, the reader's machine decides where the symbol and the separators go.
|
||||
* There is no conversion here and there must not be — inventing an exchange rate would
|
||||
* be quoting a price nobody agreed to.
|
||||
*/
|
||||
function formatPrice (price: CatalogPrice | null): string | null {
|
||||
if (price === null) return null
|
||||
if (price.amountCents <= 0) return null
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency', currency: price.currency
|
||||
}).format(price.amountCents / 100)
|
||||
} catch {
|
||||
// An unknown currency code: better the number and the code than nothing at all.
|
||||
return `${(price.amountCents / 100).toFixed(2)} ${price.currency}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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 type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository'
|
||||
import type { StoreEngineInstaller } from '../../domain/ports/StoreEngineInstaller'
|
||||
import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
|
||||
import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository'
|
||||
import type { StoreSelectionService } from './StoreSelectionService'
|
||||
|
||||
@@ -19,7 +20,8 @@ export class StoreProvisioningService {
|
||||
private readonly registry: StoreRegistryRepository,
|
||||
private readonly installer: StoreEngineInstaller,
|
||||
private readonly stores: InstalledStoreRepository,
|
||||
private readonly selection: StoreSelectionService
|
||||
private readonly selection: StoreSelectionService,
|
||||
private readonly catalogGateway: StoreCatalogGateway
|
||||
) {}
|
||||
|
||||
public get registryUrl (): string {
|
||||
@@ -47,4 +49,79 @@ export class StoreProvisioningService {
|
||||
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
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const store = this.stores.findByHome(home)
|
||||
if (store === null) throw new StoreMissingError(home)
|
||||
@@ -55,6 +62,20 @@ export class StoreSelectionService {
|
||||
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 {
|
||||
return this.stores.readRoots()[0] ?? ''
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* What a catalog says about getting one title.
|
||||
*
|
||||
* The vocabulary is the engine's and deliberately generic — `gated`, `entitled`, a
|
||||
* price. One client reads many catalogs, so a field named after what a particular shop
|
||||
* calls the thing it sells is a field that works in exactly one shop.
|
||||
*
|
||||
* Absent (`null` where this appears) is its own answer: an engine too old to have an
|
||||
* opinion. That is not the same as "not gated", and only one of the two is a reason to
|
||||
* offer somebody a sign-in.
|
||||
*/
|
||||
export interface CatalogAccess {
|
||||
/** Downloading needs an entitlement. */
|
||||
readonly gated: boolean
|
||||
/** For the signed-in caller; null when nobody was signed in to ask about. */
|
||||
readonly entitled: boolean | null
|
||||
readonly price: CatalogPrice | null
|
||||
/** Where a person goes to get it. Absolute — it opens in their own browser. */
|
||||
readonly purchaseUrl: string | null
|
||||
/** Where a hosted build is played, when the catalog serves it somewhere of its own. */
|
||||
readonly webUrl: string | null
|
||||
}
|
||||
|
||||
export interface CatalogPrice {
|
||||
readonly amountCents: number
|
||||
readonly currency: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this caller install this title?
|
||||
*
|
||||
* Three answers, because the middle one is real: yes; no, and here is where to buy it;
|
||||
* and "the catalog would tell you if you signed in". A client that collapsed the last
|
||||
* two would either hide a title somebody owns or offer to sell them one they have.
|
||||
*/
|
||||
export type AccessVerdict = 'open' | 'entitled' | 'purchasable' | 'signInRequired'
|
||||
|
||||
export function readAccessVerdict (access: CatalogAccess | null): AccessVerdict {
|
||||
if (access?.gated !== true) return 'open'
|
||||
if (access.entitled === true) return 'entitled'
|
||||
return access.entitled === false ? 'purchasable' : 'signInRequired'
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Game } from './Game'
|
||||
import type { StoreAccount } from './StoreAccount'
|
||||
import type { StorePaths } from './StorePaths'
|
||||
|
||||
/** One reading of a store's catalog. */
|
||||
@@ -6,4 +7,13 @@ export interface CatalogListing {
|
||||
readonly games: readonly Game[]
|
||||
readonly skipped: readonly string[]
|
||||
readonly paths: StorePaths | null
|
||||
/**
|
||||
* Where this machine stands with the store, as of this reading.
|
||||
*
|
||||
* Part of the listing rather than a call of its own because it is the same answer
|
||||
* from the same request: the catalog was fetched with whatever credential we hold,
|
||||
* and what it said about entitlements is only meaningful next to whether anybody was
|
||||
* signed in when it said it.
|
||||
*/
|
||||
readonly account: StoreAccount
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { CatalogAccess } from './CatalogAccess'
|
||||
|
||||
/** How a title runs: unpacked on this machine, or served as a web build. */
|
||||
export type GameMode = 'app' | 'web'
|
||||
|
||||
@@ -41,4 +43,12 @@ export interface Game {
|
||||
readonly unavailableReason: UnavailableReason | null
|
||||
/** The engine's sentence for it, for a tooltip or the log. */
|
||||
readonly unavailableDetail: string | null
|
||||
/**
|
||||
* What the catalog says about getting it, or null where it said nothing.
|
||||
*
|
||||
* Kept separate from `installable`: that one is about this machine — no build for
|
||||
* this architecture — and this one is about this person. A title can be perfectly
|
||||
* installable and still not yours.
|
||||
*/
|
||||
readonly access: CatalogAccess | null
|
||||
}
|
||||
|
||||
@@ -6,8 +6,13 @@ import type { SelectedGame } from './SelectedGame'
|
||||
* This is the shape `state.json` carries, keyed `<scope>:<name>`. Every path in it
|
||||
* is something the store put there and may therefore delete — which is why an
|
||||
* uninstall reads the record rather than guessing at paths.
|
||||
*
|
||||
* The catalog's `access` block is deliberately *not* part of it. Whether somebody may
|
||||
* download a title is the server's answer to a question asked now; a copy of it on disk
|
||||
* would go stale the moment a purchase or a refund happened, and a stale "yes" is the
|
||||
* dangerous direction. What is installed stays installed either way.
|
||||
*/
|
||||
export interface InstalledRecord extends SelectedGame {
|
||||
export interface InstalledRecord extends Omit<SelectedGame, 'access'> {
|
||||
/** The unpacked archive's directory; null for a hosted entry, which has none. */
|
||||
readonly payload: string | null
|
||||
readonly executable: string | null
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CatalogAccess } from './CatalogAccess'
|
||||
import type { UnavailableReason } from './Game'
|
||||
|
||||
/**
|
||||
@@ -26,6 +27,8 @@ export interface SelectedGame {
|
||||
readonly createdAt: string | null
|
||||
/** `app` for a native archive, `web` for a hosted page. */
|
||||
readonly mode: string
|
||||
/** What the catalog says about getting it; null from an engine that cannot say. */
|
||||
readonly access: CatalogAccess | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +50,8 @@ export interface UnavailableEntry {
|
||||
readonly reason: UnavailableReason
|
||||
/** The sentence behind the code, for a tooltip or the log. */
|
||||
readonly detail: string
|
||||
/** Carried here too: a title with no build for this machine can still have a price. */
|
||||
readonly access: CatalogAccess | null
|
||||
}
|
||||
|
||||
/** What a survey of the catalog found: installable, why not, and what was skipped. */
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* What one catalog's server says about itself.
|
||||
*
|
||||
* This is how the client stops being built for a particular store. Whether there is a
|
||||
* sign-in here, where it lives, whether any title can be gated — all of it used to be
|
||||
* knowledge the client would have had to carry, and a client that carries it works for
|
||||
* exactly one catalog. Now the server answers, and the same binary serves any of them.
|
||||
*
|
||||
* Every field is optional in practice: an engine older than 0.5 has no descriptor at
|
||||
* all, and `DEFAULT_SERVICE_DESCRIPTOR` is what that means — a plain catalog, nothing
|
||||
* gated, nobody to sign in as. That is what this client always assumed.
|
||||
*/
|
||||
export interface ServiceDescriptor {
|
||||
readonly engineVersion: string | null
|
||||
/** Whether any title in this catalog can require an entitlement. */
|
||||
readonly catalogGated: boolean
|
||||
/** Null where the server offers no sign-in, which is most of them. */
|
||||
readonly auth: AuthDescriptor | null
|
||||
}
|
||||
|
||||
export interface AuthDescriptor {
|
||||
/** The device authorization grant, for a client with no browser of its own. */
|
||||
readonly device: DeviceAuthDescriptor
|
||||
}
|
||||
|
||||
export interface DeviceAuthDescriptor {
|
||||
/** Where to ask for a code pair. */
|
||||
readonly authorizeUrl: string
|
||||
/** Where to poll for the token. */
|
||||
readonly tokenUrl: string
|
||||
/** Where to throw the token away again. */
|
||||
readonly revokeUrl: string | null
|
||||
/** Where a person takes the code, opened in their own browser. */
|
||||
readonly verificationUrl: string
|
||||
/** Seconds the server asks the client to wait between polls. */
|
||||
readonly interval: number
|
||||
}
|
||||
|
||||
export const DEFAULT_SERVICE_DESCRIPTOR: ServiceDescriptor = {
|
||||
engineVersion: null,
|
||||
catalogGated: false,
|
||||
auth: null
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Whether this machine is signed in to one store, and whether it could be.
|
||||
*
|
||||
* Two booleans rather than one, because the interesting case is the first being false:
|
||||
* most catalogs have no sign-in at all, and a client that shows a greyed-out "Sign in"
|
||||
* on them is telling people about a door that does not exist.
|
||||
*/
|
||||
export interface StoreAccount {
|
||||
readonly signInAvailable: boolean
|
||||
readonly signedIn: boolean
|
||||
}
|
||||
|
||||
export const NO_ACCOUNT: StoreAccount = { signInAvailable: false, signedIn: false }
|
||||
|
||||
/** What to show a person while they finish signing in somewhere else. */
|
||||
export interface SignInPrompt {
|
||||
/** Opaque to the window: it is the client's half of the exchange, not the person's. */
|
||||
readonly deviceCode: string
|
||||
/** The short one, shown on screen and typed into a browser. */
|
||||
readonly userCode: string
|
||||
/** Opened in the person's own browser. */
|
||||
readonly verificationUrl: string
|
||||
readonly intervalSeconds: number
|
||||
readonly expiresInSeconds: number
|
||||
}
|
||||
|
||||
/** How a sign-in ended. `cancelled` is this side giving up, `denied` is the person. */
|
||||
export type SignInOutcome = 'signedIn' | 'denied' | 'expired' | 'cancelled'
|
||||
@@ -18,7 +18,7 @@ export const WARP_ENGINE_VERSION_HEADER = 'warpengine-version'
|
||||
* compile error until `selectCatalogDialect` says which dialect it gets, which is the
|
||||
* point — a new engine version should not be able to arrive silently.
|
||||
*/
|
||||
export const SUPPORTED_WARP_ENGINE_VERSIONS = ['0.2', '0.3', '0.4'] 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]
|
||||
|
||||
|
||||
@@ -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. */
|
||||
readRoots: () => readonly 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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CatalogListing } from '../models/CatalogListing'
|
||||
import type { EngineProgressListener } from '../models/EngineProgress'
|
||||
import type { InstalledStore } from '../models/InstalledStore'
|
||||
import type { SignInPrompt, StoreAccount } from '../models/StoreAccount'
|
||||
import type { StorePaths } from '../models/StorePaths'
|
||||
|
||||
/**
|
||||
@@ -15,4 +16,29 @@ export interface StoreCatalogGateway {
|
||||
readPaths: (store: InstalledStore, progress?: EngineProgressListener) => Promise<StorePaths>
|
||||
syncGames: (store: InstalledStore, names: readonly string[], progress?: EngineProgressListener) => Promise<void>
|
||||
removeGame: (store: InstalledStore, name: string, progress?: EngineProgressListener) => Promise<void>
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
@@ -41,15 +41,29 @@ export class CatalogClient {
|
||||
private readonly configuration: StoreConfiguration,
|
||||
private readonly files: StoreFileSystem,
|
||||
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({
|
||||
userAgent: `warp-engine-client/${CLIENT_VERSION} (${configuration.store.id})`,
|
||||
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 {
|
||||
const { baseUrl, api } = this.configuration.store
|
||||
const url = `${baseUrl}/${api[endpoint].replace(/^\/+/, '')}`
|
||||
|
||||
@@ -134,7 +134,8 @@ export class CatalogSurveyor {
|
||||
author: software.author,
|
||||
imageUrl: software.imageUrl,
|
||||
createdAt: release.createdAt,
|
||||
mode
|
||||
mode,
|
||||
access: entry.access
|
||||
})
|
||||
}
|
||||
return { games, reasons, unavailable }
|
||||
@@ -172,6 +173,7 @@ function toUnavailable (
|
||||
): UnavailableEntry {
|
||||
const software: CatalogSoftware = entry.software
|
||||
return {
|
||||
access: entry.access,
|
||||
name: software.name,
|
||||
title: software.title,
|
||||
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 {
|
||||
CatalogSurvey, SelectedGame, UnavailableEntry
|
||||
} from '../../domain/models/SelectedGame'
|
||||
import type { ServiceDescriptor } from '../../domain/models/ServiceDescriptor'
|
||||
import type { SignInPrompt, StoreAccount } from '../../domain/models/StoreAccount'
|
||||
import { APP_MODE, WEB_MODE, type StoreConfiguration } from '../../domain/models/StoreConfiguration'
|
||||
import type { StorePaths } from '../../domain/models/StorePaths'
|
||||
import type { 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 { CatalogClient, type FetchedCatalog } from './CatalogClient'
|
||||
import { CatalogSurveyor } from './CatalogSurveyor'
|
||||
import type { CatalogEntry } from './dialects/CatalogDialect'
|
||||
import { selectCatalogDialect } from './dialects/CatalogDialectSelector'
|
||||
import { DesktopLayoutResolver } from './DesktopLayoutResolver'
|
||||
import { DeviceSignInClient } from './DeviceSignInClient'
|
||||
import { GameInstaller } from './GameInstaller'
|
||||
import { HostMachineDetector } from './HostMachineDetector'
|
||||
import { LauncherWriter } from './launchers/LauncherWriter'
|
||||
import { PayloadInstaller } from './PayloadInstaller'
|
||||
import { ServiceDescriptorClient } from './ServiceDescriptorClient'
|
||||
import { StoreConfigurationReader } from './StoreConfigurationReader'
|
||||
import { StoreStateRepository } from './StoreStateRepository'
|
||||
|
||||
@@ -45,6 +51,15 @@ const CATALOG_CACHE_FILE_NAME = 'catalog.json'
|
||||
export class NativeStoreCatalogGateway implements StoreCatalogGateway {
|
||||
private readonly hosts = new HostMachineDetector()
|
||||
|
||||
/**
|
||||
* The credentials are injected because they are the host's to keep: on a desktop the
|
||||
* OS keychain, in the smoke test a map in memory. Nothing here knows which.
|
||||
*/
|
||||
public constructor (
|
||||
private readonly credentials: CredentialRepository = NO_CREDENTIALS,
|
||||
private readonly stores: InstalledStoreRepository = NO_STORES
|
||||
) {}
|
||||
|
||||
public async listGames (
|
||||
store: InstalledStore,
|
||||
progress: EngineProgressListener = {}
|
||||
@@ -53,7 +68,10 @@ export class NativeStoreCatalogGateway implements StoreCatalogGateway {
|
||||
const host = this.hosts.findHost()
|
||||
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()
|
||||
|
||||
// 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 =>
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -157,7 +274,8 @@ export class NativeStoreCatalogGateway implements StoreCatalogGateway {
|
||||
const layouts = new DesktopLayoutResolver(configuration, this.hosts)
|
||||
const layout = layouts.resolveLayout()
|
||||
const catalog = new CatalogClient(
|
||||
configuration, files, path.join(store.home, CATALOG_CACHE_FILE_NAME), log)
|
||||
configuration, files, path.join(store.home, CATALOG_CACHE_FILE_NAME), log,
|
||||
(): string | null => this.credentials.readToken(store.id))
|
||||
const launchers = new LauncherWriter(configuration, layouts, files, log)
|
||||
|
||||
return {
|
||||
@@ -167,6 +285,7 @@ export class NativeStoreCatalogGateway implements StoreCatalogGateway {
|
||||
catalog,
|
||||
launchers,
|
||||
log,
|
||||
service: new ServiceDescriptorClient(catalog.httpClient(), configuration.store.baseUrl, log),
|
||||
surveyor: new CatalogSurveyor(configuration, log),
|
||||
state: new StoreStateRepository(files, path.join(store.home, STATE_FILE_NAME), log),
|
||||
installer: new GameInstaller(
|
||||
@@ -197,10 +316,16 @@ export class NativeStoreCatalogGateway implements StoreCatalogGateway {
|
||||
installedVersion: record?.version ?? null,
|
||||
menuEntryPath: record?.menuEntry ?? 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,
|
||||
unavailableReason: null,
|
||||
unavailableDetail: null
|
||||
unavailableDetail: null,
|
||||
access: game.access
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +351,7 @@ interface StoreEngineContext {
|
||||
readonly layout: DesktopLayout
|
||||
readonly layouts: DesktopLayoutResolver
|
||||
readonly catalog: CatalogClient
|
||||
readonly service: ServiceDescriptorClient
|
||||
readonly launchers: LauncherWriter
|
||||
readonly surveyor: CatalogSurveyor
|
||||
readonly state: StoreStateRepository
|
||||
@@ -258,10 +384,57 @@ function toUnavailableGame (entry: UnavailableEntry): Game {
|
||||
hostedUrl: null,
|
||||
installable: false,
|
||||
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 {
|
||||
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'
|
||||
|
||||
/**
|
||||
@@ -17,6 +18,15 @@ export interface CatalogDialect {
|
||||
|
||||
export interface CatalogEntry {
|
||||
readonly software: CatalogSoftware
|
||||
/**
|
||||
* What the catalog says about getting this title, or null where it says nothing.
|
||||
*
|
||||
* Null is not "free": it is an engine too old to have an opinion, and a store that
|
||||
* never gated anything reads the same as one that could not say. Both mean the same
|
||||
* thing in practice — try the download — but only one of them is worth offering a
|
||||
* sign-in for.
|
||||
*/
|
||||
readonly access: CatalogAccess | null
|
||||
/**
|
||||
* The release the catalog itself calls newest-and-stable, or null when it names none.
|
||||
*
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngineVersion'
|
||||
import { AccessAwareCatalogDialect } from './AccessAwareCatalogDialect'
|
||||
import type { CatalogDialect } from './CatalogDialect'
|
||||
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
|
||||
* 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
|
||||
* shape has not changed across them — and one class serving three versions is the
|
||||
* honest way to say that, rather than three identical ones pretending otherwise.
|
||||
* what it reads like. Three versions share one dialect because the catalog's shape did
|
||||
* not change across them — and one class serving three versions is the honest way to
|
||||
* say that, rather than three identical ones pretending otherwise.
|
||||
*
|
||||
* 0.5 gets its own, because that is the engine that started saying what a title costs.
|
||||
*/
|
||||
export function selectCatalogDialect (version: SupportedWarpEngineVersion): CatalogDialect {
|
||||
switch (version) {
|
||||
@@ -17,5 +20,7 @@ export function selectCatalogDialect (version: SupportedWarpEngineVersion): Cata
|
||||
case '0.3':
|
||||
case '0.4':
|
||||
return new SoftwareListCatalogDialect(version)
|
||||
case '0.5':
|
||||
return new AccessAwareCatalogDialect(version)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngi
|
||||
import {
|
||||
asRecord, readOptionalString, readRecord, readString, type JsonRecord
|
||||
} from '../../json/JsonRecord'
|
||||
import type { CatalogAccess } from '../../../domain/models/CatalogAccess'
|
||||
import type {
|
||||
CatalogAsset, CatalogDialect, CatalogEntry, CatalogRelease, CatalogSoftware
|
||||
} from './CatalogDialect'
|
||||
@@ -35,6 +36,7 @@ export class SoftwareListCatalogDialect implements CatalogDialect {
|
||||
if (software === null) continue
|
||||
found.push({
|
||||
software,
|
||||
access: this.readAccess(entry),
|
||||
latestRelease: this.readLatestRelease(entry),
|
||||
releaseCandidates: this.readCandidates(entry)
|
||||
})
|
||||
@@ -42,8 +44,20 @@ export class SoftwareListCatalogDialect implements CatalogDialect {
|
||||
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. */
|
||||
private readSoftware (entry: JsonRecord): CatalogSoftware | null {
|
||||
protected readSoftware (entry: JsonRecord): CatalogSoftware | null {
|
||||
const software = readRecord(entry, 'software')
|
||||
if (software === null) return null
|
||||
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')
|
||||
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
|
||||
* 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 latest = readRecord(entry, 'latestRelease')
|
||||
if (latest !== null) records.push(latest)
|
||||
@@ -93,7 +107,7 @@ export class SoftwareListCatalogDialect implements CatalogDialect {
|
||||
return candidates
|
||||
}
|
||||
|
||||
private readRelease (release: JsonRecord): CatalogRelease {
|
||||
protected readRelease (release: JsonRecord): CatalogRelease {
|
||||
const assets: CatalogAsset[] = []
|
||||
const listed = release['assets']
|
||||
if (Array.isArray(listed)) {
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface HttpResponseBody {
|
||||
readonly contentType: string
|
||||
/** Lower-cased names, as Node delivers them. The engine version arrives in one. */
|
||||
readonly headers: Readonly<Record<string, string>>
|
||||
readonly statusCode: number
|
||||
}
|
||||
|
||||
export interface StoreHttpOptions {
|
||||
@@ -19,6 +20,22 @@ export interface StoreHttpOptions {
|
||||
/** Seconds, as the store config states it. */
|
||||
readonly timeout: number
|
||||
readonly insecure: boolean
|
||||
/**
|
||||
* The bearer token to send, asked for per request.
|
||||
*
|
||||
* A function rather than a value because the token changes under a long-lived
|
||||
* client — signing in and out do not rebuild it — and because there is no reason
|
||||
* to hold the secret in a field that outlives the request that needs it.
|
||||
*/
|
||||
readonly bearerToken?: () => string | null
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
readonly method?: string
|
||||
readonly body?: string
|
||||
readonly contentType?: string
|
||||
/** Statuses to hand back rather than throw on. */
|
||||
readonly accept?: readonly number[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,8 +50,8 @@ export interface StoreHttpOptions {
|
||||
export class StoreHttpClient {
|
||||
public constructor (private readonly options: StoreHttpOptions) {}
|
||||
|
||||
public async readBytes (url: string): Promise<HttpResponseBody> {
|
||||
return await this.request(url, MAX_REDIRECTS, async (
|
||||
public async readBytes (url: string, request: RequestOptions = {}): Promise<HttpResponseBody> {
|
||||
return await this.request(url, MAX_REDIRECTS, request, async (
|
||||
response: http.IncomingMessage
|
||||
): Promise<HttpResponseBody> => {
|
||||
const chunks: Buffer[] = []
|
||||
@@ -42,11 +59,31 @@ export class StoreHttpClient {
|
||||
return {
|
||||
body: Buffer.concat(chunks),
|
||||
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.
|
||||
*
|
||||
@@ -60,7 +97,7 @@ export class StoreHttpClient {
|
||||
let written = 0
|
||||
|
||||
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 })
|
||||
await pipeline(response, fs.createWriteStream(partial))
|
||||
})
|
||||
@@ -76,42 +113,93 @@ export class StoreHttpClient {
|
||||
private async request<TResult> (
|
||||
url: string,
|
||||
redirectsLeft: number,
|
||||
consume: (response: http.IncomingMessage) => Promise<TResult>
|
||||
request: RequestOptions,
|
||||
consume: (response: http.IncomingMessage) => Promise<TResult>,
|
||||
origin: string = originOf(url)
|
||||
): Promise<TResult> {
|
||||
const response = await this.open(url)
|
||||
const response = await this.open(url, request, origin)
|
||||
const status = response.statusCode ?? 0
|
||||
const location = response.headers.location
|
||||
|
||||
if (status >= 300 && status < 400 && location !== undefined) {
|
||||
response.resume()
|
||||
if (redirectsLeft <= 0) throw new Error(`too many redirects for ${url}`)
|
||||
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()
|
||||
throw new HttpStatusError(url, status)
|
||||
}
|
||||
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>((
|
||||
resolve: (response: http.IncomingMessage) => void,
|
||||
reject: (error: Error) => void
|
||||
): void => {
|
||||
const secure = !url.startsWith('http://')
|
||||
const client = secure ? https : http
|
||||
const request = client.get(url, {
|
||||
headers: { 'User-Agent': this.options.userAgent },
|
||||
const outgoing = client.request(url, {
|
||||
method: request.method ?? 'GET',
|
||||
headers: this.buildHeaders(url, request, origin),
|
||||
...(secure && this.options.insecure ? { rejectUnauthorized: false } : {})
|
||||
}, resolve)
|
||||
|
||||
request.setTimeout(Math.max(1, this.options.timeout) * 1000, (): void => {
|
||||
request.destroy(new Error(`${url} timed out`))
|
||||
outgoing.setTimeout(Math.max(1, this.options.timeout) * 1000, (): void => {
|
||||
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.
|
||||
*
|
||||
* The roots are searched in the shell installers' own order — those homes are still
|
||||
* valid stores — and `STORE_ROOT` comes first so a sandbox can be driven without
|
||||
* touching a working installation, which is how this repository is tested.
|
||||
* valid stores. `STORE_ROOT` replaces the lot: a sandbox has to be a sandbox, and it
|
||||
* 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 {
|
||||
public findAll (): readonly InstalledStore[] {
|
||||
@@ -34,10 +36,11 @@ export class FileSystemInstalledStoreRepository implements InstalledStoreReposit
|
||||
}
|
||||
|
||||
public readRoots (): readonly string[] {
|
||||
const override = process.env['STORE_ROOT']
|
||||
if (override !== undefined && override.length > 0) return [override]
|
||||
|
||||
const home = os.homedir()
|
||||
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']
|
||||
if (xdgDataHome !== undefined && xdgDataHome.length > 0) {
|
||||
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}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] {
|
||||
try {
|
||||
return fs.readdirSync(root, { withFileTypes: true })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from 'node:path'
|
||||
import { BrowserWindow, shell, type BrowserWindowConstructorOptions } from 'electron'
|
||||
import { app, BrowserWindow, shell, type BrowserWindowConstructorOptions } from 'electron'
|
||||
|
||||
const WINDOW_OPTIONS: BrowserWindowConstructorOptions = {
|
||||
width: 1040,
|
||||
@@ -10,6 +10,22 @@ const WINDOW_OPTIONS: BrowserWindowConstructorOptions = {
|
||||
title: 'WarpEngine Client'
|
||||
}
|
||||
|
||||
/**
|
||||
* The window icon, in development only.
|
||||
*
|
||||
* A packaged app carries its icon in the bundle, the .exe and the .desktop entry, and
|
||||
* `resources/` is not inside the package at all — pointing at it there would be a path
|
||||
* that does not exist. Run from source there is nothing to carry the icon, so the
|
||||
* window and the taskbar show Electron's own, which makes a dev run look like a
|
||||
* different application from the one being built. macOS ignores this either way: the
|
||||
* Dock icon comes from the bundle.
|
||||
*/
|
||||
function developmentIcon (): Pick<BrowserWindowConstructorOptions, 'icon'> {
|
||||
if (app.isPackaged) return {}
|
||||
|
||||
return { icon: path.join(__dirname, '..', '..', 'resources', 'icons', '512x512.png') }
|
||||
}
|
||||
|
||||
/**
|
||||
* The one window.
|
||||
*
|
||||
@@ -23,6 +39,7 @@ export class MainWindowFactory {
|
||||
public createWindow (): BrowserWindow {
|
||||
const window = new BrowserWindow({
|
||||
...WINDOW_OPTIONS,
|
||||
...developmentIcon(),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '..', 'preload', 'preload.js'),
|
||||
contextIsolation: true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { App, IpcMain, Shell } from 'electron'
|
||||
import { AccountService } from '../../application/services/AccountService'
|
||||
import { ApplicationStateService } from '../../application/services/ApplicationStateService'
|
||||
import { CatalogService } from '../../application/services/CatalogService'
|
||||
import { GameLaunchService } from '../../application/services/GameLaunchService'
|
||||
@@ -7,12 +8,14 @@ import { StoreProvisioningService } from '../../application/services/StoreProvis
|
||||
import { StoreSelectionService } from '../../application/services/StoreSelectionService'
|
||||
import { ElectronApplicationEnvironment } from '../../infrastructure/electron/ElectronApplicationEnvironment'
|
||||
import { ElectronGameLauncher } from '../../infrastructure/electron/ElectronGameLauncher'
|
||||
import { SafeStorageCredentialRepository } from '../../infrastructure/electron/SafeStorageCredentialRepository'
|
||||
import { NativeStoreCatalogGateway } from '../../infrastructure/engine/NativeStoreCatalogGateway'
|
||||
import { HttpTextClient } from '../../infrastructure/http/HttpTextClient'
|
||||
import { FileSystemInstalledStoreRepository } from '../../infrastructure/repositories/FileSystemInstalledStoreRepository'
|
||||
import { NativeStoreEngineInstaller } from '../../infrastructure/repositories/NativeStoreEngineInstaller'
|
||||
import { HttpStoreRegistryRepository } from '../../infrastructure/repositories/HttpStoreRegistryRepository'
|
||||
import { JsonFilePreferencesRepository } from '../../infrastructure/repositories/JsonFilePreferencesRepository'
|
||||
import { AccountIpcController } from '../ipc/AccountIpcController'
|
||||
import { AppIpcController } from '../ipc/AppIpcController'
|
||||
import { CatalogIpcController } from '../ipc/CatalogIpcController'
|
||||
import { IpcRouter } from '../ipc/IpcRouter'
|
||||
@@ -35,6 +38,7 @@ export class ServiceContainer {
|
||||
public readonly provisioning: StoreProvisioningService
|
||||
public readonly state: ApplicationStateService
|
||||
public readonly launching: GameLaunchService
|
||||
public readonly accounts: AccountService
|
||||
|
||||
private readonly controllers: readonly { register: (router: IpcRouter) => void }[]
|
||||
|
||||
@@ -46,7 +50,8 @@ export class ServiceContainer {
|
||||
const httpClient = new HttpTextClient()
|
||||
|
||||
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 installer = new NativeStoreEngineInstaller()
|
||||
const preferencesRepository = new JsonFilePreferencesRepository(environment)
|
||||
@@ -54,7 +59,10 @@ export class ServiceContainer {
|
||||
const preferences = new PreferencesService(preferencesRepository, environment)
|
||||
this.selection = new StoreSelectionService(stores, preferences)
|
||||
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.state = new ApplicationStateService(
|
||||
preferences, this.selection, this.provisioning, environment
|
||||
@@ -63,7 +71,8 @@ export class ServiceContainer {
|
||||
this.controllers = [
|
||||
new AppIpcController(this.state, preferences, this.launching),
|
||||
new CatalogIpcController(this.catalog, this.launching, this.guard, this.streams),
|
||||
new StoreIpcController(this.provisioning, this.selection, this.guard, this.streams)
|
||||
new StoreIpcController(this.provisioning, this.selection, this.guard, this.streams),
|
||||
new AccountIpcController(this.accounts, this.streams)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,20 @@ import {
|
||||
asRecord, readBoolean, readNumber, readOptionalString, readString, readStringArray
|
||||
} 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 SHOT_FRAME_DELAY_MS = 400
|
||||
|
||||
@@ -28,6 +41,8 @@ interface SelfTestReport {
|
||||
readonly locales: readonly string[]
|
||||
/** `<accessible name>:<glyph count>` per icon-only control in the footer. */
|
||||
readonly iconControls: readonly string[]
|
||||
/** `<title> [current|newer] Upgrade:on|off Uninstall:on|off` per installed card. */
|
||||
readonly cardMenus: readonly string[]
|
||||
}
|
||||
|
||||
/** What changed after clicking a store that was not open. */
|
||||
@@ -53,12 +68,14 @@ export class SelfTestRunner {
|
||||
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 {
|
||||
return SETTLE_DELAY_MS
|
||||
}
|
||||
|
||||
/** True when the window is in a state a user could work with. */
|
||||
public async run (): Promise<boolean> {
|
||||
await this.awaitSettled()
|
||||
const report = await this.readReport()
|
||||
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
|
||||
// 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.
|
||||
// Both footer icons must be present, named and drawn: refresh and the language
|
||||
// picker are the only way to reach those two actions now that neither has a label.
|
||||
const iconsNamed = report.iconControls.length === 2 &&
|
||||
// Every footer icon must be named and drawn. There are three — refresh, add a
|
||||
// store, and the language picker — and none of them has a label, so an unnamed one
|
||||
// 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))
|
||||
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.categories.length > 0 && report.activeCategory !== null) ||
|
||||
(report.gateVisible && report.gateAction.length > 0))
|
||||
@@ -87,6 +111,27 @@ export class SelfTestRunner {
|
||||
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> {
|
||||
const record = asRecord(JSON.parse(await this.evaluate(`JSON.stringify({
|
||||
cards: document.querySelectorAll('.card').length,
|
||||
@@ -107,6 +152,16 @@ export class SelfTestRunner {
|
||||
locales: [...document.getElementById('locale').options].map((option) => option.value),
|
||||
// The two icon-only controls: a glyph with no accessible name is a button nobody
|
||||
// can identify, and the failure is silent because the icon still draws.
|
||||
// One entry per installed card: its title, whether the version line shows an
|
||||
// upgrade, and the menu's two items with their disabled state. This is the only
|
||||
// way to see that Upgrade is offered exactly when there is something newer —
|
||||
// a screenshot shows a closed menu.
|
||||
cardMenus: [...document.querySelectorAll('.card.is-installed')].map((card) => {
|
||||
const items = [...card.querySelectorAll('.menu-item')]
|
||||
.map((item) => item.textContent + (item.disabled ? ':off' : ':on'))
|
||||
const arrow = card.querySelector('.version.has-update') === null ? 'current' : 'newer'
|
||||
return (card.querySelector('h2') || {}).textContent + ' [' + arrow + '] ' + items.join(' ')
|
||||
}),
|
||||
iconControls: [...document.querySelectorAll('.side-tools .icon-btn')]
|
||||
.map((control) => {
|
||||
const named = control.getAttribute('aria-label') || control.getAttribute('title') ||
|
||||
@@ -132,7 +187,8 @@ export class SelfTestRunner {
|
||||
paths: readString(record, 'paths'),
|
||||
logLines: readNumber(record, 'logLines'),
|
||||
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> {
|
||||
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')]
|
||||
.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)}))
|
||||
return JSON.stringify({
|
||||
storeId: document.getElementById('store-id').textContent,
|
||||
@@ -189,3 +248,9 @@ export class SelfTestRunner {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import os from 'node:os'
|
||||
import type { AccountService, SignInResult } from '../../application/services/AccountService'
|
||||
import type { StoreAccount } from '../../domain/models/StoreAccount'
|
||||
import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels'
|
||||
import type { AccountDto, SignInPromptDto } from '../../shared/contracts/dto/AccountDto'
|
||||
import type { WindowStreamBroadcaster } from '../streams/WindowStreamBroadcaster'
|
||||
import type { IpcRouter } from './IpcRouter'
|
||||
|
||||
/**
|
||||
* Signing in and out.
|
||||
*
|
||||
* Deliberately outside the single-flight guard: signing in takes as long as somebody
|
||||
* takes to find their browser, and holding the store busy for that would stop them
|
||||
* doing anything else meanwhile. Nothing here writes to the library.
|
||||
*
|
||||
* `beginSignIn` answers with the code as soon as there is one and lets the waiting run
|
||||
* on; the end arrives on the sign-in stream. A reply that only came back minutes later
|
||||
* would be a request the window had to keep alive for no reason.
|
||||
*/
|
||||
export class AccountIpcController {
|
||||
public constructor (
|
||||
private readonly accounts: AccountService,
|
||||
private readonly streams: WindowStreamBroadcaster
|
||||
) {}
|
||||
|
||||
public register (router: IpcRouter): void {
|
||||
router.handle(IPC_CHANNELS.accountRead, async (): Promise<AccountDto> =>
|
||||
toDto(await this.accounts.readAccount()))
|
||||
router.handle(IPC_CHANNELS.accountBeginSignIn, async (): Promise<SignInPromptDto> =>
|
||||
this.handleBeginSignIn())
|
||||
router.handle(IPC_CHANNELS.accountCancelSignIn, (): Promise<void> => {
|
||||
this.accounts.cancelSignIn()
|
||||
return Promise.resolve()
|
||||
})
|
||||
router.handle(IPC_CHANNELS.accountSignOut, async (): Promise<AccountDto> =>
|
||||
toDto(await this.accounts.signOut()))
|
||||
}
|
||||
|
||||
private async handleBeginSignIn (): Promise<SignInPromptDto> {
|
||||
const session = await this.accounts.beginSignIn(clientName())
|
||||
|
||||
session.finished.then((result: SignInResult): void => {
|
||||
this.streams.publishSignInFinished({
|
||||
outcome: result.outcome, account: toDto(result.account)
|
||||
})
|
||||
}, (error: unknown): void => {
|
||||
// A sign-in that fell over is a sign-in that did not happen; the window needs to
|
||||
// stop showing a code either way.
|
||||
this.streams.publishSignInFinished({
|
||||
outcome: 'expired', account: { signInAvailable: true, signedIn: false }
|
||||
})
|
||||
this.streams.publishLog(`sign-in failed: ${describe(error)}`)
|
||||
})
|
||||
|
||||
return {
|
||||
userCode: session.prompt.userCode,
|
||||
verificationUrl: session.prompt.verificationUrl,
|
||||
expiresInSeconds: session.prompt.expiresInSeconds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toDto (account: StoreAccount): AccountDto {
|
||||
return { signInAvailable: account.signInAvailable, signedIn: account.signedIn }
|
||||
}
|
||||
|
||||
/**
|
||||
* What this device calls itself on the person's account page.
|
||||
*
|
||||
* The machine's own name, because that is what somebody looking at a list of signed-in
|
||||
* devices needs in order to recognise which one to remove.
|
||||
*/
|
||||
function clientName (): string {
|
||||
const hostname = os.hostname()
|
||||
return hostname.length > 0 ? `WarpEngine Client (${hostname})` : 'WarpEngine Client'
|
||||
}
|
||||
|
||||
function describe (error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
@@ -46,7 +46,11 @@ export class CatalogIpcController {
|
||||
return {
|
||||
games: this.gameMapper.toDtoList(listing.games, baseUrl),
|
||||
skipped: listing.skipped,
|
||||
paths: listing.paths === null ? null : this.pathsMapper.toDto(listing.paths)
|
||||
paths: listing.paths === null ? null : this.pathsMapper.toDto(listing.paths),
|
||||
account: {
|
||||
signInAvailable: listing.account.signInAvailable,
|
||||
signedIn: listing.account.signedIn
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,8 +27,12 @@ export class StoreIpcController {
|
||||
this.handleListRegistry())
|
||||
router.handle(IPC_CHANNELS.storeInstallStore, async (store: unknown): Promise<InstalledStoreDto> =>
|
||||
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 =>
|
||||
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 {
|
||||
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()
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
|
||||
import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels'
|
||||
import type { SignInFinishedDto } from '../../shared/contracts/dto/AccountDto'
|
||||
import type { SyncEventDto } from '../../shared/contracts/dto/SyncEventDto'
|
||||
|
||||
/**
|
||||
* The three one-way streams to the window: log lines, progress events, busy state.
|
||||
* The one-way streams to the window: log lines, progress events, busy state, and how a
|
||||
* sign-in ended.
|
||||
*
|
||||
* Holds no window of its own — the reference is handed in when one exists and
|
||||
* cleared when it does not, so a stream that outlives the window is a no-op rather
|
||||
@@ -33,6 +35,14 @@ export class WindowStreamBroadcaster {
|
||||
this.send(IPC_CHANNELS.streamBusyChanged, busy)
|
||||
}
|
||||
|
||||
/**
|
||||
* A sign-in finishes minutes after the call that started it returned, and in another
|
||||
* window entirely — so it arrives as an event rather than as a reply.
|
||||
*/
|
||||
public publishSignInFinished (result: SignInFinishedDto): void {
|
||||
this.send(IPC_CHANNELS.streamSignInFinished, result)
|
||||
}
|
||||
|
||||
/** A progress listener wired to these streams, for handing to the engine. */
|
||||
public asProgressListener (): EngineProgressListener {
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,9 @@ import {
|
||||
BRIDGE_GLOBAL_NAME, type BridgeApi, type StreamListener
|
||||
} from '../shared/contracts/BridgeApi'
|
||||
import { IPC_CHANNELS } from '../shared/contracts/IpcChannels'
|
||||
import type {
|
||||
AccountDto, SignInFinishedDto, SignInPromptDto
|
||||
} from '../shared/contracts/dto/AccountDto'
|
||||
import type { AppStateDto } from '../shared/contracts/dto/AppStateDto'
|
||||
import type { CatalogListingDto } from '../shared/contracts/dto/CatalogListingDto'
|
||||
import type { InstalledStoreDto } from '../shared/contracts/dto/InstalledStoreDto'
|
||||
@@ -40,12 +43,25 @@ const bridge: BridgeApi = {
|
||||
launchGame: async (name: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.catalogLaunchGame, name) as Promise<boolean>,
|
||||
|
||||
readAccount: async (): Promise<AccountDto> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.accountRead) as Promise<AccountDto>,
|
||||
beginSignIn: async (): Promise<SignInPromptDto> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.accountBeginSignIn) as Promise<SignInPromptDto>,
|
||||
cancelSignIn: async (): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.accountCancelSignIn) as Promise<void>,
|
||||
signOut: async (): Promise<AccountDto> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.accountSignOut) as Promise<AccountDto>,
|
||||
|
||||
listRegistryStores: async (): Promise<RegistryResultDto> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.storeListRegistry) as Promise<RegistryResultDto>,
|
||||
installStore: async (store: RegistryStoreDto): Promise<InstalledStoreDto> =>
|
||||
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> =>
|
||||
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> =>
|
||||
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 => {
|
||||
listener(busy)
|
||||
})
|
||||
},
|
||||
onSignInFinished: (listener: StreamListener<SignInFinishedDto>): void => {
|
||||
ipcRenderer.on(
|
||||
IPC_CHANNELS.streamSignInFinished,
|
||||
(_event: IpcRendererEvent, payload: SignInFinishedDto): void => { listener(payload) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BridgeApi } from '../shared/contracts/BridgeApi'
|
||||
import { requireBridge } from './BridgeAccess'
|
||||
import { AccountController } from './controllers/AccountController'
|
||||
import { CatalogController } from './controllers/CatalogController'
|
||||
import { EngineStreamController } from './controllers/EngineStreamController'
|
||||
import { PreferencesController } from './controllers/PreferencesController'
|
||||
@@ -11,6 +12,7 @@ import { GameCardView } from './views/GameCardView'
|
||||
import { GateView } from './views/GateView'
|
||||
import { LogDrawerView } from './views/LogDrawerView'
|
||||
import { SideMenuView } from './views/SideMenuView'
|
||||
import { SignInView } from './views/SignInView'
|
||||
import { TopBarView } from './views/TopBarView'
|
||||
|
||||
/**
|
||||
@@ -28,10 +30,12 @@ export class RendererApplication {
|
||||
private readonly grid: CatalogGridView
|
||||
private readonly topBar: TopBarView
|
||||
private readonly sideMenu: SideMenuView
|
||||
private readonly signInPanel: SignInView
|
||||
private readonly catalog: CatalogController
|
||||
private readonly stores: StoreController
|
||||
private readonly preferences: PreferencesController
|
||||
private readonly streams: EngineStreamController
|
||||
private readonly accounts: AccountController
|
||||
|
||||
public constructor (bridge: BridgeApi = requireBridge()) {
|
||||
this.bridge = bridge
|
||||
@@ -44,25 +48,57 @@ export class RendererApplication {
|
||||
this.stores = new StoreController(this.bridge, this.store, this.log, this.catalog)
|
||||
this.preferences = new PreferencesController(this.bridge, this.store)
|
||||
this.streams = new EngineStreamController(this.bridge, this.store, this.log)
|
||||
this.accounts = new AccountController(
|
||||
this.bridge, this.store, this.log,
|
||||
async (): Promise<void> => { await this.catalog.refresh() }
|
||||
)
|
||||
|
||||
this.grid = new CatalogGridView(new GameCardView({
|
||||
onInstall: (name: string): void => { void this.catalog.syncGames([name]) },
|
||||
// The same call as an install: a sync of one name fetches whatever the catalog
|
||||
// now has for it, and the engine replaces the old payload and menu entry.
|
||||
onUpgrade: (name: string): void => { void this.catalog.syncGames([name]) },
|
||||
onLaunch: (name: string): void => { void this.catalog.launchGame(name) },
|
||||
onRemove: (name: string): void => { void this.catalog.removeGame(name) }
|
||||
onRemove: (name: string): void => { void this.catalog.removeGame(name) },
|
||||
onPurchase: (name: string): void => { void this.purchase(name) },
|
||||
onSignIn: (): void => { void this.accounts.signIn() }
|
||||
}))
|
||||
this.signInPanel = new SignInView({
|
||||
onOpenPage: (): void => { void this.accounts.openVerificationPage() },
|
||||
onCancel: (): void => { void this.accounts.cancelSignIn() }
|
||||
})
|
||||
this.topBar = new TopBarView({
|
||||
onToggleNavigation: (): void => { void this.preferences.toggleNavigation() }
|
||||
})
|
||||
this.sideMenu = new SideMenuView({
|
||||
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() },
|
||||
onRefresh: (): void => { void this.catalog.refresh() },
|
||||
onSelectCategory: (filter: CategoryFilter): void => { this.store.applyFilter(filter) },
|
||||
onSelectLocale: (locale: string): void => { void this.preferences.selectLocale(locale) }
|
||||
onSelectLocale: (locale: string): void => { void this.preferences.selectLocale(locale) },
|
||||
onSignIn: (): void => { void this.accounts.signIn() },
|
||||
onSignOut: (): void => { void this.accounts.signOut() }
|
||||
})
|
||||
|
||||
this.store.subscribe((state: AppState): void => { this.render(state) })
|
||||
this.streams.subscribe()
|
||||
this.accounts.subscribe()
|
||||
}
|
||||
|
||||
/**
|
||||
* Buying happens in a browser.
|
||||
*
|
||||
* A checkout rebuilt in this window would be a second place to get card handling
|
||||
* wrong, and the store's own pages already do it. What this side owes afterwards is
|
||||
* a refresh, which the Refresh button is for.
|
||||
*/
|
||||
private async purchase (name: string): Promise<void> {
|
||||
const url = this.store.readState().games
|
||||
.find((candidate): boolean => candidate.name === name)?.purchaseUrl ?? null
|
||||
if (url === null) return
|
||||
|
||||
await this.bridge.openUrl(url)
|
||||
}
|
||||
|
||||
/** Decides what the window is showing, then hands over to the views. */
|
||||
@@ -88,6 +124,7 @@ export class RendererApplication {
|
||||
}
|
||||
|
||||
document.body.classList.toggle('nav-closed', !state.navigationOpen)
|
||||
this.signInPanel.render(state)
|
||||
this.topBar.render(state)
|
||||
this.sideMenu.render(state)
|
||||
this.log.render(state)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { BridgeApi } from '../../shared/contracts/BridgeApi'
|
||||
import type { SignInFinishedDto } from '../../shared/contracts/dto/AccountDto'
|
||||
import type { AppStore } from '../state/AppStore'
|
||||
import type { LogDrawerView } from '../views/LogDrawerView'
|
||||
|
||||
/**
|
||||
* Signing in and out, from the window's side.
|
||||
*
|
||||
* Two halves that do not meet: `signIn` puts a code on screen and returns, and the
|
||||
* answer arrives later on the sign-in stream — because the person is not here while it
|
||||
* happens, they are in a browser. Nothing waits on anything.
|
||||
*
|
||||
* A sign-in that succeeds refreshes the catalog rather than patching the cards, since
|
||||
* every entitlement in the listing was read without a credential and is now stale.
|
||||
*/
|
||||
export class AccountController {
|
||||
public constructor (
|
||||
private readonly bridge: BridgeApi,
|
||||
private readonly store: AppStore,
|
||||
private readonly log: LogDrawerView,
|
||||
private readonly onSignedIn: () => Promise<void>
|
||||
) {}
|
||||
|
||||
public subscribe (): void {
|
||||
this.bridge.onSignInFinished((result: SignInFinishedDto): void => {
|
||||
this.store.applySignIn(null)
|
||||
this.store.applyAccount(result.account)
|
||||
this.log.appendLine(this.describeOutcome(result))
|
||||
// Only a successful sign-in changes what the catalog would say. The other three
|
||||
// leave it exactly as it was, and re-reading it would be a pointless wait.
|
||||
if (result.outcome === 'signedIn') void this.onSignedIn()
|
||||
})
|
||||
}
|
||||
|
||||
public async signIn (): Promise<void> {
|
||||
try {
|
||||
const prompt = await this.bridge.beginSignIn()
|
||||
this.store.applySignIn(prompt)
|
||||
// Opened for them rather than waiting to be clicked: the browser is where the
|
||||
// rest of this happens, and the code on screen is no use until it is open.
|
||||
await this.bridge.openUrl(prompt.verificationUrl)
|
||||
} catch (error: unknown) {
|
||||
this.store.applySignIn(null)
|
||||
this.log.appendLine(`${this.store.readState().messages.signInFailed} ${describe(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-open the page for somebody who closed the tab before typing the code. */
|
||||
public async openVerificationPage (): Promise<void> {
|
||||
const prompt = this.store.readState().signIn
|
||||
if (prompt === null) return
|
||||
|
||||
await this.bridge.openUrl(prompt.verificationUrl)
|
||||
}
|
||||
|
||||
public async cancelSignIn (): Promise<void> {
|
||||
this.store.applySignIn(null)
|
||||
await this.bridge.cancelSignIn()
|
||||
}
|
||||
|
||||
public async signOut (): Promise<void> {
|
||||
try {
|
||||
this.store.applyAccount(await this.bridge.signOut())
|
||||
// The listing was read as somebody; it has to be read again as nobody, or every
|
||||
// owned title keeps its Install button until the next refresh.
|
||||
await this.onSignedIn()
|
||||
} catch (error: unknown) {
|
||||
this.log.appendLine(describe(error))
|
||||
}
|
||||
}
|
||||
|
||||
private describeOutcome (result: SignInFinishedDto): string {
|
||||
const messages = this.store.readState().messages
|
||||
switch (result.outcome) {
|
||||
case 'signedIn': return messages.signInDone
|
||||
case 'denied': return messages.signInDenied
|
||||
case 'expired': return messages.signInExpired
|
||||
case 'cancelled': return messages.signInCancelled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function describe (error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export class CatalogController {
|
||||
public async refresh (): Promise<void> {
|
||||
try {
|
||||
const listing: CatalogListingDto = await this.bridge.listGames()
|
||||
this.store.applyCatalog(listing.games, listing.paths)
|
||||
this.store.applyCatalog(listing.games, listing.paths, listing.account)
|
||||
for (const reason of listing.skipped) this.log.appendLine(`skipped ${reason}`)
|
||||
} catch (error: unknown) {
|
||||
this.reportFailure(error)
|
||||
|
||||
@@ -41,11 +41,30 @@ export class StoreController {
|
||||
const messages = state.messages
|
||||
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) {
|
||||
this.store.applyGate({
|
||||
title: messages.registryFailed,
|
||||
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
|
||||
}
|
||||
@@ -53,7 +72,9 @@ export class StoreController {
|
||||
if (result.stores.length === 0) {
|
||||
this.store.applyGate({
|
||||
title: messages.setupTitle,
|
||||
body: `${messages.registryEmpty}\n\n${result.sourceUrl}`
|
||||
body: `${messages.registryEmpty}\n\n${result.sourceUrl}`,
|
||||
custom,
|
||||
...cancel
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -67,10 +88,65 @@ export class StoreController {
|
||||
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> {
|
||||
const messages = this.store.readState().messages
|
||||
this.store.applyProgress({ total: 0, done: 0, label: messages.setupWorking })
|
||||
|
||||
@@ -33,7 +33,17 @@
|
||||
<section class="side-block">
|
||||
<h2 class="side-head" id="head-stores"></h2>
|
||||
<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 class="side-block side-cats">
|
||||
@@ -55,6 +65,17 @@
|
||||
<path d="M13.5 2v3h-3" />
|
||||
</svg>
|
||||
</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 native dropdown knows how to open upward in a cramped window and is
|
||||
@@ -74,6 +95,22 @@
|
||||
</aside>
|
||||
|
||||
<div class="content">
|
||||
<!--
|
||||
The code to type into a browser. A panel over the grid rather than a screen of
|
||||
its own: the catalog is still there and still readable, and the sign-in is
|
||||
something happening elsewhere that this window is only reporting on.
|
||||
-->
|
||||
<section id="signin" class="signin" hidden>
|
||||
<h2 id="signin-title"></h2>
|
||||
<p id="signin-body"></p>
|
||||
<p class="signin-code" id="signin-code"></p>
|
||||
<p class="signin-waiting" id="signin-waiting"></p>
|
||||
<div class="signin-actions">
|
||||
<button id="signin-open" class="btn btn-secondary"></button>
|
||||
<button id="signin-cancel" class="btn btn-ghost"></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Shown instead of the grid when there is nothing to drive yet. -->
|
||||
<section id="gate" class="gate" hidden>
|
||||
<h1 id="gate-title"></h1>
|
||||
@@ -84,8 +121,25 @@
|
||||
<select id="gate-select" class="select"></select>
|
||||
</label>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<main id="grid" class="grid" hidden></main>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AccountDto, SignInPromptDto } from '../../shared/contracts/dto/AccountDto'
|
||||
import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto'
|
||||
import type { GameDto } from '../../shared/contracts/dto/GameDto'
|
||||
import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto'
|
||||
@@ -31,6 +32,10 @@ export interface AppState {
|
||||
readonly progress: SyncProgress | null
|
||||
/** Non-null while the setup screen is up, which is also what hides the grid. */
|
||||
readonly gate: GatePresentation | null
|
||||
/** Where this machine stands with the open store. */
|
||||
readonly account: AccountDto
|
||||
/** Non-null while a code is on screen waiting to be typed into a browser. */
|
||||
readonly signIn: SignInPromptDto | null
|
||||
}
|
||||
|
||||
const INITIAL_STATE: AppState = {
|
||||
@@ -47,7 +52,9 @@ const INITIAL_STATE: AppState = {
|
||||
filter: ALL_CATEGORIES,
|
||||
busy: false,
|
||||
progress: null,
|
||||
gate: null
|
||||
gate: null,
|
||||
account: { signInAvailable: false, signedIn: false },
|
||||
signIn: null
|
||||
}
|
||||
|
||||
export type AppStateListener = (state: AppState) => void
|
||||
@@ -96,13 +103,38 @@ export class AppStore {
|
||||
this.notify()
|
||||
}
|
||||
|
||||
public applyCatalog (games: readonly GameDto[], paths: StorePathsDto | null): void {
|
||||
this.state = { ...this.state, games, paths: paths ?? this.state.paths }
|
||||
public applyCatalog (
|
||||
games: readonly GameDto[],
|
||||
paths: StorePathsDto | null,
|
||||
account: AccountDto
|
||||
): void {
|
||||
this.state = { ...this.state, games, paths: paths ?? this.state.paths, account }
|
||||
this.notify()
|
||||
}
|
||||
|
||||
public applyAccount (account: AccountDto): void {
|
||||
this.state = { ...this.state, account }
|
||||
this.notify()
|
||||
}
|
||||
|
||||
public applySignIn (prompt: SignInPromptDto | null): void {
|
||||
this.state = { ...this.state, signIn: prompt }
|
||||
this.notify()
|
||||
}
|
||||
|
||||
public applySelectedStore (store: InstalledStoreDto): void {
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,12 @@ function matchesGroup (game: GameDto, group: string): boolean {
|
||||
return game.updateAvailable
|
||||
case 'available':
|
||||
return game.installable && !game.installed
|
||||
case 'owned':
|
||||
// Owning something is not the same as having installed it — the point of the
|
||||
// category is finding what you paid for and have not put on this machine yet.
|
||||
return game.accessVerdict === 'entitled'
|
||||
case 'purchasable':
|
||||
return game.accessVerdict === 'purchasable'
|
||||
case 'unsupported':
|
||||
return !game.installable
|
||||
default:
|
||||
@@ -57,8 +63,8 @@ function matchesGroup (game: GameDto, group: string): boolean {
|
||||
* The categories, built from what the catalog actually contains.
|
||||
*
|
||||
* There is no genre in a WarpEngine catalog, so the useful axes are the state of a
|
||||
* title on this machine, the platform it was built with, and whether it runs here or
|
||||
* in a browser. Empty axes are left out rather than shown as zeroes, and an axis with
|
||||
* title on this machine, what this person may have of it, the platform it was built
|
||||
* with, and whether it runs here or in a browser. Empty axes are left out rather than shown as zeroes, and an axis with
|
||||
* a single value is left out too — a filter that changes nothing is noise.
|
||||
*/
|
||||
export function buildCategorySections (
|
||||
@@ -73,6 +79,8 @@ export function buildCategorySections (
|
||||
{ kind: 'group', value: 'installed', label: messages.catInstalled, count: count((game: GameDto): boolean => game.installed) },
|
||||
{ kind: 'group', value: 'updates', label: messages.catUpdates, count: count((game: GameDto): boolean => game.updateAvailable) },
|
||||
{ kind: 'group', value: 'available', label: messages.catAvailable, count: count((game: GameDto): boolean => game.installable && !game.installed) },
|
||||
{ kind: 'group', value: 'owned', label: messages.catOwned, count: count((game: GameDto): boolean => game.accessVerdict === 'entitled') },
|
||||
{ kind: 'group', value: 'purchasable', label: messages.catPurchasable, count: count((game: GameDto): boolean => game.accessVerdict === 'purchasable') },
|
||||
{ kind: 'group', value: 'unsupported', label: messages.catUnsupported, count: count((game: GameDto): boolean => !game.installable) }
|
||||
]
|
||||
sections.push({
|
||||
|
||||
@@ -11,6 +11,14 @@ export interface GateLink {
|
||||
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.
|
||||
*
|
||||
@@ -24,4 +32,12 @@ export interface GatePresentation {
|
||||
readonly action?: GateAction
|
||||
readonly link?: GateLink
|
||||
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
|
||||
}
|
||||
|
||||
@@ -113,26 +113,44 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
}
|
||||
.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-row {
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
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;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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-id {
|
||||
font-size: 11px;
|
||||
@@ -141,7 +159,30 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
text-overflow: ellipsis;
|
||||
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. */
|
||||
.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:hover:not(:disabled) { background: #45cd9b; }
|
||||
.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; }
|
||||
.select {
|
||||
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-choice { display: inline-flex; align-items: center; gap: 8px; color: var(--ink-dim); font-size: 13px; }
|
||||
|
||||
/* --- signing in --------------------------------------------------------- */
|
||||
/*
|
||||
* The code, while somebody carries it to a browser. A band across the top of the
|
||||
* content rather than a screen of its own: the sign-in is happening elsewhere, and
|
||||
* there is no reason the catalog should stop being readable while it does.
|
||||
*/
|
||||
.signin {
|
||||
margin: 18px 18px 0;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--panel);
|
||||
text-align: center;
|
||||
}
|
||||
.signin h2 { margin: 0 0 6px; font-size: 16px; }
|
||||
.signin p { margin: 0 0 10px; color: var(--ink-dim); font-size: 13px; }
|
||||
/* The one thing on screen somebody has to copy by eye, so: large, spaced, and
|
||||
selectable — a code that cannot be highlighted is a code that has to be retyped. */
|
||||
.signin-code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 28px;
|
||||
letter-spacing: 0.22em;
|
||||
color: var(--ink);
|
||||
user-select: text;
|
||||
}
|
||||
.signin-waiting { font-size: 12px; }
|
||||
.signin-actions { display: flex; gap: 10px; justify-content: center; }
|
||||
|
||||
/* --- the grid ----------------------------------------------------------- */
|
||||
.grid {
|
||||
flex: 1;
|
||||
@@ -300,6 +376,44 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
align-content: start;
|
||||
}
|
||||
.empty { margin: auto; color: var(--ink-dim); }
|
||||
/* A title somebody has not bought is not a broken one: the dimming and the dashed
|
||||
border belong to what this *machine* cannot do, and there is nothing wrong with
|
||||
the machine here. */
|
||||
.card.is-purchasable { opacity: 1; }
|
||||
.side-quiet-text { color: var(--ink-dim); font-size: 12px; margin: 0 0 8px; }
|
||||
|
||||
/* --- 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; }
|
||||
|
||||
.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-web { color: var(--warn); border-color: #5a4a2f; }
|
||||
/* A price reads as a fact rather than a warning: same weight as the mode badges,
|
||||
filled rather than outlined, so it is findable while scanning a row of cards. */
|
||||
.badge-price {
|
||||
color: var(--ink);
|
||||
background: var(--panel-2);
|
||||
border-color: var(--line);
|
||||
}
|
||||
.badge-owned { color: var(--accent); border-color: #2f5a49; }
|
||||
.version { font-size: 12px; color: var(--ink-dim); margin-left: auto; }
|
||||
.desc {
|
||||
margin: 0;
|
||||
@@ -357,6 +479,56 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
}
|
||||
.actions { display: flex; gap: 8px; margin-top: auto; }
|
||||
|
||||
/* --- the card's actions menu -------------------------------------------- */
|
||||
/*
|
||||
* A <details> holding the two actions that are not the card's headline. The card is the
|
||||
* positioning context, and the panel is pinned to the button's right edge so it opens
|
||||
* inward rather than off the side of the grid.
|
||||
*/
|
||||
.menu { position: relative; margin-left: auto; }
|
||||
.menu-toggle { list-style: none; }
|
||||
/* Safari and Chrome each draw their own marker on a summary; both have to go. */
|
||||
.menu-toggle::-webkit-details-marker { display: none; }
|
||||
.menu-toggle::marker { content: ''; }
|
||||
.menu[open] .menu-toggle { color: var(--ink); background: var(--panel-2); border-color: var(--line); }
|
||||
|
||||
.menu-items {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 6px);
|
||||
z-index: 20;
|
||||
min-width: 148px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px;
|
||||
gap: 2px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 28px rgb(0 0 0 / .45);
|
||||
}
|
||||
.menu-item {
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.menu-item:hover:not(:disabled) { background: #2b3746; }
|
||||
.menu-item:disabled { color: var(--ink-dim); opacity: .5; cursor: default; }
|
||||
|
||||
.icon-dots { fill: currentColor; stroke: none; }
|
||||
|
||||
/* An installed version with a newer one behind it: the arrow carries the news, so the
|
||||
colour only has to make it findable in a grid. */
|
||||
.version.has-update { color: var(--accent); font-weight: 600; }
|
||||
|
||||
|
||||
/* --- log ----------------------------------------------------------------
|
||||
No permanent footer: the panel is in the flow only while it is open, and the
|
||||
switch for it sits in the side menu with everything else that is not a title. */
|
||||
|
||||
@@ -9,7 +9,26 @@ export class CatalogGridView {
|
||||
private readonly grid = requireElement('grid', HTMLElement)
|
||||
private readonly empty = requireElement('empty', HTMLElement)
|
||||
|
||||
public constructor (private readonly cards: GameCardView) {}
|
||||
public constructor (private readonly cards: GameCardView) {
|
||||
this.closeMenusOnOutsideClick()
|
||||
}
|
||||
|
||||
/**
|
||||
* One listener for every card's actions menu.
|
||||
*
|
||||
* A `<details>` does not close when the pointer goes elsewhere, and a card cannot own
|
||||
* this: cards are rebuilt on every render, so a listener per card would be a listener
|
||||
* per render. The grid is created once, which makes it the right place for it.
|
||||
*/
|
||||
private closeMenusOnOutsideClick (): void {
|
||||
document.addEventListener('click', (event: MouseEvent): void => {
|
||||
const target = event.target
|
||||
const clicked = target instanceof Node ? target : null
|
||||
for (const menu of this.grid.querySelectorAll('details.menu[open]')) {
|
||||
if (clicked === null || !menu.contains(clicked)) menu.removeAttribute('open')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public render (state: AppState): void {
|
||||
const shown = state.games.filter((game: GameDto): boolean => matchesFilter(game, state.filter))
|
||||
|
||||
@@ -4,15 +4,22 @@ import { createElement } from '../dom/Dom'
|
||||
|
||||
export interface GameCardViewCallbacks {
|
||||
readonly onInstall: (name: string) => void
|
||||
readonly onUpgrade: (name: string) => void
|
||||
readonly onLaunch: (name: string) => void
|
||||
readonly onRemove: (name: string) => void
|
||||
/** Opens the catalog's own purchase page in the person's browser. */
|
||||
readonly onPurchase: (name: string) => void
|
||||
readonly onSignIn: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* One card.
|
||||
*
|
||||
* A card is a function of a title and the strings: it holds no state of its own, so
|
||||
* the grid can throw the lot away and rebuild after every listing.
|
||||
* A card is a function of a title and the strings: it holds no state of its own, so the
|
||||
* grid can throw the lot away and rebuild after every listing. The one exception is the
|
||||
* actions menu, whose open/closed state lives in a `<details>` element — and being
|
||||
* thrown away is exactly what should happen to an open menu when the catalog changes
|
||||
* under it.
|
||||
*/
|
||||
export class GameCardView {
|
||||
public constructor (private readonly callbacks: GameCardViewCallbacks) {}
|
||||
@@ -21,6 +28,7 @@ export class GameCardView {
|
||||
const card = createElement('article', 'card')
|
||||
if (game.installed) card.classList.add('is-installed')
|
||||
if (!game.installable) card.classList.add('is-unavailable')
|
||||
if (game.accessVerdict === 'purchasable') card.classList.add('is-purchasable')
|
||||
card.appendChild(this.createArt(game))
|
||||
card.appendChild(this.createBody(game, messages, busy))
|
||||
return card
|
||||
@@ -71,13 +79,41 @@ export class GameCardView {
|
||||
meta.appendChild(badge)
|
||||
}
|
||||
meta.appendChild(createElement('span', 'badge badge-plain', game.platform))
|
||||
meta.appendChild(createElement('span', 'version',
|
||||
game.installed && game.installedVersion !== null
|
||||
? `${game.installedVersion} · ${messages.installed}`
|
||||
: game.version))
|
||||
// The price is where the mode badge is rather than down by the button: what a title
|
||||
// costs is something a person scans a grid for, and a number that only appears
|
||||
// beside a button is a number they have to hunt for card by card.
|
||||
if (game.priceLabel !== null && game.accessVerdict !== 'entitled') {
|
||||
meta.appendChild(createElement('span', 'badge badge-price', game.priceLabel))
|
||||
}
|
||||
if (game.accessVerdict === 'entitled') {
|
||||
const owned = createElement('span', 'badge badge-owned', messages.owned)
|
||||
owned.title = messages.ownedHint
|
||||
meta.appendChild(owned)
|
||||
}
|
||||
meta.appendChild(this.createVersion(game, messages))
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* The version, and what the catalog has that this machine does not.
|
||||
*
|
||||
* Both numbers, when they differ: which version is installed is the thing a person
|
||||
* came to the card to find out, and "there is a newer one" is only meaningful next to
|
||||
* it. Where they agree, the second number would be noise.
|
||||
*/
|
||||
private createVersion (game: GameDto, messages: MessageBundle): HTMLElement {
|
||||
if (!game.installed || game.installedVersion === null) {
|
||||
return createElement('span', 'version', game.version)
|
||||
}
|
||||
if (!game.updateAvailable) {
|
||||
return createElement('span', 'version', `${game.installedVersion} · ${messages.installed}`)
|
||||
}
|
||||
const version = createElement('span', 'version has-update',
|
||||
`${game.installedVersion} → ${game.version}`)
|
||||
version.title = messages.updateAvailable
|
||||
return version
|
||||
}
|
||||
|
||||
private createActions (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||
const actions = createElement('div', 'actions')
|
||||
|
||||
@@ -89,25 +125,128 @@ export class GameCardView {
|
||||
return actions
|
||||
}
|
||||
|
||||
const primary = createElement('button', 'btn btn-primary')
|
||||
primary.disabled = busy
|
||||
// Not yours yet: the card sells rather than installs. Deliberately a live button
|
||||
// and not a dimmed one — the dimmed treatment above is for what this *machine*
|
||||
// cannot do, and there is nothing wrong with this machine.
|
||||
if (!game.installed && game.accessVerdict === 'purchasable') {
|
||||
actions.appendChild(this.createPurchase(game, messages, busy))
|
||||
return actions
|
||||
}
|
||||
// The catalog would know, if it knew who was asking.
|
||||
if (!game.installed && game.accessVerdict === 'signInRequired') {
|
||||
actions.appendChild(this.createSignIn(messages, busy))
|
||||
return actions
|
||||
}
|
||||
|
||||
if (game.installed && !game.updateAvailable) {
|
||||
actions.appendChild(this.createPrimary(game, messages, busy))
|
||||
// Only an installed title has anything in the menu: nothing to upgrade and nothing
|
||||
// to uninstall until there is something on the disk.
|
||||
if (game.installed) actions.appendChild(this.createMenu(game, messages, busy))
|
||||
return actions
|
||||
}
|
||||
|
||||
/**
|
||||
* Buy it — which happens in a browser, not here.
|
||||
*
|
||||
* Payment is the store's business and its own web pages already do it; a checkout
|
||||
* rebuilt in this window would be a second place to get card handling wrong. After
|
||||
* buying, Refresh is what turns the card into an Install.
|
||||
*/
|
||||
private createPurchase (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||
const button = createElement('button', 'btn btn-primary btn-purchase')
|
||||
button.textContent = game.priceLabel === null
|
||||
? messages.purchase
|
||||
: `${messages.purchase} · ${game.priceLabel}`
|
||||
button.disabled = busy || game.purchaseUrl === null
|
||||
button.title = messages.purchaseHint
|
||||
button.addEventListener('click', (): void => { this.callbacks.onPurchase(game.name) })
|
||||
return button
|
||||
}
|
||||
|
||||
private createSignIn (messages: MessageBundle, busy: boolean): HTMLElement {
|
||||
const button = createElement('button', 'btn btn-secondary')
|
||||
button.textContent = messages.signInToInstall
|
||||
button.disabled = busy
|
||||
button.title = messages.signInToInstallHint
|
||||
button.addEventListener('click', (): void => { this.callbacks.onSignIn() })
|
||||
return button
|
||||
}
|
||||
|
||||
/**
|
||||
* The one action a card leads with.
|
||||
*
|
||||
* For an installed title that is playing it — including when an upgrade is waiting,
|
||||
* because the version on the disk still runs and wanting to play it is not the same
|
||||
* as wanting to wait for a download.
|
||||
*/
|
||||
private createPrimary (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||
const primary = createElement('button', 'btn btn-primary')
|
||||
if (game.installed) {
|
||||
primary.textContent = game.mode === 'web' ? messages.open : messages.play
|
||||
primary.disabled = busy || !game.launchable
|
||||
primary.addEventListener('click', (): void => { this.callbacks.onLaunch(game.name) })
|
||||
} else {
|
||||
primary.textContent = game.updateAvailable ? messages.update : messages.install
|
||||
return primary
|
||||
}
|
||||
primary.textContent = messages.install
|
||||
primary.disabled = busy
|
||||
primary.addEventListener('click', (): void => { this.callbacks.onInstall(game.name) })
|
||||
return primary
|
||||
}
|
||||
actions.appendChild(primary)
|
||||
|
||||
if (game.installed) {
|
||||
const remove = createElement('button', 'btn btn-ghost', messages.remove)
|
||||
remove.disabled = busy
|
||||
remove.addEventListener('click', (): void => { this.callbacks.onRemove(game.name) })
|
||||
actions.appendChild(remove)
|
||||
/**
|
||||
* The three-dot menu: upgrade, and uninstall.
|
||||
*
|
||||
* A `<details>` rather than a scripted popover, so the open state is the DOM's and the
|
||||
* keyboard works without being taught to. Upgrade is present but disabled when there
|
||||
* is nothing newer — a menu whose items appear and disappear makes a person hunt for
|
||||
* the one they used last time, and "greyed out" already says "not now".
|
||||
*/
|
||||
private createMenu (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||
const menu = createElement('details', 'menu')
|
||||
const toggle = createElement('summary', 'icon-btn menu-toggle')
|
||||
toggle.title = messages.moreActions
|
||||
toggle.setAttribute('aria-label', messages.moreActions)
|
||||
toggle.appendChild(this.createDotsIcon())
|
||||
menu.appendChild(toggle)
|
||||
|
||||
const items = createElement('div', 'menu-items')
|
||||
items.appendChild(this.createMenuItem(messages.upgrade, busy || !game.updateAvailable,
|
||||
(): void => { this.callbacks.onUpgrade(game.name) }))
|
||||
items.appendChild(this.createMenuItem(messages.uninstall, busy,
|
||||
(): void => { this.callbacks.onRemove(game.name) }))
|
||||
menu.appendChild(items)
|
||||
return menu
|
||||
}
|
||||
return actions
|
||||
|
||||
private createMenuItem (
|
||||
label: string,
|
||||
disabled: boolean,
|
||||
perform: () => void
|
||||
): HTMLButtonElement {
|
||||
const item = createElement('button', 'menu-item', label)
|
||||
item.disabled = disabled
|
||||
item.addEventListener('click', (): void => {
|
||||
// Close before acting: the click starts work that re-renders the grid, and a menu
|
||||
// left open would vanish mid-gesture rather than answer the press.
|
||||
item.closest('details')?.removeAttribute('open')
|
||||
perform()
|
||||
})
|
||||
return item
|
||||
}
|
||||
|
||||
private createDotsIcon (): SVGSVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
svg.setAttribute('class', 'icon icon-dots')
|
||||
svg.setAttribute('viewBox', '0 0 16 16')
|
||||
svg.setAttribute('aria-hidden', 'true')
|
||||
svg.setAttribute('focusable', 'false')
|
||||
for (const y of [3.5, 8, 12.5]) {
|
||||
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
|
||||
dot.setAttribute('cx', '8')
|
||||
dot.setAttribute('cy', String(y))
|
||||
dot.setAttribute('r', '1.35')
|
||||
svg.appendChild(dot)
|
||||
}
|
||||
return svg
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
|
||||
import { createElement, requireElement, setHidden, setText } from '../dom/Dom'
|
||||
import type { MessageBundle } from '../../shared/i18n/MessageBundle'
|
||||
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
|
||||
@@ -15,7 +15,13 @@ export class GateView {
|
||||
private readonly choiceLabel = requireElement('gate-choice-label', HTMLElement)
|
||||
private readonly select = requireElement('gate-select', HTMLSelectElement)
|
||||
private readonly button = requireElement('gate-action', HTMLButtonElement)
|
||||
private readonly cancel = requireElement('gate-cancel', HTMLButtonElement)
|
||||
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) {}
|
||||
|
||||
@@ -25,6 +31,8 @@ export class GateView {
|
||||
setText(this.body, presentation.body)
|
||||
this.renderChoices(presentation.choices ?? [], messages)
|
||||
this.renderAction(presentation)
|
||||
this.renderCancel(presentation)
|
||||
this.renderCustom(presentation.custom ?? 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 {
|
||||
setHidden(this.link, link === null)
|
||||
if (link === null) return
|
||||
|
||||
@@ -8,10 +8,13 @@ import type { AppState } from '../state/AppStore'
|
||||
|
||||
export interface SideMenuViewCallbacks {
|
||||
readonly onSelectStore: (home: string) => void
|
||||
readonly onRemoveStore: (home: string, name: string) => void
|
||||
readonly onAddStore: () => void
|
||||
readonly onRefresh: () => void
|
||||
readonly onSelectCategory: (filter: CategoryFilter) => 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 refresh = requireElement('refresh', HTMLButtonElement)
|
||||
private readonly categories = requireElement('cats', HTMLElement)
|
||||
private readonly accountBlock = requireElement('account-block', HTMLElement)
|
||||
private readonly accountHead = requireElement('head-account', HTMLElement)
|
||||
private readonly accountState = requireElement('account-state', HTMLElement)
|
||||
private readonly accountAction = requireElement('account-action', HTMLButtonElement)
|
||||
private readonly locale = requireElement('locale', HTMLSelectElement)
|
||||
/** The square around the select: it is what a pointer hovers, so the tooltip is its. */
|
||||
private readonly localeControl = requireElement('locale-control', HTMLElement)
|
||||
@@ -34,12 +41,26 @@ export class SideMenuView {
|
||||
this.addStore.addEventListener('click', callbacks.onAddStore)
|
||||
this.refresh.addEventListener('click', callbacks.onRefresh)
|
||||
this.locale.addEventListener('change', (): void => { callbacks.onSelectLocale(this.locale.value) })
|
||||
this.accountAction.addEventListener('click', (): void => {
|
||||
if (this.signedIn) this.callbacks.onSignOut()
|
||||
else this.callbacks.onSignIn()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the two the one button does.
|
||||
*
|
||||
* Read at click time rather than rebound on every render: a listener replaced under
|
||||
* a pointer that is already down is a click that goes nowhere.
|
||||
*/
|
||||
private signedIn = false
|
||||
|
||||
public render (state: AppState): void {
|
||||
setText(this.storesHead, state.messages.stores)
|
||||
setText(this.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
|
||||
// writing text into them would replace the glyph.
|
||||
describeControl(this.refresh, state.messages.refresh)
|
||||
@@ -49,6 +70,7 @@ export class SideMenuView {
|
||||
this.localeControl.title = state.messages.language
|
||||
|
||||
this.renderStores(state)
|
||||
this.renderAccount(state)
|
||||
this.renderCategories(state)
|
||||
this.renderLocales(state)
|
||||
this.renderEnabled(state)
|
||||
@@ -65,19 +87,55 @@ export class SideMenuView {
|
||||
.map((store: InstalledStoreDto): string => store.id))
|
||||
|
||||
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')
|
||||
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))
|
||||
row.title = store.home
|
||||
row.addEventListener('click', (): void => {
|
||||
open.title = store.home
|
||||
open.addEventListener('click', (): void => {
|
||||
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
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* The account block, or nothing at all.
|
||||
*
|
||||
* Hidden outright where the catalog offers no sign-in — which is most of them. A
|
||||
* disabled "Sign in" would be telling somebody about a door that is not there.
|
||||
*/
|
||||
private renderAccount (state: AppState): void {
|
||||
this.accountBlock.hidden = !state.account.signInAvailable
|
||||
if (!state.account.signInAvailable) return
|
||||
|
||||
this.signedIn = state.account.signedIn
|
||||
setText(this.accountHead, state.messages.account)
|
||||
setText(this.accountState, state.account.signedIn ? state.messages.signedIn : '')
|
||||
setText(this.accountAction, state.account.signedIn
|
||||
? state.messages.signOut
|
||||
: state.messages.signIn)
|
||||
// A sign-in already under way has its own panel with a cancel on it; a second
|
||||
// "Sign in" here would start a second flow behind the first one's code.
|
||||
this.accountAction.disabled = state.busy || state.signIn !== null
|
||||
}
|
||||
|
||||
private renderCategories (state: AppState): void {
|
||||
const sections = buildCategorySections(state.games, state.messages)
|
||||
const nodes: HTMLElement[] = []
|
||||
@@ -128,6 +186,26 @@ export class SideMenuView {
|
||||
* `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.
|
||||
*/
|
||||
/**
|
||||
* 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 {
|
||||
element.title = name
|
||||
element.setAttribute('aria-label', name)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { requireElement, setText } from '../dom/Dom'
|
||||
import type { AppState } from '../state/AppStore'
|
||||
|
||||
export interface SignInViewCallbacks {
|
||||
readonly onOpenPage: () => void
|
||||
readonly onCancel: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The code, while somebody takes it to a browser.
|
||||
*
|
||||
* A panel over the catalog rather than a screen of its own: the sign-in is happening
|
||||
* somewhere else, and there is no reason this window should stop being useful while it
|
||||
* does. Everything on it is one of three things — the code, a way back to the page for
|
||||
* whoever closed the tab, and a way out.
|
||||
*/
|
||||
export class SignInView {
|
||||
private readonly panel = requireElement('signin', HTMLElement)
|
||||
private readonly title = requireElement('signin-title', HTMLElement)
|
||||
private readonly body = requireElement('signin-body', HTMLElement)
|
||||
private readonly code = requireElement('signin-code', HTMLElement)
|
||||
private readonly waiting = requireElement('signin-waiting', HTMLElement)
|
||||
private readonly openPage = requireElement('signin-open', HTMLButtonElement)
|
||||
private readonly cancel = requireElement('signin-cancel', HTMLButtonElement)
|
||||
|
||||
public constructor (callbacks: SignInViewCallbacks) {
|
||||
this.openPage.addEventListener('click', callbacks.onOpenPage)
|
||||
this.cancel.addEventListener('click', callbacks.onCancel)
|
||||
}
|
||||
|
||||
public render (state: AppState): void {
|
||||
const prompt = state.signIn
|
||||
if (prompt === null) {
|
||||
this.panel.hidden = true
|
||||
return
|
||||
}
|
||||
|
||||
const storeName = state.currentStore?.name ?? state.messages.appName
|
||||
setText(this.title, state.messages.signInTitle.replace('%{store}', storeName))
|
||||
setText(this.body, state.messages.signInBody)
|
||||
setText(this.code, prompt.userCode)
|
||||
setText(this.waiting, state.messages.signInWaiting)
|
||||
setText(this.openPage, state.messages.signInOpenAgain)
|
||||
setText(this.cancel, state.messages.signInCancel)
|
||||
this.panel.hidden = false
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { CatalogListing } from '../domain/models/CatalogListing'
|
||||
import type { InstalledStore } from '../domain/models/InstalledStore'
|
||||
import { DESKTOP_STORE_ENGINE } from '../domain/models/StoreEngine'
|
||||
import { deriveStoreId } from '../domain/models/StoreIdentity'
|
||||
import type { CredentialRepository } from '../domain/ports/CredentialRepository'
|
||||
import { NativeStoreCatalogGateway } from '../infrastructure/engine/NativeStoreCatalogGateway'
|
||||
import { HttpTextClient } from '../infrastructure/http/HttpTextClient'
|
||||
import { FileSystemInstalledStoreRepository } from '../infrastructure/repositories/FileSystemInstalledStoreRepository'
|
||||
@@ -26,12 +27,18 @@ import { LOCALES } from '../shared/i18n/MessageBundle'
|
||||
*
|
||||
* npm run smoke the store on this machine
|
||||
* SMOKE_HOME=/path/to/store-home npm run smoke a sandbox store
|
||||
* SMOKE_TOKEN=<bearer> npm run smoke as a signed-in person
|
||||
*
|
||||
* `SMOKE_TOKEN` exists because the signed-in path is otherwise untestable here: the
|
||||
* real credential store is the OS keychain, reached through Electron, and there is no
|
||||
* Electron in this process. Without it a gated catalog can only ever be read as an
|
||||
* anonymous caller, and "owned" and "not owned" never happen.
|
||||
*/
|
||||
class SmokeTest {
|
||||
private failed = false
|
||||
|
||||
private readonly stores = new FileSystemInstalledStoreRepository()
|
||||
private readonly catalogGateway = new NativeStoreCatalogGateway()
|
||||
private readonly catalogGateway = new NativeStoreCatalogGateway(envCredentials())
|
||||
private readonly httpClient = new HttpTextClient()
|
||||
private readonly registry = new HttpStoreRegistryRepository(this.httpClient)
|
||||
private readonly gameMapper = new GameDtoMapper()
|
||||
@@ -123,10 +130,44 @@ class SmokeTest {
|
||||
}
|
||||
const games = this.gameMapper.toDtoList(listing.games, listing.paths?.catalogBaseUrl ?? '')
|
||||
this.reportListing(games)
|
||||
this.reportAccess(listing, games)
|
||||
|
||||
if (logLines.length > 0) this.reportOk('stderr log', `${String(logLines.length)} lines (kept off stdout)`)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the catalog said about who may have what.
|
||||
*
|
||||
* The load-bearing case is the boring one: an older engine says nothing, every title
|
||||
* comes back `open`, and the client behaves exactly as it did before any of this
|
||||
* existed. A gated catalog is where the rest of it starts mattering.
|
||||
*/
|
||||
private reportAccess (listing: CatalogListing, games: readonly GameDto[]): void {
|
||||
const account = listing.account
|
||||
this.reportOk('sign-in', account.signInAvailable
|
||||
? (account.signedIn ? 'offered, and signed in' : 'offered, not signed in')
|
||||
: 'not offered by this catalog')
|
||||
|
||||
const counts = new Map<string, number>()
|
||||
for (const game of games) {
|
||||
counts.set(game.accessVerdict, (counts.get(game.accessVerdict) ?? 0) + 1)
|
||||
}
|
||||
const summary = [...counts.entries()]
|
||||
.map(([verdict, count]: readonly [string, number]): string => `${verdict}:${String(count)}`)
|
||||
.join(', ')
|
||||
this.reportOk('access', summary)
|
||||
|
||||
// A price with nothing to click, or a purchase button with no price, is a card
|
||||
// somebody cannot act on.
|
||||
const unbuyable = games.filter((game: GameDto): boolean =>
|
||||
game.accessVerdict === 'purchasable' && game.purchaseUrl === null)
|
||||
if (unbuyable.length > 0) {
|
||||
this.reportBad('purchase links', `${String(unbuyable.length)} priced titles have nowhere to buy them`)
|
||||
} else if ((counts.get('purchasable') ?? 0) > 0) {
|
||||
this.reportOk('purchase links', 'every priced title has one')
|
||||
}
|
||||
}
|
||||
|
||||
private reportListing (games: readonly GameDto[]): void {
|
||||
const installable = games.filter((game: GameDto): boolean => game.installable)
|
||||
const native = installable.filter((game: GameDto): boolean => game.mode === 'app').length
|
||||
@@ -185,3 +226,19 @@ void new SmokeTest().run().then(
|
||||
process.exitCode = 1
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* The token from the environment, for every store.
|
||||
*
|
||||
* Deliberately not per store: this is a test harness pointed at one catalog at a time,
|
||||
* and a keyed map here would be ceremony around a single value. Nothing writes — a
|
||||
* smoke run must not leave a credential behind on the machine that ran it.
|
||||
*/
|
||||
function envCredentials (): CredentialRepository {
|
||||
const token = process.env['SMOKE_TOKEN'] ?? ''
|
||||
return {
|
||||
readToken: (): string | null => (token.length > 0 ? token : null),
|
||||
writeToken: (storeId: string, value: string): void => { void storeId; void value },
|
||||
clearToken: (storeId: string): void => { void storeId }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AccountDto, SignInFinishedDto, SignInPromptDto } from './dto/AccountDto'
|
||||
import type { AppStateDto } from './dto/AppStateDto'
|
||||
import type { CatalogListingDto } from './dto/CatalogListingDto'
|
||||
import type { InstalledStoreDto } from './dto/InstalledStoreDto'
|
||||
@@ -30,9 +31,19 @@ export interface BridgeApi {
|
||||
removeGame: (name: string) => Promise<void>
|
||||
launchGame: (name: string) => Promise<boolean>
|
||||
|
||||
readAccount: () => Promise<AccountDto>
|
||||
/** Answers with the code to show; how it ended arrives on `onSignInFinished`. */
|
||||
beginSignIn: () => Promise<SignInPromptDto>
|
||||
cancelSignIn: () => Promise<void>
|
||||
signOut: () => Promise<AccountDto>
|
||||
|
||||
listRegistryStores: () => Promise<RegistryResultDto>
|
||||
installStore: (store: RegistryStoreDto) => Promise<InstalledStoreDto>
|
||||
/** A catalog the registry does not list; the name is derived from the address. */
|
||||
installCatalog: (catalogUrl: string) => Promise<InstalledStoreDto>
|
||||
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>
|
||||
openUrl: (url: string) => Promise<boolean>
|
||||
@@ -40,6 +51,7 @@ export interface BridgeApi {
|
||||
onLog: (listener: StreamListener<string>) => void
|
||||
onSyncEvent: (listener: StreamListener<SyncEventDto>) => void
|
||||
onBusyChanged: (listener: StreamListener<boolean>) => void
|
||||
onSignInFinished: (listener: StreamListener<SignInFinishedDto>) => void
|
||||
}
|
||||
|
||||
/** The name the bridge is published under on `window`. */
|
||||
|
||||
@@ -19,14 +19,23 @@ export const IPC_CHANNELS = {
|
||||
catalogRemoveGame: 'catalog:removeGame',
|
||||
catalogLaunchGame: 'catalog:launchGame',
|
||||
|
||||
accountRead: 'account:read',
|
||||
accountBeginSignIn: 'account:beginSignIn',
|
||||
accountCancelSignIn: 'account:cancelSignIn',
|
||||
accountSignOut: 'account:signOut',
|
||||
|
||||
storeListRegistry: 'store:listRegistry',
|
||||
storeInstallStore: 'store:installStore',
|
||||
storeInstallCatalog: 'store:installCatalog',
|
||||
storeSelectStore: 'store:selectStore',
|
||||
storeRemoveStore: 'store:removeStore',
|
||||
|
||||
/** Main to renderer, one way. */
|
||||
streamLog: 'stream:log',
|
||||
streamSyncEvent: 'stream:syncEvent',
|
||||
streamBusyChanged: 'stream:busyChanged'
|
||||
streamBusyChanged: 'stream:busyChanged',
|
||||
/** How a sign-in ended, once the browser half is done. */
|
||||
streamSignInFinished: 'stream:signInFinished'
|
||||
} as const
|
||||
|
||||
export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]
|
||||
|
||||
@@ -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 { StorePathsDto } from './StorePathsDto'
|
||||
|
||||
@@ -6,4 +7,12 @@ export interface CatalogListingDto {
|
||||
readonly games: readonly GameDto[]
|
||||
readonly skipped: readonly string[]
|
||||
readonly paths: StorePathsDto | null
|
||||
/**
|
||||
* Where this machine stood with the store when the catalog was read.
|
||||
*
|
||||
* Carried with the listing rather than asked for separately, because the entitlement
|
||||
* each entry reports is only meaningful next to whether anybody was signed in when
|
||||
* the catalog answered.
|
||||
*/
|
||||
readonly account: AccountDto
|
||||
}
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
/** How a title runs: unpacked on this machine, or served as a web build. */
|
||||
export type GameModeDto = 'app' | 'web'
|
||||
|
||||
/**
|
||||
* Whether this person can install this title.
|
||||
*
|
||||
* Separate from `installable`, which is about the machine. A title can have a perfectly
|
||||
* good build for this architecture and still not be yours.
|
||||
*
|
||||
* - `open` — nothing to own; install it
|
||||
* - `entitled` — owned; install it
|
||||
* - `purchasable` — not owned, and here is the price
|
||||
* - `signInRequired` — the catalog would say, if it knew who was asking
|
||||
*/
|
||||
export type AccessVerdictDto = 'open' | 'entitled' | 'purchasable' | 'signInRequired'
|
||||
|
||||
/** Why a title cannot be installed on this machine. */
|
||||
export type UnavailableReasonDto = 'platformOff' | 'hostAsset' | 'noAsset' | 'vetoed'
|
||||
|
||||
@@ -30,4 +43,9 @@ export interface GameDto {
|
||||
readonly installable: boolean
|
||||
readonly unavailableReason: UnavailableReasonDto | null
|
||||
readonly unavailableDetail: string | null
|
||||
readonly accessVerdict: AccessVerdictDto
|
||||
/** Already formatted for the window's locale; null where there is no price to show. */
|
||||
readonly priceLabel: string | null
|
||||
/** Opened in the person's own browser. Null where the catalog named none. */
|
||||
readonly purchaseUrl: string | null
|
||||
}
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
*/
|
||||
export const ENGLISH_MESSAGES = {
|
||||
appName: 'WarpEngine Client',
|
||||
refresh: 'Refresh',
|
||||
refresh: 'Refresh the catalog',
|
||||
install: 'Install',
|
||||
update: 'Update',
|
||||
upgrade: 'Upgrade',
|
||||
play: 'Play',
|
||||
open: 'Open',
|
||||
remove: 'Remove',
|
||||
uninstall: 'Uninstall',
|
||||
moreActions: 'More actions',
|
||||
installed: 'installed',
|
||||
native: 'native',
|
||||
hosted: 'hosted',
|
||||
@@ -24,12 +25,22 @@ export const ENGLISH_MESSAGES = {
|
||||
menu: 'Menu',
|
||||
stores: 'Stores',
|
||||
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',
|
||||
categories: 'Categories',
|
||||
catAll: 'Everything',
|
||||
catInstalled: 'Installed',
|
||||
catUpdates: 'Updates',
|
||||
catAvailable: 'Not installed',
|
||||
catOwned: 'Owned',
|
||||
catPurchasable: 'To buy',
|
||||
catUnsupported: 'Not for this machine',
|
||||
catPlatform: 'Platform',
|
||||
catMode: 'Kind',
|
||||
@@ -51,7 +62,27 @@ export const ENGLISH_MESSAGES = {
|
||||
failed: 'failed',
|
||||
removed: 'removed',
|
||||
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
|
||||
|
||||
/** Every string the window can show, by key. */
|
||||
|
||||
@@ -6,12 +6,13 @@ import type { MessageBundle } from './MessageBundle'
|
||||
*/
|
||||
export const HUNGARIAN_MESSAGES: MessageBundle = {
|
||||
appName: 'WarpEngine Client',
|
||||
refresh: 'Frissítés',
|
||||
refresh: 'Katalógus frissítése',
|
||||
install: 'Telepítés',
|
||||
update: 'Frissítés',
|
||||
upgrade: 'Frissítés',
|
||||
play: 'Indítás',
|
||||
open: 'Megnyitás',
|
||||
remove: 'Eltávolítás',
|
||||
uninstall: 'Eltávolítás',
|
||||
moreActions: 'További műveletek',
|
||||
installed: 'telepítve',
|
||||
native: 'natív',
|
||||
hosted: 'hosztolt',
|
||||
@@ -24,12 +25,22 @@ export const HUNGARIAN_MESSAGES: MessageBundle = {
|
||||
menu: 'Menü',
|
||||
stores: 'Store-ok',
|
||||
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',
|
||||
categories: 'Kategóriák',
|
||||
catAll: 'Minden',
|
||||
catInstalled: 'Telepítve',
|
||||
catUpdates: 'Frissítés',
|
||||
catAvailable: 'Nincs telepítve',
|
||||
catOwned: 'Birtokolt',
|
||||
catPurchasable: 'Megvehető',
|
||||
catUnsupported: 'Erre a gépre nem',
|
||||
catPlatform: 'Platform',
|
||||
catMode: 'Fajta',
|
||||
@@ -51,5 +62,25 @@ export const HUNGARIAN_MESSAGES: MessageBundle = {
|
||||
failed: 'hiba',
|
||||
removed: 'eltávolítva',
|
||||
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.'
|
||||
}
|
||||
|
||||