Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31da869800 | ||
|
|
6285d93790 | ||
|
|
255c588cbd | ||
|
|
3d42355189 | ||
|
|
26c7aa9be1 | ||
|
|
e35a72336a | ||
|
|
82590d3ec4 | ||
|
|
045c7bf5b7 | ||
|
|
06f3f2a3b1 | ||
|
|
8511ccbef8 | ||
|
|
a364a5ce5f | ||
|
|
7026e0cc6a | ||
|
|
2f725fdd14 | ||
|
|
526c67b069 | ||
|
|
3d63c8a0b0 | ||
|
|
a25acf6e35 | ||
|
|
cd5361e222 | ||
|
|
0f5da38a27 | ||
|
|
cb8a28b156 | ||
|
|
85c6d33b05 | ||
|
|
e635a032ea |
@@ -1,2 +1,3 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# The pipeline lives in the repository rather than in the update server's
|
||||
# `/build/config` extension. That extension serves game-platform pipelines, which
|
||||
# build a cartridge and publish it into the site's catalog; this one builds a desktop
|
||||
# application and publishes it to a Gitea release. Different product, different target.
|
||||
#
|
||||
# What CI can and cannot do here: Linux and Windows packages are built in containers —
|
||||
# Windows through Wine — while the **macOS package stays a local build**, because
|
||||
# Apple's toolchain and its signing exist only on a Mac. A release therefore gets its
|
||||
# Linux and Windows assets from this pipeline and its macOS assets from `make release`.
|
||||
when:
|
||||
- event: [push, manual]
|
||||
branch: master
|
||||
- event: tag
|
||||
|
||||
variables:
|
||||
# The official electron-builder images: Node with the packaging tools, and the same
|
||||
# image plus Wine, which is what lets a Windows installer be built on Linux.
|
||||
- &node_image 'electronuserland/builder:22'
|
||||
- &wine_image 'electronuserland/builder:22-wine'
|
||||
|
||||
steps:
|
||||
- name: check
|
||||
image: *node_image
|
||||
commands:
|
||||
- node --version
|
||||
- npm ci
|
||||
- npm run typecheck
|
||||
- npm run lint
|
||||
# The window test wants a display and a store on the machine; that check belongs
|
||||
# where there is one. The bridge check runs here unconditionally — it needs nothing
|
||||
# but Node, now that the store engine is part of the application.
|
||||
- npm run smoke
|
||||
|
||||
# A quarter of a gigabyte of packages is not worth building on every push, so the
|
||||
# two builds run when a release is being cut — or when asked for by hand.
|
||||
- name: linux
|
||||
image: *node_image
|
||||
commands:
|
||||
- npm run dist:linux
|
||||
- scripts/ci-verify-packages.sh '*.AppImage' '*.deb'
|
||||
when:
|
||||
- event: [tag, manual]
|
||||
|
||||
- name: windows
|
||||
image: *wine_image
|
||||
commands:
|
||||
- npm run dist:win
|
||||
- scripts/ci-verify-packages.sh '*.exe'
|
||||
when:
|
||||
- event: [tag, manual]
|
||||
|
||||
# Only on a tag, and only what this pipeline built: the macOS assets are uploaded
|
||||
# from the Mac that can sign them.
|
||||
- name: release
|
||||
image: alpine
|
||||
environment:
|
||||
# Needed. Woodpecker does hand steps a forge credential — a manual build printed
|
||||
# one — but a build started by the tag webhook does not get it: the first tag build
|
||||
# died here with no credential at all. So the token is a repository secret, and the
|
||||
# script still falls back to the forge credential when it is there.
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
commands:
|
||||
- apk add --no-cache curl jq
|
||||
# No globs on the command line: the package names have spaces in them.
|
||||
- scripts/ci-upload.sh
|
||||
when:
|
||||
- event: tag
|
||||
@@ -0,0 +1,128 @@
|
||||
# WarpEngine Client — the front door to the npm scripts.
|
||||
#
|
||||
# Everything here is a thin wrapper: the app is an Electron project, so npm still
|
||||
# does the work. The Makefile exists so the useful sequences have names, and so
|
||||
# `make release` is one command rather than a build followed by remembered tea
|
||||
# invocations.
|
||||
#
|
||||
# make list the targets
|
||||
# make setup install the dependencies
|
||||
# make check typecheck, lint and both test suites
|
||||
# make dist package for this machine
|
||||
# make release package and publish to Gitea
|
||||
#
|
||||
# Publishing assumes `tea` is installed and logged in — the devarea repo has
|
||||
# `make tea` for that.
|
||||
|
||||
SHELL := /bin/sh
|
||||
SCRIPTS := scripts
|
||||
NODE_MIN := 22
|
||||
|
||||
# The version is package.json's, so the release tag never drifts from the app. Read with
|
||||
# node, which this project already requires — nothing here needs an interpreter the app
|
||||
# itself no longer depends on.
|
||||
VERSION := $(shell node -p 'require("./package.json").version')
|
||||
TAG ?= v$(VERSION)
|
||||
|
||||
# Which site's store registry a packaged build reads. Empty means the default in
|
||||
# package.json (ours); set it to build a client for somebody else's catalog:
|
||||
#
|
||||
# make dist STORES_API=https://games.example.org/api/stores
|
||||
#
|
||||
# It is baked into the package's own package.json, so the built app carries it. A runtime
|
||||
# STORES_API still overrides it, which is for trying something out rather than shipping.
|
||||
STORES_API ?=
|
||||
BUILDER_ARGS := $(if $(STORES_API),-- --config.extraMetadata.warpEngine.registryUrl=$(STORES_API),)
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help setup node-check build typecheck lint lint-fix check start smoke uitest storetest icons test \
|
||||
dist dist-mac dist-win dist-linux release publish clean distclean version
|
||||
|
||||
help: ## List available targets
|
||||
@echo "WarpEngine Client $(VERSION) — usage: make <target>"
|
||||
@echo
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \
|
||||
awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
|
||||
@echo
|
||||
@echo " Variables: TAG=$(TAG) TEA_LOGIN=ttg REPO=<owner/name> NOTES=RELEASE_NOTES.md"
|
||||
|
||||
node-check: ## Check the Node version Electron's installer needs
|
||||
@node -e 'const [maj] = process.versions.node.split("."); \
|
||||
if (Number(maj) < $(NODE_MIN)) { \
|
||||
console.error("Node $(NODE_MIN)+ is needed to install Electron (found " + process.versions.node + \
|
||||
"): its installer is ESM-only. The packaged app carries its own runtime."); \
|
||||
process.exit(1); \
|
||||
} else { console.log("node " + process.versions.node + " ok"); }'
|
||||
|
||||
setup: node-check ## Install the dependencies
|
||||
npm install
|
||||
|
||||
build: ## Compile TypeScript and bundle the preload and the renderer
|
||||
npm run build
|
||||
|
||||
typecheck: ## Type-check everything, emitting nothing
|
||||
npm run typecheck
|
||||
|
||||
lint: ## Lint with the strict rule set
|
||||
npm run lint
|
||||
|
||||
lint-fix: ## Lint and fix what can be fixed automatically
|
||||
npm run lint:fix
|
||||
|
||||
# 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 every test suite
|
||||
|
||||
start: ## Run the app against whatever store is installed
|
||||
npm start
|
||||
|
||||
smoke: ## Drive the store bridge with no window at all
|
||||
npm run smoke
|
||||
|
||||
uitest: ## Load the window once and report what rendered
|
||||
npm run uitest
|
||||
|
||||
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)
|
||||
|
||||
dist-mac: node-check ## Package for macOS (ad-hoc signed, see the README)
|
||||
npm run dist:mac $(BUILDER_ARGS)
|
||||
|
||||
dist-win: node-check ## Package for Windows
|
||||
npm run dist:win $(BUILDER_ARGS)
|
||||
|
||||
dist-linux: node-check ## Package for Linux
|
||||
npm run dist:linux $(BUILDER_ARGS)
|
||||
|
||||
publish: ## Upload the packages already in dist/ to the Gitea release
|
||||
@TAG=$(TAG) $(SCRIPTS)/release.sh
|
||||
|
||||
# clean first: dist/ keeps earlier builds, and a release should be made of exactly
|
||||
# what this version produced.
|
||||
release: clean dist publish ## Package for this machine and publish it
|
||||
|
||||
clean: ## Remove the compiled output and the built packages
|
||||
rm -rf build dist
|
||||
|
||||
distclean: clean ## Remove the packages and the dependencies
|
||||
rm -rf node_modules
|
||||
|
||||
version: ## Show the versions involved
|
||||
@echo "app $(VERSION) (tag $(TAG))"
|
||||
@printf "node "; node --version 2>/dev/null || echo "missing"
|
||||
@printf "npm "; npm --version 2>/dev/null || echo "missing"
|
||||
@printf "electron "; node -p "require('./package.json').devDependencies.electron" 2>/dev/null || echo "missing"
|
||||
@printf "typescript "; npx tsc --version 2>/dev/null || echo "missing"
|
||||
@printf "eslint "; npx eslint --version 2>/dev/null || echo "missing"
|
||||
@printf "tea "; tea --version 2>/dev/null | head -1 || echo "missing — devarea: make tea"
|
||||
@printf "registry "; node -p 'require("./package.json").warpEngine.registryUrl'
|
||||
@if [ -n "$(STORES_API)" ]; then printf " build override: %s\n" "$(STORES_API)"; fi
|
||||
@@ -1,104 +1,526 @@
|
||||
# warp-engine-desktop-gui — a window for the desktop store
|
||||
# warp-engine-client — the WarpEngine Client app
|
||||
|
||||
A graphical client for
|
||||
[`warp-engine-desktop-store`](https://git.teletypegames.org/stores/warp-engine-desktop-store):
|
||||
the catalog as a grid of cards, one click to install a title into your own
|
||||
application menu, one to play it, one to remove it. Linux, macOS and Windows.
|
||||
The client for a WarpEngine store: the catalog as a grid of cards, one click to install
|
||||
a title into your own application menu, one to play it, one to remove it. Linux, macOS
|
||||
and Windows.
|
||||
|
||||
The CLI stays the product; this is its front door. Every action here runs
|
||||
`desktop_store.py`, so there is one catalog logic, one state file and one delete
|
||||
guard — the window never touches the filesystem itself.
|
||||
**The store engine is part of this application.** Reading the catalog, choosing which
|
||||
release fits this machine, unpacking it, writing the menu entry and remembering what
|
||||
went where all happen in process — there is no interpreter to find and no child process
|
||||
to parse. What lands on disk has not changed: `config.json` and `state.json` keep the
|
||||
shape the shell engine wrote, so a machine whose library was installed by the CLI keeps
|
||||
it, and the engine's own defaults still decide everything a store does not configure.
|
||||
|
||||
It is also **the Windows install path**. The store's own installer is
|
||||
`curl … | sh`, which Windows does not have; this app downloads the store engine
|
||||
itself, into the same folder the shell installer would use.
|
||||
That also makes this the only install path that needs nothing of the machine. The shell
|
||||
store's installer was `curl … | sh`, which Windows does not have.
|
||||
|
||||
Which store it installs is not baked in: the client asks a registry — `GET
|
||||
/api/stores` on the site — and each record says what the store is called, which
|
||||
catalog it serves and where its configuration lives.
|
||||
|
||||
## What it needs
|
||||
|
||||
- **Python 3** on the machine, because the store is a Python program. The app
|
||||
looks for `python3`, `python` and `py -3`, and says so plainly if none answer.
|
||||
- Nothing else at runtime. No Node, no package manager, no admin rights: the
|
||||
store installs under your own user account.
|
||||
- **Nothing.** No interpreter, no package manager, no admin rights: the app carries its
|
||||
own runtime and the store installs under your own user account.
|
||||
|
||||
To *develop* it you also need **Node 22 or newer** — see below — and nothing else: the
|
||||
toolchain (TypeScript, ESLint, esbuild, electron-builder) installs with `make setup`.
|
||||
|
||||
## Install
|
||||
|
||||
Grab the package for your machine from the
|
||||
[releases](https://git.teletypegames.org/stores/warp-engine-desktop-gui/releases)
|
||||
[releases](https://git.teletypegames.org/stores/warp-engine-client/releases)
|
||||
and open it. On first run, if there is no store on the machine yet, the window
|
||||
offers to download one — that is the whole setup.
|
||||
|
||||
The macOS build is **not signed or notarised**, so the first open needs
|
||||
*right-click ▸ Open* (or *System Settings ▸ Privacy & Security*). Nothing the
|
||||
store itself downloads is affected: those files are fetched by Python, which does
|
||||
not set the quarantine flag.
|
||||
### Opening it on macOS
|
||||
|
||||
The build is ad-hoc signed but **not notarised**, so macOS asks before running a
|
||||
copy that came from a browser. The reliable way through:
|
||||
|
||||
```sh
|
||||
xattr -dr com.apple.quarantine "/Applications/WarpEngine Client.app"
|
||||
```
|
||||
|
||||
If macOS offers *Open Anyway* under **System Settings ▸ Privacy & Security** after
|
||||
a blocked attempt, that works as well. Notarisation is the only thing that removes
|
||||
the step entirely, and it needs a paid Apple Developer ID.
|
||||
|
||||
Nothing the store itself downloads is affected: the app fetches those files over its
|
||||
own HTTP client, which does not set the quarantine flag.
|
||||
|
||||
**v1.0.0 could not be opened at all** — it reported *"is damaged"*. The bundle had
|
||||
never been signed; only its main executable carried the linker's ad-hoc signature,
|
||||
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
|
||||
and there is nothing to decide; several and the setup screen shows a picker.
|
||||
|
||||
```json
|
||||
[
|
||||
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org" },
|
||||
{ "name": "Some Other Store", "catalogUrl": "https://games.example.org" }
|
||||
]
|
||||
```
|
||||
|
||||
**A name and a catalog are the whole record.** The store engine's built-in defaults
|
||||
already cover the host-to-asset mapping, the install modes, the platforms and the
|
||||
behaviour, so what is actually missing from them is identity — and identity is all a
|
||||
registry says. Nothing a record carries decides where files go: how a store behaves is
|
||||
fixed per installed client, which knows its own machine, and a copy of that on a server
|
||||
would be a second authority over decisions this side has already made.
|
||||
|
||||
From a record the client works out the rest:
|
||||
|
||||
- **the store id** — which names the store home and the folder games land in — is a slug
|
||||
of the catalog host (`teletypegames.org` becomes `teletypegames`), or of the display
|
||||
name if that fails. Derived from the *catalog* on purpose: the catalog is what a store
|
||||
is, so two records naming the same one are the same store and land in the same place.
|
||||
Reinstalling therefore never orphans what is already installed.
|
||||
- **the games folder** is that same slug inside the OS's usual place for programs, and it
|
||||
is the only subtree this store will ever delete from. That is the whole of how two
|
||||
stores on one machine stay out of each other's files: a subfolder, derived here.
|
||||
- **released, archived and demo** titles are listed, where the engine alone would show
|
||||
released and archived only — a catalog that publishes a demo means it to be played.
|
||||
|
||||
Because a record has no paths in it and no config, there is nothing for the window to
|
||||
tamper with: `RegistryStoreDtoMapper.toModel` can take its choice at face value, and the
|
||||
config that lands on disk is written by the installer from the engine's own defaults.
|
||||
|
||||
What the defaults produce, for a record with no repository: the games land in a
|
||||
folder named after the store id, and released, archived **and demo** titles are
|
||||
listed — a catalog that publishes a demo means it to be played.
|
||||
|
||||
The registry address is the single thing about a particular site left in the client,
|
||||
and it is decided in three places, most specific first:
|
||||
|
||||
```sh
|
||||
STORES_API=http://127.0.0.1:8731/stores npm start # runtime: for trying something out
|
||||
make dist STORES_API=https://games.example.org/api/stores # build: for shipping it
|
||||
```
|
||||
|
||||
The build variant is baked into the packaged app's own `package.json`
|
||||
(`warpEngine.registryUrl`, written by `electron-builder --config.extraMetadata`), so a
|
||||
client built for somebody else's catalog needs no source change and no environment on the
|
||||
user's machine. With neither set, the address is ours.
|
||||
|
||||
Adding a store is therefore a database row on the site — see its ActiveAdmin
|
||||
panel — and not a release of this app.
|
||||
|
||||
## Use
|
||||
|
||||
- **Install all** fetches everything the catalog offers for this machine.
|
||||
- A card's button is **Install**, **Update**, or **Play** / **Open** once it is
|
||||
there. **Remove** takes a title back out.
|
||||
- Each card says whether it is **native** — unpacked and run locally, works
|
||||
offline — or **hosted**: a browser build the catalog serves rather than
|
||||
packages, so its entry opens a page and needs the network.
|
||||
- The **Log** drawer at the bottom carries the store's own output verbatim, and
|
||||
next to it are buttons that open the two folders everything lands in.
|
||||
- The language follows the system and can be switched; **English and Hungarian**.
|
||||
Everything that is not a title lives in the **side menu** on the left, and the
|
||||
`☰` button in the bar folds it away — the state is remembered between runs.
|
||||
|
||||
- **Stores** lists every store on this machine, the open one marked. Clicking
|
||||
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. 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
|
||||
WarpEngine catalog, so these are the categories there are.
|
||||
- **Log** opens the store's own output — its words, verbatim — together with the two
|
||||
folders everything lands in. Off screen until asked for: the window has no footer,
|
||||
because a permanent bar of absolute paths is not what a store is for.
|
||||
- **Language** follows the system and can be switched; **English and Hungarian**.
|
||||
|
||||
In the grid, a card's button is **Install**, **Update**, or **Play** / **Open**
|
||||
once it is there. **Remove** takes a title back out. Each card says whether it is
|
||||
**native** — unpacked and run locally, works offline — or **hosted**: a browser
|
||||
build the catalog serves rather than packages, so its entry opens a page and needs
|
||||
the network.
|
||||
|
||||
**Everything in the catalog is listed, including what this machine cannot install.**
|
||||
Those cards are dimmed, carry an *unsupported platform* or *no build for this machine*
|
||||
badge with the engine's own explanation under it, and have nothing to press. A store
|
||||
that hides them leaves you wondering whether the catalog is small or your machine is
|
||||
unusual; this way it says which. They have a category of their own — *Not for this
|
||||
machine* — and they are left out of the native/hosted counts, because a title with no
|
||||
build has no mode to be counted under.
|
||||
|
||||
Every card carries a band of box art the same height — the first letter of the
|
||||
title when the catalog has no image — so titles and buttons line up across a row.
|
||||
Until this was photographed, the grid was quietly broken: the rows split the
|
||||
window's height evenly instead of following their content, which collapsed the art
|
||||
to nothing and clipped the buttons out of sight.
|
||||
|
||||
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.
|
||||
|
||||
## Development
|
||||
|
||||
`make` is the front door; it wraps the npm scripts so the useful sequences have
|
||||
names. `make` on its own lists everything.
|
||||
|
||||
| Target | What it does |
|
||||
|---|---|
|
||||
| `make setup` | install the dependencies (checks the Node version first) |
|
||||
| `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 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 |
|
||||
| `make dist` | package for this machine (`dist-mac`, `dist-win`, `dist-linux` to pick) |
|
||||
| `make publish` | upload the packages already in `dist/` to the Gitea release |
|
||||
| `make release` | **package and publish in one go** |
|
||||
| `make clean` | remove `build/` and the packages (`distclean` also drops `node_modules`) |
|
||||
| `make version` | the versions involved, including whether `tea` is there |
|
||||
|
||||
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
|
||||
them to the Gitea release. The pipeline is in this repository rather than served by the
|
||||
update server's `/build/config` extension: that extension serves game-platform
|
||||
pipelines, which build a cartridge and publish it into the site's catalog, and this
|
||||
builds an application and publishes to a release.
|
||||
|
||||
| Step | Image | What it does |
|
||||
|---|---|---|
|
||||
| `check` | `electronuserland/builder:22` | `npm ci`, type-check, lint, and the smoke test |
|
||||
| `linux` | `electronuserland/builder:22` | AppImage and deb |
|
||||
| `windows` | `electronuserland/builder:22-wine` | the NSIS installer and the portable exe, built through Wine |
|
||||
| `release` | `alpine` | on a tag only: **creates the release** and attaches what this pipeline built |
|
||||
|
||||
**macOS stays a local build.** Apple's toolchain and its signing only exist on a Mac.
|
||||
So the whole of a release is:
|
||||
|
||||
1. bump the version, commit, and push the tag: `git tag v1.4.0 && git push origin v1.4.0`;
|
||||
2. the pipeline builds Linux and Windows, **creates the release** with `RELEASE_NOTES.md`
|
||||
as its body, and attaches those four packages;
|
||||
3. on a Mac, `make release` builds the macOS package and pushes it onto the same release.
|
||||
|
||||
The window test is local as well: it needs a display and a store on the machine.
|
||||
|
||||
The `release` step needs a **`gitea_token`** repository secret — a Gitea token with
|
||||
write access to this repository:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm start # the window, against whatever store is installed
|
||||
npm run smoke # the bridge only: no window, no Electron
|
||||
npm run uitest # loads the window once and reports what rendered
|
||||
npm run dist:mac # or dist:win / dist:linux
|
||||
woodpecker-cli repo secret add --repository stores/warp-engine-client \
|
||||
--name gitea_token --value <token> --event tag
|
||||
```
|
||||
|
||||
Woodpecker does hand steps a forge credential of its own, and the script uses it when the
|
||||
secret is absent, but that is not something to rely on: a **manual** build has it and a
|
||||
build started by the **tag webhook** does not, which is how the first tag build failed —
|
||||
after building all four packages. Gitea takes either credential as `token …` or
|
||||
`Bearer …` depending on how it was issued, so the script probes which of the two `/user`
|
||||
accepts instead of assuming, and logs which one it used.
|
||||
|
||||
There are two publishers on purpose: `scripts/release.sh` drives `tea`, which is logged
|
||||
in on a workstation, and `scripts/ci-upload.sh` speaks the API with whatever credential
|
||||
CI has. Each is short enough to read in full; one script with two ways to authenticate
|
||||
would not be.
|
||||
|
||||
Both build steps end by checking what they produced: a package under 10 MB did not
|
||||
finish. That check exists because a half-finished Wine build leaves a stub *named* like
|
||||
the real installer — 162 KB of it — and `ls` is perfectly happy with that.
|
||||
|
||||
**The Windows step cannot be rehearsed on an Apple Silicon Mac.** Wine assumes 4 KB
|
||||
memory pages and this host has 16 KB ones, so an emulated amd64 container dies with
|
||||
`anon_mmap_fixed: Assertion failed`. It is a property of the machine, not of the
|
||||
pipeline; the x86_64 runner is where that step is proven. The Linux step was rehearsed
|
||||
locally in the same image and produced both packages.
|
||||
|
||||
The Windows installer is **not signed**: Windows will warn about an unknown publisher
|
||||
until there is a code-signing certificate. Linux packages carry no signature by
|
||||
convention.
|
||||
|
||||
### Publishing a release
|
||||
|
||||
```sh
|
||||
make release
|
||||
```
|
||||
|
||||
This is the **macOS half** of a release; the Linux and Windows packages come from the
|
||||
pipeline when the tag is pushed (see above). The tag comes from `package.json`, so
|
||||
`npm version patch` is the only place a version is set. The release is created if it is
|
||||
not there yet — either half can go first — and an attachment whose name is already on it
|
||||
is **replaced** rather than refused, so a rebuild and a second `make publish` lands
|
||||
rather than erroring.
|
||||
|
||||
Release notes come from `RELEASE_NOTES.md` when the file is present, otherwise the
|
||||
release gets a one-line note. The repository is read from `origin`, so a fork
|
||||
publishes to the fork.
|
||||
|
||||
Each upload is retried up to three times, and the existing attachment is dropped
|
||||
before every attempt so a retry cannot leave two copies. A 100 MB upload does fail
|
||||
on its own: publishing 1.2.0 got *"invalid username, password or token"* on the
|
||||
second package while the first had just gone up with the same token, and the same
|
||||
command succeeded immediately afterwards.
|
||||
|
||||
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`.
|
||||
|
||||
```sh
|
||||
make publish TAG=v1.0.2 # a tag other than package.json's
|
||||
scripts/release.sh dist/one-file.dmg # just one package
|
||||
```
|
||||
|
||||
**Node 22 or newer is needed to install**, not to run: Electron's own installer
|
||||
is ESM-only, and older Node cannot `require()` it. The packaged app carries its
|
||||
own runtime.
|
||||
|
||||
Both test scripts accept a sandbox store instead of the real one, which is how
|
||||
this repository is tested without touching a working installation:
|
||||
`npm run uitest` runs with its own user-data directory and without the
|
||||
single-instance lock. Otherwise a copy the user already has open swallows the test
|
||||
process, which exits 0 and reads as a pass.
|
||||
|
||||
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
|
||||
|
||||
| File | What it does |
|
||||
TypeScript, in layers, with the dependency rule pointing inward. **[STRUCTURE.md](STRUCTURE.md)
|
||||
is the map** — the layers, every pattern in use, and the naming rules. The short version:
|
||||
|
||||
| Layer | What lives there |
|
||||
|---|---|
|
||||
| `main.js` | the window, the IPC, and the one-call-at-a-time guard |
|
||||
| `preload.js` | the entire surface the renderer gets — no Node reaches it |
|
||||
| `lib/store.js` | finds the store and Python, runs the CLI, parses its JSON |
|
||||
| `lib/bootstrap.js` | downloads the engine, the shared core and a config |
|
||||
| `lib/i18n.js` | the two string tables |
|
||||
| `renderer/` | plain HTML, CSS and JS — no framework, no build step |
|
||||
| `src/shared/` | the IPC channel table, the bridge contract, the DTOs, the two message bundles |
|
||||
| `src/domain/` | models, ports and errors — no Electron, no Node |
|
||||
| `src/application/` | services and the domain → DTO mappers |
|
||||
| `src/infrastructure/` | the adapters: the store engine, HTTP, the archive reader, the filesystem, Electron itself |
|
||||
| `src/main/` | the window, the IPC controllers, the composition root, the self-test |
|
||||
| `src/preload/` | the bridge, bundled into one file — a sandboxed preload cannot require modules |
|
||||
| `src/renderer/` | the state store, the views and the renderer controllers |
|
||||
| `src/scripts/` | the smoke test: the same services with no window at all |
|
||||
| `scripts/release.sh` | creates the Gitea release and replaces its attachments |
|
||||
| `scripts/after-pack.js` | ad-hoc signs the macOS bundle during packaging |
|
||||
| `scripts/build-assets.mjs` | bundles the preload and the renderer, copies the page |
|
||||
|
||||
`contextIsolation` is on, `nodeIntegration` off, `sandbox` on, and the page
|
||||
carries a CSP that allows only its own script and stylesheet plus images over
|
||||
HTTPS. Links open in the real browser; the window itself never navigates.
|
||||
Two properties are worth stating because they are what the layers buy:
|
||||
|
||||
`lib/store.js` talks to the CLI through `--json`, which puts data on stdout and
|
||||
the human-readable log on stderr. That flag arrived with engine **1.1.0**, and the
|
||||
client checks: an older store is met with an offer to refresh it rather than a
|
||||
failed call.
|
||||
- **The catalog can be driven without a window.** `make smoke` assembles the same
|
||||
services against the same ports with no Electron in the process at all.
|
||||
- **The window never receives a filesystem path.** A `GameDto` carries no paths; the
|
||||
window asks to launch a title *by name* and the main process resolves what that means
|
||||
from the store's own state.
|
||||
|
||||
The bridge keeps an `ENGINES` list with one entry today. The RetroArch store has
|
||||
the same command shape, so a second entry is the whole change needed to drive it
|
||||
too — that is why the indirection is there.
|
||||
`contextIsolation` is on, `nodeIntegration` off, `sandbox` on, and the page carries a
|
||||
CSP that allows only its own script and stylesheet plus images over HTTPS. Links open
|
||||
in the real browser; the window itself never navigates.
|
||||
|
||||
`NativeStoreCatalogGateway` is the engine behind the `StoreCatalogGateway` port, and
|
||||
`src/infrastructure/engine/` is the engine itself: the catalog client, the release
|
||||
picker, the host match, the payload installer, the three launcher writers and the state
|
||||
file. Above the port nothing knows any of that exists, which is the point — a second
|
||||
host would be a second gateway, not a second code path.
|
||||
|
||||
`STORE_ENGINES` has one entry today. A RetroArch store writes playlists rather than
|
||||
menu entries, so it would be an entry there and a gateway of its own.
|
||||
|
||||
### Reading a zip without a dependency
|
||||
|
||||
Node has no zip reader and this application has **no runtime dependencies**, so
|
||||
`src/infrastructure/archive/ZipArchive.ts` is one over `node:zlib` — about 150 lines that
|
||||
walk the central directory, inflate `stored` and `deflate` entries, and restore the
|
||||
executable bit from each entry's external attributes. That last part is not a detail: the
|
||||
archive records it, and without it nothing the store installs can start.
|
||||
|
||||
It reads what our own release pipeline produces and refuses the rest: a zip64 archive, an
|
||||
unknown compression method and a path that would escape the destination are all errors
|
||||
rather than best guesses.
|
||||
|
||||
### Which WarpEngine served the catalog
|
||||
|
||||
Every WarpEngine API response carries a `WarpEngine-Version` header, so the client knows
|
||||
the engine's age without asking. `SUPPORTED_WARP_ENGINE_VERSIONS` lists the versions this
|
||||
client is written against, and `selectCatalogDialect` maps each one to the `CatalogDialect`
|
||||
that reads its catalog shape.
|
||||
|
||||
The switch over that list is exhaustive, which is the whole mechanism: adding a version to
|
||||
the array stops the build — in the type checker *and* in the linter — until somebody says
|
||||
what it reads like. A new engine version cannot arrive silently.
|
||||
|
||||
| What the header says | What happens |
|
||||
|---|---|
|
||||
| a supported version | its dialect reads the catalog, and the log names it |
|
||||
| nothing at all | read as the oldest supported version, which is what an engine older than 0.4.0 is |
|
||||
| older than anything supported | the same, and the log says so |
|
||||
| newer than anything supported | the newest dialect is tried anyway, with a warning that titles may be missed |
|
||||
|
||||
Three versions share one dialect today, because the catalog's shape has not changed
|
||||
across them. One class serving three versions is the honest way to say that.
|
||||
|
||||
## Verified, and not
|
||||
|
||||
Exercised on macOS (arm64): the store is discovered, the catalog lists, a sync
|
||||
installs, the window renders the installed state, and `npm run uitest` passes with
|
||||
the grid rendered and both languages in the picker. The bootstrap download was run
|
||||
into an empty directory and the resulting store answered the bridge.
|
||||
**2.2.0** — the registry record was cut back to a name and a catalog, so the whole
|
||||
install path was measured again against a local registry serving exactly that. The slug
|
||||
came out `teletypegames` from the catalog host, the home `teletypegames-desktop`, the
|
||||
games subfolder `teletypegames`, and installing the same record twice landed in the same
|
||||
home. A record carrying `config` and `storeRepositoryUrl` — the fields a stale client or a
|
||||
tampering renderer might still send — changed nothing, because neither exists in the model
|
||||
any more. The site side was migrated and its specs re-run; the frontend was built, which
|
||||
first required removing a dead `engines` list that had been failing `vue-tsc` on master.
|
||||
|
||||
Older entries below describe what was verified for the version they name, and some of
|
||||
them predate the store engine moving into this application.
|
||||
|
||||
The pipeline's commands were run in the same containers it uses, before the pipeline was
|
||||
committed: `electronuserland/builder:22` installs, type-checks, lints, passes the smoke
|
||||
test (registry reached, store skipped as it should be on a machine that has none) and
|
||||
produces the AppImage (128 MB) and the deb (100 MB). The Wine step could not be
|
||||
rehearsed here — see above — and the size check that guards it was tested against both
|
||||
outcomes: it rejects the 162 KB stub the failed Wine build left and accepts the two real
|
||||
Linux packages.
|
||||
|
||||
A store with no repository was installed end to end from a local registry serving
|
||||
one record with `storeRepositoryUrl: null`: the id came out as `teletypegames`, the
|
||||
engine and the shared core downloaded, the written config had the three sections,
|
||||
engine 1.1.0 accepted it, and it listed the same ten titles the configured store
|
||||
does — then a hosted title synced into a sandbox and its menu entry appeared. The
|
||||
setup gate was also photographed on a machine with no store at all.
|
||||
|
||||
The 1.3.0 refactor was measured rather than trusted: `make check` is clean — no type
|
||||
errors, no lint findings, both test suites green — the window was photographed before
|
||||
and after and the two are the same picture, and the packaged 1.3.0 bundle was run from
|
||||
`dist/` and drove the real store. The published package contains `build/` and
|
||||
`package.json` and nothing else: 111 entries, no sources, no toolchain.
|
||||
|
||||
Exercised on macOS (arm64), with the packaged app from the release rather than a
|
||||
dev run: the store is discovered, the catalog lists, a sync installs, the window
|
||||
renders the installed state, and `npm run uitest` passes with the grid rendered
|
||||
and both languages in the picker. The bootstrap download was run into an empty
|
||||
directory and the resulting store answered the bridge.
|
||||
|
||||
The grid was checked by looking at it, not only by counting nodes: `SELFTEST_SHOT`
|
||||
has the window capture itself, which is how the collapsed rows were found — the DOM
|
||||
had ten cards and twenty buttons all along, and every count passed while the page
|
||||
showed neither art nor buttons. A screenshot from outside the app is not available
|
||||
here, so the window takes its own.
|
||||
|
||||
The side menu was measured with two stores in one root — a sandbox copy alongside
|
||||
the real install — and `npm run uitest` clicks the store that is not open and
|
||||
checks that the bar, the grid and the categories follow. With a single store the
|
||||
switch is skipped, which is what the normal run reports.
|
||||
|
||||
The registry path was exercised against a local endpoint serving the same payload
|
||||
the site returns, with two records: one store whose repository has a `config.json`
|
||||
and one without. Both installed, and the engine listed all ten titles with the
|
||||
synthesised config. `npm run uitest` was run twice — with a store present it shows
|
||||
the grid, with none it shows the setup gate and its picker carries both names —
|
||||
and once more with the registry unreachable, which produces the retry gate.
|
||||
|
||||
The signing was measured rather than assumed, by setting the quarantine flag on a
|
||||
copy unzipped from the release artifact: `codesign --verify --deep --strict` is
|
||||
clean, and `syspolicy_check` reports only the expected *"adhoc signed"* warning.
|
||||
A quarantined copy is still stopped until it is approved — that part is Gatekeeper
|
||||
policy, not a fault in the package.
|
||||
|
||||
**Not tried on Linux or Windows.** The paths and the launch behaviour are written
|
||||
for them, and the store CLI itself has the same gap — `.desktop` and `.lnk`
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# WarpEngine Client 2.5.1
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
Nothing else changed: same store handling, same catalog, same sign-in.
|
||||
@@ -0,0 +1,325 @@
|
||||
# Structure
|
||||
|
||||
This is the map of the client: which layer may know about which, what every kind of
|
||||
class is called, and which pattern is used where. It is written to be read before
|
||||
adding anything — the point of the layout is that a new feature has an obvious place.
|
||||
|
||||
The application drives a program that **installs and deletes files**. That is why the
|
||||
rules below are strict rather than tasteful: an implicit `any` or a filesystem path
|
||||
that reaches the window is a safety question, not a style one.
|
||||
|
||||
## The layers
|
||||
|
||||
```
|
||||
shared ← contracts and strings both sides need (no logic, no I/O)
|
||||
domain ← models, ports, errors. Knows nothing about Electron or Node
|
||||
application ← services and DTO mappers. Orchestrates the domain through its ports
|
||||
infrastructure ← adapters: the store engine, HTTP, the filesystem, Electron itself
|
||||
main ← the Electron host: window, IPC controllers, composition root
|
||||
preload ← the bridge, and only the bridge
|
||||
renderer ← the window: state store, views, controllers
|
||||
```
|
||||
|
||||
**The dependency rule: imports point inward.** `domain` imports nothing but `shared`.
|
||||
`application` imports `domain` and `shared`. `infrastructure` implements `domain`
|
||||
ports. `main`, `preload` and `renderer` are hosts: they may import inward, and nothing
|
||||
imports them. There is no barrel file and no `index.ts` re-export — every import names
|
||||
the module it needs, so a cycle is visible in the diff that creates it.
|
||||
|
||||
Two consequences worth stating, because they are the reason the layout pays for
|
||||
itself:
|
||||
|
||||
- **`domain` and `application` never import `electron`.** The smoke test assembles the
|
||||
same services with no Electron at all (`src/scripts/SmokeTest.ts`), which is how the
|
||||
catalog is exercised in a terminal.
|
||||
- **The renderer never receives a filesystem path it could act on.** `GameDto` has no
|
||||
paths; a launch is asked for by name and resolved in the main process.
|
||||
|
||||
## The tree
|
||||
|
||||
```
|
||||
src/
|
||||
shared/
|
||||
contracts/
|
||||
IpcChannels.ts every channel name, frozen, in one table
|
||||
BridgeApi.ts the whole surface the window gets
|
||||
dto/ what crosses the bridge: plain, JSON-safe data
|
||||
i18n/
|
||||
EnglishMessages.ts the key set, and the English bundle
|
||||
HungarianMessages.ts typed against those keys
|
||||
MessageBundle.ts MessageBundle, Locale, LOCALES
|
||||
TranslationCatalog.ts locale resolution and bundle lookup
|
||||
domain/
|
||||
models/ Game, InstalledStore, RegistryStore, StorePaths, …
|
||||
ports/ the interfaces the application depends on
|
||||
errors/ DomainError and its subclasses, each with a code
|
||||
application/
|
||||
services/ CatalogService, StoreSelectionService, …
|
||||
mappers/ domain → DTO
|
||||
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
|
||||
repositories/ the port implementations
|
||||
http/ HttpTextClient, StoreHttpClient, HttpStatusError
|
||||
json/ JsonRecord: reading data that came from elsewhere
|
||||
config/ BuildConfiguration: what was decided when this was packaged
|
||||
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
|
||||
MainWindowFactory.ts the window and its security settings
|
||||
composition/ ServiceContainer: the composition root
|
||||
ipc/ IpcRouter, the controllers, the guard, argument readers
|
||||
streams/ WindowStreamBroadcaster: the three one-way streams
|
||||
diagnostics/ SelfTestRunner
|
||||
preload/
|
||||
preload.ts implements BridgeApi over ipcRenderer
|
||||
renderer/
|
||||
main.ts the entry point
|
||||
RendererApplication.ts wires views and controllers, owns the boot decision
|
||||
BridgeAccess.ts the typed window.storeApi
|
||||
state/ AppStore, CategoryFilter
|
||||
views/ one class per region of the window
|
||||
controllers/ one class per group of actions
|
||||
dom/ Dom.ts: the DOM chores
|
||||
index.html, style.css copied into the build as-is
|
||||
scripts/
|
||||
SmokeTest.ts the second composition root, with no window
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
Every pattern in the codebase is listed here. If a change needs a pattern that is not
|
||||
on this list, it belongs on this list.
|
||||
|
||||
### Ports and adapters
|
||||
|
||||
`domain/ports/*` are interfaces; `infrastructure/*` implements them; the composition
|
||||
root is the only file that knows which implementation is in use. This is what makes the
|
||||
store engine, the registry HTTP call and Electron's `shell` replaceable — by a stub in a
|
||||
test, by a local endpoint in development, by a second host's engine later.
|
||||
|
||||
It has already paid for itself once: the engine used to be a Python CLI driven as a child
|
||||
process, and replacing it with one that runs in process was a new adapter behind the same
|
||||
port. Nothing in `application`, `main` or `renderer` changed shape for it.
|
||||
|
||||
### Repository and Gateway
|
||||
|
||||
Both are ports; the distinction is what is behind them.
|
||||
|
||||
- **Repository** — a store of records this application owns the shape of:
|
||||
`InstalledStoreRepository`, `PreferencesRepository`, `StoreRegistryRepository`.
|
||||
- **Gateway** — something with a protocol of its own, whether or not it is another
|
||||
process: `StoreCatalogGateway` (the store engine), which today is
|
||||
`NativeStoreCatalogGateway` in this application and was a Python CLI before it. The port
|
||||
stays async because the work is: it downloads and unpacks.
|
||||
|
||||
### Service
|
||||
|
||||
`application/services/*` — one service per area of behaviour, no HTTP, no `fs`, no
|
||||
`child_process`. A service may depend on ports and on other services, never on a
|
||||
controller or a view.
|
||||
|
||||
### DTO and Mapper
|
||||
|
||||
Data crossing a boundary is a DTO, and a mapper converts. Two boundaries, two
|
||||
directions:
|
||||
|
||||
- `infrastructure/engine/dialects/*` — catalog JSON → typed catalog records. These are
|
||||
the only files that know a WarpEngine version's field names.
|
||||
- `infrastructure/engine/StoreConfigurationReader`, `StoreStateRepository` — the two
|
||||
snake_case files on disk → domain models. These are the only files that know the on-disk
|
||||
field names, which are the shell engine's and stay that way.
|
||||
- `application/mappers/*DtoMapper` — domain model → DTO for the bridge. Decisions the
|
||||
window must not make live here: the absolute box-art URL, whether a title can be
|
||||
launched at all.
|
||||
|
||||
### Composition root
|
||||
|
||||
`main/composition/ServiceContainer.ts` for the application, `scripts/SmokeTest.ts` for
|
||||
the headless check. Wiring happens in exactly these two places. No service constructs
|
||||
its own adapter, and there is no service locator or global registry — dependencies
|
||||
arrive through constructors.
|
||||
|
||||
### Controller and Router
|
||||
|
||||
`main/ipc/*IpcController` register their channels on `IpcRouter` and translate a
|
||||
channel invocation into one service call. They validate their arguments
|
||||
(`IpcArguments.ts`) and map results through DTO mappers. The router normalises errors
|
||||
so a `DomainError` crosses as `CODE: message`.
|
||||
|
||||
Renderer controllers (`renderer/controllers/*`) are the mirror image: a user action
|
||||
becomes one bridge call and one write to the state store.
|
||||
|
||||
### Single flight
|
||||
|
||||
`SingleFlightGuard` — one engine call at a time, because the store writes files and
|
||||
two writers would race. It reports its state, which is what lets the window disable
|
||||
exactly the controls that would start a second call and leave the filters and the log
|
||||
alive.
|
||||
|
||||
### Observer streams
|
||||
|
||||
Main pushes three one-way streams — log lines, progress events, busy state — through
|
||||
`WindowStreamBroadcaster`, which the engine sees as an `EngineProgressListener`. The
|
||||
renderer subscribes once, in `EngineStreamController`.
|
||||
|
||||
### State store and unidirectional flow
|
||||
|
||||
`renderer/state/AppStore.ts` holds the whole window state. Every mutator is named
|
||||
after what it changes and notifies afterwards; `RendererApplication` re-renders every
|
||||
view from the new state. Views never read each other and never hold state, so a
|
||||
listing can be thrown away and rebuilt.
|
||||
|
||||
Screens are state, not calls. The setup screen lives in the state as
|
||||
`gate: GatePresentation | null`, and that one field decides whether the gate or the
|
||||
grid is drawn. While it was two imperative calls the two disagreed: the gate went up
|
||||
and the empty-catalog line stayed on screen underneath it.
|
||||
|
||||
### Passive view
|
||||
|
||||
`renderer/views/*` — a view takes its DOM nodes and callbacks in the constructor and
|
||||
has one `render(state)` method. It contains no decisions beyond presentation, and it
|
||||
never calls the bridge.
|
||||
|
||||
### Error hierarchy with codes
|
||||
|
||||
`DomainError` is abstract with a `code`; subclasses name a single failure
|
||||
(`StoreMissingError`, `EngineInvocationError`, `RegistryUnavailableError`, `BusyError`).
|
||||
The code is what crosses the bridge.
|
||||
|
||||
### Frozen constant tables
|
||||
|
||||
`IPC_CHANNELS`, `STORE_ENGINES`, the message bundles: `as const` tables with a derived
|
||||
type, so a typo is a compile error and adding an entry is the whole change. This is
|
||||
the extension point for a second engine.
|
||||
|
||||
### Build-time configuration
|
||||
|
||||
`infrastructure/config/BuildConfiguration.ts` reads the packaged `package.json`, which is
|
||||
where a build records the registry it was made for (`warpEngine.registryUrl`, set by
|
||||
`make dist STORES_API=…`). Precedence is runtime environment, then build, then the
|
||||
built-in default — most specific first, and each one is a different audience: someone
|
||||
trying it out, someone shipping a client for another site, us.
|
||||
|
||||
### Untrusted-data readers
|
||||
|
||||
Anything parsed from outside — the catalog, a store's config, the state file, the
|
||||
registry — goes through
|
||||
`infrastructure/json/JsonRecord.ts`: `unknown` in, a typed value with a stated
|
||||
fallback out. No `as` casts on foreign data.
|
||||
|
||||
## Naming
|
||||
|
||||
The names are a pattern, not a preference, and are checked by
|
||||
`@typescript-eslint/naming-convention` where a linter can check them.
|
||||
|
||||
### Files
|
||||
|
||||
- One primary export per file; the filename is the subject in `PascalCase`
|
||||
(`CatalogService.ts`, `GameDto.ts`).
|
||||
- A file whose primary export is a constant table is named for the table, and the
|
||||
export is its `UPPER_SNAKE_CASE` form (`IpcChannels.ts` exports `IPC_CHANNELS`).
|
||||
- Directories are lowercase and plural where they hold several of a kind (`models`,
|
||||
`ports`, `views`, `services`).
|
||||
|
||||
### Types and classes
|
||||
|
||||
| Kind | Pattern | Example |
|
||||
|---|---|---|
|
||||
| Domain model | plain noun, no suffix | `Game`, `InstalledStore` |
|
||||
| Port | `<Subject>Repository` / `Gateway` / `Locator` / `Installer` / `Launcher` | `StoreCatalogGateway` |
|
||||
| Adapter | `<Technology><Port>` | `NativeStoreCatalogGateway`, `HttpStoreRegistryRepository`, `FileSystemInstalledStoreRepository` |
|
||||
| Service | `<Area>Service` | `CatalogService` |
|
||||
| Mapper | `<Subject>Mapper` / `<Subject>DtoMapper` | `EngineGameMapper`, `GameDtoMapper` |
|
||||
| Wire type | `<Subject>Dto` | `CatalogListingDto` |
|
||||
| IPC controller | `<Domain>IpcController` | `CatalogIpcController` |
|
||||
| Renderer controller | `<Area>Controller` | `StoreController` |
|
||||
| View | `<Region>View` | `SideMenuView`, `GameCardView` |
|
||||
| Factory | `<Product>Factory` | `MainWindowFactory` |
|
||||
| Error | `<Cause>Error` | `StoreMissingError` |
|
||||
| Callback bag | `<Owner>Callbacks` | `SideMenuViewCallbacks` |
|
||||
| Type parameter | `T`-prefixed | `TResult`, `TElement` |
|
||||
|
||||
Interfaces carry no `I` prefix: a port is named for what it does, and its
|
||||
implementations say what they are made of.
|
||||
|
||||
### Methods
|
||||
|
||||
The verb states the contract, so a caller knows what a name will do before reading it.
|
||||
|
||||
| Prefix | Contract |
|
||||
|---|---|
|
||||
| `find…` | returns the thing or `null` / an array; absence is normal |
|
||||
| `require…` | returns the thing or **throws**; absence is a fault |
|
||||
| `read…` | fetches from a store, a file or a process |
|
||||
| `list…` | returns a collection from somewhere outside |
|
||||
| `install…`, `sync…`, `remove…`, `select…`, `update…` | changes something |
|
||||
| `apply…` | writes to the renderer state store |
|
||||
| `render…` | draws (views only) |
|
||||
| `handle…` | an IPC or DOM event handler |
|
||||
| `on…` | a callback property or subscription |
|
||||
| `to…` / `from…` | a mapper conversion |
|
||||
| `is…`, `has…`, `can…` | a boolean |
|
||||
| `describe…` | turns something into a message for a person |
|
||||
|
||||
Booleans read as assertions (`supported`, `installed`, `launchable`, `busy`), never
|
||||
`flag` or `status`.
|
||||
|
||||
## Type rules
|
||||
|
||||
- `strict`, plus `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
|
||||
`noImplicitOverride`, `noImplicitReturns`, `noPropertyAccessFromIndexSignature`,
|
||||
`noFallthroughCasesInSwitch`, `isolatedModules`.
|
||||
- **Every signature is annotated** — parameters, return types, class properties —
|
||||
including where inference would manage: `explicit-function-return-type`,
|
||||
`explicit-module-boundary-types` and `typedef` are errors.
|
||||
- Data is `readonly`: DTO and model fields, and arrays as `readonly T[]`.
|
||||
- No `any`, no non-null `!`, no unchecked casts. Foreign data goes through
|
||||
`JsonRecord`; DOM lookups go through `requireElement`, which checks the element type
|
||||
it was asked for.
|
||||
- Exhaustive `switch` over union types, checked by `switch-exhaustiveness-check` — the
|
||||
sync-event union is handled that way on purpose.
|
||||
|
||||
`erasableSyntaxOnly` is deliberately **off**: constructor parameter properties are how
|
||||
dependencies are declared here, and that is worth more than being strippable by
|
||||
`node --experimental-strip-types`.
|
||||
|
||||
## How to add things
|
||||
|
||||
**A new bridge call.** Add the channel to `IPC_CHANNELS`, the method to `BridgeApi`,
|
||||
the implementation to `preload.ts`, a `handle…` method to the right controller, and the
|
||||
behaviour to a service. The compiler names every file you missed.
|
||||
|
||||
**A new engine (e.g. RetroArch).** Add an entry to `STORE_ENGINES` — the store discovery,
|
||||
the home suffix and the launcher name all read from that table — and a `StoreCatalogGateway`
|
||||
implementation for that host. Everything above the port is unchanged.
|
||||
|
||||
**A new WarpEngine version.** Add it to `SUPPORTED_WARP_ENGINE_VERSIONS`. The build then
|
||||
fails in `selectCatalogDialect` until the switch says which `CatalogDialect` reads it:
|
||||
either an existing one, when the catalog's shape did not change, or a new one beside
|
||||
`SoftwareListCatalogDialect`.
|
||||
|
||||
**A new field from the catalog.** The dialect reads it into `CatalogSoftware`,
|
||||
`CatalogRelease` or `CatalogAsset`; `SelectedGame` and `Game` carry it if the survey or the
|
||||
window needs it; `GameDto` and `GameDtoMapper` take it across the bridge.
|
||||
|
||||
**A new language.** Add `<Language>Messages.ts` typed as `MessageBundle`, add the code
|
||||
to `LOCALES` and the bundle to `TranslationCatalog`. A missing key will not compile.
|
||||
|
||||
## Build layout
|
||||
|
||||
`tsc` compiles the main process to CommonJS in `build/`. The preload and the renderer
|
||||
are **bundled** by esbuild into one file each (`build/preload/preload.js`,
|
||||
`build/renderer/app.js`), because a sandboxed preload may not require its own modules
|
||||
and a module script over `file://` is blocked by the page's origin rules. `index.html`
|
||||
and `style.css` are copied. `electron-builder` ships `build/**` and nothing else.
|
||||
|
||||
`make check` is the gate: `typecheck`, `lint`, then the two test suites — the cheapest
|
||||
check that can fail runs first.
|
||||
@@ -0,0 +1,49 @@
|
||||
// Strict on purpose: this is a client that drives a program which deletes files,
|
||||
// so an implicit `any` crossing a layer boundary is not a style question.
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['build/**', 'dist/**', 'node_modules/**', 'scripts/*.js', 'scripts/*.mjs', 'eslint.config.mjs'] },
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }
|
||||
},
|
||||
rules: {
|
||||
// Types everywhere, including the ones TypeScript would happily infer: a
|
||||
// signature is the layer's contract, and it should be readable without
|
||||
// running the compiler in your head.
|
||||
'@typescript-eslint/explicit-function-return-type': ['error', { allowExpressions: false }],
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'error',
|
||||
'@typescript-eslint/typedef': ['error', { parameter: true, propertyDeclaration: true }],
|
||||
// typedef and no-inferrable-types disagree about `fallback: string = ''`. The
|
||||
// annotation wins: a signature states its types even where TypeScript could
|
||||
// guess them.
|
||||
'@typescript-eslint/no-inferrable-types': ['error', { ignoreParameters: true }],
|
||||
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
|
||||
'@typescript-eslint/prefer-readonly': 'error',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-unnecessary-condition': 'error',
|
||||
'@typescript-eslint/switch-exhaustiveness-check': 'error',
|
||||
|
||||
// The naming patterns STRUCTURE.md documents, enforced rather than trusted.
|
||||
'@typescript-eslint/naming-convention': ['error',
|
||||
{ selector: 'default', format: ['camelCase'] },
|
||||
{ selector: 'variable', format: ['camelCase', 'UPPER_CASE'] },
|
||||
{ selector: 'parameter', format: ['camelCase'], leadingUnderscore: 'allow' },
|
||||
{ selector: 'typeLike', format: ['PascalCase'] },
|
||||
{ selector: 'enumMember', format: ['UPPER_CASE'] },
|
||||
{ selector: 'objectLiteralProperty', format: null },
|
||||
{ selector: 'typeProperty', format: ['camelCase'] },
|
||||
{ selector: 'classProperty', modifiers: ['static', 'readonly'], format: ['UPPER_CASE'] },
|
||||
{ selector: 'classMethod', format: ['camelCase'] },
|
||||
{ selector: 'function', format: ['camelCase'] }
|
||||
],
|
||||
|
||||
'no-console': 'off',
|
||||
curly: ['error', 'multi-line'],
|
||||
eqeqeq: ['error', 'always']
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1,82 +0,0 @@
|
||||
'use strict'
|
||||
// Setting up the store when there is none yet.
|
||||
//
|
||||
// This is the reason the client exists on Windows at all: the shell installer is
|
||||
// `curl … | sh`, which Windows does not have. The three files it would place are
|
||||
// downloaded here instead, into the very same store home — so the CLI and the
|
||||
// client stay one installation, and running install.sh afterwards only adds the
|
||||
// launcher script.
|
||||
|
||||
const fs = require('node:fs')
|
||||
const https = require('node:https')
|
||||
const path = require('node:path')
|
||||
|
||||
const FORGE = 'https://git.teletypegames.org'
|
||||
const SOURCES = {
|
||||
engine: `${FORGE}/stores/warp-engine-desktop-store/raw/branch/master/desktop_store.py`,
|
||||
core: `${FORGE}/engines/warpstore/raw/branch/master/warpstore.py`,
|
||||
config: `${FORGE}/stores/ttg-desktop-store/raw/branch/master/config.json`
|
||||
}
|
||||
|
||||
/** GET a URL as a string, following redirects — a moved repo answers 301. */
|
||||
function fetchText (url, redirects = 5) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.get(url, { headers: { 'User-Agent': 'warp-engine-desktop-gui' } }, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
res.resume()
|
||||
if (redirects <= 0) return reject(new Error(`too many redirects for ${url}`))
|
||||
const next = new URL(res.headers.location, url).toString()
|
||||
return fetchText(next, redirects - 1).then(resolve, reject)
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume()
|
||||
return reject(new Error(`${url} answered ${res.statusCode}`))
|
||||
}
|
||||
let body = ''
|
||||
res.setEncoding('utf8')
|
||||
res.on('data', (chunk) => { body += chunk })
|
||||
res.on('end', () => resolve(body))
|
||||
})
|
||||
request.setTimeout(60000, () => request.destroy(new Error(`${url} timed out`)))
|
||||
request.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the engine, the shared core and the store's config into `home`.
|
||||
*
|
||||
* `onLog` reports each step, because on a slow line this takes a few seconds and
|
||||
* silence looks like a hang. An existing config is left alone: a store that is
|
||||
* already set up keeps its settings.
|
||||
*/
|
||||
async function install (home, { onLog = () => {} } = {}) {
|
||||
fs.mkdirSync(home, { recursive: true })
|
||||
const wrote = []
|
||||
|
||||
for (const [name, file] of [['engine', 'desktop_store.py'], ['core', 'warpstore.py']]) {
|
||||
onLog(`downloading ${file}`)
|
||||
const body = await fetchText(SOURCES[name])
|
||||
if (!body.startsWith('#!/usr/bin/env python3')) {
|
||||
throw new Error(`${file} does not look like the store engine — refusing to install it`)
|
||||
}
|
||||
const dest = path.join(home, file)
|
||||
fs.writeFileSync(dest, body, { mode: 0o755 })
|
||||
wrote.push(dest)
|
||||
}
|
||||
|
||||
const config = path.join(home, 'config.json')
|
||||
if (fs.existsSync(config)) {
|
||||
onLog('keeping the config already in place')
|
||||
} else {
|
||||
onLog('downloading config.json')
|
||||
const body = await fetchText(SOURCES.config)
|
||||
JSON.parse(body) // a broken config would fail later and less clearly
|
||||
fs.writeFileSync(config, body)
|
||||
wrote.push(config)
|
||||
}
|
||||
|
||||
onLog(`the store is set up in ${home}`)
|
||||
return { home, config, script: path.join(home, 'desktop_store.py'), wrote }
|
||||
}
|
||||
|
||||
module.exports = { FORGE, SOURCES, fetchText, install }
|
||||
@@ -1,94 +0,0 @@
|
||||
'use strict'
|
||||
// Two languages, the way the public site has them. The CLI and the docs stay
|
||||
// English; this is the one end-user surface where Hungarian matters.
|
||||
//
|
||||
// Catalog text — titles, descriptions — is never translated here: it arrives
|
||||
// from the store as it was published.
|
||||
|
||||
const STRINGS = {
|
||||
en: {
|
||||
appName: 'WarpEngine Store',
|
||||
syncAll: 'Install all',
|
||||
refresh: 'Refresh',
|
||||
install: 'Install',
|
||||
update: 'Update',
|
||||
play: 'Play',
|
||||
open: 'Open',
|
||||
remove: 'Remove',
|
||||
installed: 'installed',
|
||||
native: 'native',
|
||||
hosted: 'hosted',
|
||||
hostedHint: 'Opens in your browser — needs the network',
|
||||
nativeHint: 'Installed on this machine — works offline',
|
||||
updateAvailable: 'update available',
|
||||
log: 'Log',
|
||||
noGames: 'No installable titles in the catalog.',
|
||||
setupTitle: 'Set up the store',
|
||||
setupBody: 'The store engine is not on this machine yet. It can be downloaded now — the same files the shell installer would place, in the same folder.',
|
||||
setupAction: 'Download the store',
|
||||
setupWorking: 'Setting up…',
|
||||
oldEngineTitle: 'The store needs refreshing',
|
||||
oldEngineBody: 'The store engine on this machine is older than this client can drive. Refreshing it downloads the current engine and keeps your settings and installed games.',
|
||||
oldEngineAction: 'Refresh the store',
|
||||
noPythonTitle: 'Python 3 is required',
|
||||
noPythonBody: 'The store is a Python program, so Python 3 has to be installed. Install it, then reopen this window.',
|
||||
pythonLink: 'python.org/downloads',
|
||||
paths: 'Where things go',
|
||||
openStoreFolder: 'Open the store folder',
|
||||
openMenuFolder: 'Open the menu folder',
|
||||
busy: 'Working…',
|
||||
failed: 'failed',
|
||||
removed: 'removed',
|
||||
upToDate: 'Everything is up to date.',
|
||||
of: 'of'
|
||||
},
|
||||
hu: {
|
||||
appName: 'WarpEngine Store',
|
||||
syncAll: 'Mind telepítése',
|
||||
refresh: 'Frissítés',
|
||||
install: 'Telepítés',
|
||||
update: 'Frissítés',
|
||||
play: 'Indítás',
|
||||
open: 'Megnyitás',
|
||||
remove: 'Eltávolítás',
|
||||
installed: 'telepítve',
|
||||
native: 'natív',
|
||||
hosted: 'hosztolt',
|
||||
hostedHint: 'A böngészőben nyílik meg — internet kell hozzá',
|
||||
nativeHint: 'Erre a gépre telepítve — internet nélkül is megy',
|
||||
updateAvailable: 'frissítés elérhető',
|
||||
log: 'Napló',
|
||||
noGames: 'Nincs telepíthető cím a katalógusban.',
|
||||
setupTitle: 'A store beállítása',
|
||||
setupBody: 'A store motorja még nincs ezen a gépen. Most letölthető — ugyanazok a fájlok, ugyanabba a könyvtárba, ahová a shell-telepítő tenné.',
|
||||
setupAction: 'Store letöltése',
|
||||
setupWorking: 'Beállítás…',
|
||||
oldEngineTitle: 'A store frissítésre vár',
|
||||
oldEngineBody: 'A gépen lévő store-motor régebbi, mint amit ez a kliens vezérelni tud. A frissítés letölti a mostani motort, a beállításaid és a telepített játékok pedig megmaradnak.',
|
||||
oldEngineAction: 'Store frissítése',
|
||||
noPythonTitle: 'Python 3 kell hozzá',
|
||||
noPythonBody: 'A store egy Python program, tehát Python 3 kell a gépre. Telepítsd, majd nyisd meg újra ezt az ablakot.',
|
||||
pythonLink: 'python.org/downloads',
|
||||
paths: 'Hova kerül',
|
||||
openStoreFolder: 'Store könyvtár megnyitása',
|
||||
openMenuFolder: 'Menü könyvtár megnyitása',
|
||||
busy: 'Dolgozom…',
|
||||
failed: 'hiba',
|
||||
removed: 'eltávolítva',
|
||||
upToDate: 'Minden naprakész.',
|
||||
of: '/'
|
||||
}
|
||||
}
|
||||
|
||||
const FALLBACK = 'en'
|
||||
|
||||
function pick (locale) {
|
||||
const short = String(locale || '').slice(0, 2).toLowerCase()
|
||||
return STRINGS[short] ? short : FALLBACK
|
||||
}
|
||||
|
||||
function dict (locale) {
|
||||
return STRINGS[pick(locale)]
|
||||
}
|
||||
|
||||
module.exports = { FALLBACK, STRINGS, dict, pick, languages: Object.keys(STRINGS) }
|
||||
@@ -1,228 +0,0 @@
|
||||
'use strict'
|
||||
// The bridge to the store CLI.
|
||||
//
|
||||
// The CLI is the product; this file only finds it and talks to it. Every
|
||||
// operation is `desktop_store.py --json …`, which puts data on stdout and its
|
||||
// log on stderr — so nothing here parses a sentence meant for a person.
|
||||
//
|
||||
// Shaped for more than one engine on purpose: ENGINES is a list today with one
|
||||
// entry, and the RetroArch store could be added without touching the callers.
|
||||
|
||||
const { spawn, spawnSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const ENGINES = [
|
||||
{
|
||||
id: 'desktop',
|
||||
script: 'desktop_store.py',
|
||||
// The installer names the store home `<store id>-desktop`, so the RetroArch
|
||||
// engine can share the same root without sharing config.json and state.json.
|
||||
homeSuffix: '-desktop',
|
||||
launcherSuffix: '-desktop-store'
|
||||
}
|
||||
]
|
||||
|
||||
/** The roots the shell installers use, in the same order they would. */
|
||||
function storeRoots () {
|
||||
const home = os.homedir()
|
||||
const roots = []
|
||||
if (process.env.STORE_ROOT) roots.push(process.env.STORE_ROOT)
|
||||
if (process.env.XDG_DATA_HOME) {
|
||||
roots.push(path.join(process.env.XDG_DATA_HOME, 'warp-engine-store'))
|
||||
}
|
||||
roots.push(path.join(home, '.local', 'share', 'warp-engine-store'))
|
||||
if (process.platform === 'darwin') {
|
||||
roots.push(path.join(home, 'Library', 'Application Support', 'warp-engine-store'))
|
||||
}
|
||||
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
|
||||
roots.push(path.join(process.env.LOCALAPPDATA, 'warp-engine-store'))
|
||||
}
|
||||
return [...new Set(roots)]
|
||||
}
|
||||
|
||||
/** Every installed store this client can drive. */
|
||||
function findStores () {
|
||||
const found = []
|
||||
for (const root of storeRoots()) {
|
||||
let entries = []
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true })
|
||||
} catch { continue }
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const home = path.join(root, entry.name)
|
||||
for (const engine of ENGINES) {
|
||||
const script = path.join(home, engine.script)
|
||||
const config = path.join(home, 'config.json')
|
||||
if (fs.existsSync(script) && fs.existsSync(config)) {
|
||||
found.push({ engine: engine.id, id: entry.name.replace(engine.homeSuffix, ''), home, script, config })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function findStore () {
|
||||
return findStores()[0] || null
|
||||
}
|
||||
|
||||
/** Where a store would be installed if there is none yet. */
|
||||
function defaultHome (storeId = 'ttg') {
|
||||
const engine = ENGINES[0]
|
||||
return path.join(storeRoots()[0], `${storeId}${engine.homeSuffix}`)
|
||||
}
|
||||
|
||||
// Python 3 is what the CLI needs, and its name differs per platform. `py -3` is
|
||||
// the Windows launcher, which is often the only one on PATH.
|
||||
const PYTHON_CANDIDATES = process.platform === 'win32'
|
||||
? [['py', ['-3']], ['python', []], ['python3', []]]
|
||||
: [['python3', []], ['python', []]]
|
||||
|
||||
let cachedPython = null
|
||||
|
||||
function findPython () {
|
||||
if (cachedPython !== undefined && cachedPython !== null) return cachedPython
|
||||
for (const [cmd, args] of PYTHON_CANDIDATES) {
|
||||
try {
|
||||
const probe = spawnSync(cmd, [...args, '--version'], { encoding: 'utf8', timeout: 10000 })
|
||||
const out = `${probe.stdout || ''}${probe.stderr || ''}`
|
||||
if (probe.status === 0 && /Python 3\./.test(out)) {
|
||||
cachedPython = { cmd, args, version: out.trim() }
|
||||
return cachedPython
|
||||
}
|
||||
} catch { /* try the next one */ }
|
||||
}
|
||||
cachedPython = null
|
||||
return null
|
||||
}
|
||||
|
||||
class StoreError extends Error {
|
||||
constructor (message, code) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
// The oldest engine that speaks `--json`. An older one is not broken, it simply
|
||||
// cannot be driven from a window — and it will be met in the wild, because the
|
||||
// CLI shipped before this client did.
|
||||
const MIN_ENGINE = [1, 1, 0]
|
||||
|
||||
function parseVersion (text) {
|
||||
const match = /(\d+)\.(\d+)\.(\d+)/.exec(String(text || ''))
|
||||
return match ? match.slice(1, 4).map(Number) : null
|
||||
}
|
||||
|
||||
function atLeast (version, minimum) {
|
||||
if (!version) return false
|
||||
for (let i = 0; i < minimum.length; i += 1) {
|
||||
if ((version[i] || 0) > minimum[i]) return true
|
||||
if ((version[i] || 0) < minimum[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** The installed engine's version string, and whether this client can drive it. */
|
||||
function engineVersion (store) {
|
||||
const python = findPython()
|
||||
if (!python || !store) return null
|
||||
try {
|
||||
const probe = spawnSync(python.cmd, [...python.args, store.script, '--version'],
|
||||
{ encoding: 'utf8', timeout: 15000 })
|
||||
const text = `${probe.stdout || ''}${probe.stderr || ''}`.trim()
|
||||
if (probe.status !== 0 || !text) return null
|
||||
return { text, version: parseVersion(text), ok: atLeast(parseVersion(text), MIN_ENGINE) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one CLI command.
|
||||
*
|
||||
* `onLine` gets every stdout line already parsed (the CLI emits one JSON object
|
||||
* per line), `onLog` every stderr line as text. Resolves with the parsed lines.
|
||||
*/
|
||||
function run (store, args, { onLine, onLog, signal } = {}) {
|
||||
const python = findPython()
|
||||
if (!python) throw new StoreError('python3 was not found on this machine', 'NO_PYTHON')
|
||||
if (!store) throw new StoreError('no store is installed yet', 'NO_STORE')
|
||||
|
||||
const argv = [...python.args, store.script, '--config', store.config, '--json', ...args]
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(python.cmd, argv, {
|
||||
env: { ...process.env, DESKTOP_STORE_HOME: store.home },
|
||||
signal
|
||||
})
|
||||
const lines = []
|
||||
let stdoutRest = ''
|
||||
let stderrRest = ''
|
||||
|
||||
const takeStdout = (chunk) => {
|
||||
stdoutRest += chunk
|
||||
const parts = stdoutRest.split('\n')
|
||||
stdoutRest = parts.pop()
|
||||
for (const part of parts) {
|
||||
if (!part.trim()) continue
|
||||
let value
|
||||
try {
|
||||
value = JSON.parse(part)
|
||||
} catch {
|
||||
// Not ours to interpret — hand it on as a log line rather than crash.
|
||||
if (onLog) onLog(part)
|
||||
continue
|
||||
}
|
||||
lines.push(value)
|
||||
if (onLine) onLine(value)
|
||||
}
|
||||
}
|
||||
const takeStderr = (chunk) => {
|
||||
stderrRest += chunk
|
||||
const parts = stderrRest.split('\n')
|
||||
stderrRest = parts.pop()
|
||||
for (const part of parts) if (part.trim() && onLog) onLog(part)
|
||||
}
|
||||
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', takeStdout)
|
||||
child.stderr.on('data', takeStderr)
|
||||
child.on('error', (err) => reject(new StoreError(err.message, 'SPAWN_FAILED')))
|
||||
child.on('close', (code) => {
|
||||
takeStdout('\n')
|
||||
takeStderr('\n')
|
||||
if (code === 0) resolve(lines)
|
||||
else reject(new StoreError(`the store exited with code ${code}`, 'CLI_FAILED'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function list (store, hooks) {
|
||||
const lines = await run(store, ['list'], hooks)
|
||||
return lines[lines.length - 1] || { games: [], skipped: [] }
|
||||
}
|
||||
|
||||
async function paths (store, hooks) {
|
||||
const lines = await run(store, ['paths'], hooks)
|
||||
return lines[lines.length - 1] || {}
|
||||
}
|
||||
|
||||
function sync (store, names = [], hooks) {
|
||||
return run(store, ['sync', ...names], hooks)
|
||||
}
|
||||
|
||||
function remove (store, name, hooks) {
|
||||
return run(store, ['remove', name], hooks)
|
||||
}
|
||||
|
||||
function purge (store, hooks) {
|
||||
return run(store, ['purge'], hooks)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ENGINES, MIN_ENGINE, StoreError, atLeast, defaultHome, engineVersion, findPython,
|
||||
findStore, findStores, list, parseVersion, paths, purge, remove, run, storeRoots, sync
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
'use strict'
|
||||
// Main process: one window, and the IPC that lets it drive the store CLI.
|
||||
//
|
||||
// The renderer gets no Node access at all (contextIsolation on, nodeIntegration
|
||||
// off, sandbox on); everything it can do is in preload.js and handled here.
|
||||
|
||||
const { app, BrowserWindow, ipcMain, shell, dialog } = require('electron')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { spawn } = require('node:child_process')
|
||||
|
||||
const store = require('./lib/store')
|
||||
const bootstrap = require('./lib/bootstrap')
|
||||
const i18n = require('./lib/i18n')
|
||||
|
||||
let win = null
|
||||
let current = null // the store we are driving
|
||||
let busy = false // one CLI call at a time
|
||||
const prefsFile = () => path.join(app.getPath('userData'), 'prefs.json')
|
||||
|
||||
function loadPrefs () {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(prefsFile(), 'utf8'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function savePrefs (prefs) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(prefsFile()), { recursive: true })
|
||||
fs.writeFileSync(prefsFile(), JSON.stringify(prefs, null, 2))
|
||||
} catch { /* a lost preference is not worth an error dialog */ }
|
||||
}
|
||||
|
||||
function send (channel, payload) {
|
||||
if (win && !win.isDestroyed()) win.webContents.send(channel, payload)
|
||||
}
|
||||
|
||||
const hooks = () => ({
|
||||
onLog: (line) => send('store:log', line),
|
||||
onLine: (event) => send('store:event', event)
|
||||
})
|
||||
|
||||
/** One CLI call at a time: the store writes files, and two writers would race. */
|
||||
async function guarded (fn) {
|
||||
if (busy) throw new Error('busy')
|
||||
busy = true
|
||||
send('store:busy', true)
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
busy = false
|
||||
send('store:busy', false)
|
||||
}
|
||||
}
|
||||
|
||||
// `--selftest` drives the window once and reports what rendered, so the UI has a
|
||||
// check that does not need a pair of eyes. It is the only way a renderer error
|
||||
// would otherwise be noticed: the main process log stays empty.
|
||||
const SELFTEST = process.argv.includes('--selftest')
|
||||
|
||||
async function selftest () {
|
||||
const result = await win.webContents.executeJavaScript(`(() => ({
|
||||
cards: document.querySelectorAll('.card').length,
|
||||
installed: document.querySelectorAll('.card.is-installed').length,
|
||||
buttons: document.querySelectorAll('.card .actions button').length,
|
||||
gateVisible: !document.getElementById('gate').hidden,
|
||||
gateTitle: document.getElementById('gate-title').textContent,
|
||||
appName: document.getElementById('app-name').textContent,
|
||||
storeId: document.getElementById('store-id').textContent,
|
||||
paths: document.getElementById('log-paths').textContent.slice(0, 120),
|
||||
logLines: document.querySelectorAll('.log-line').length,
|
||||
locales: [...document.getElementById('locale').options].map((o) => o.value)
|
||||
}))()`)
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
const good = result.cards > 0 && !result.gateVisible && result.locales.length > 1
|
||||
console.log(good ? 'SELFTEST OK' : 'SELFTEST FAILED')
|
||||
app.exit(good ? 0 : 1)
|
||||
}
|
||||
|
||||
function createWindow () {
|
||||
win = new BrowserWindow({
|
||||
width: 1040,
|
||||
height: 720,
|
||||
minWidth: 760,
|
||||
minHeight: 520,
|
||||
backgroundColor: '#11151c',
|
||||
title: 'WarpEngine Store',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true
|
||||
}
|
||||
})
|
||||
|
||||
win.loadFile(path.join(__dirname, 'renderer', 'index.html'))
|
||||
|
||||
// A renderer error is invisible from here otherwise.
|
||||
win.webContents.on('console-message', (_event, level, message) => {
|
||||
if (level >= 2 || SELFTEST) console.log(`[renderer] ${message}`)
|
||||
})
|
||||
win.webContents.on('render-process-gone', (_event, details) => {
|
||||
console.log(`[renderer] gone: ${details.reason}`)
|
||||
if (SELFTEST) app.exit(1)
|
||||
})
|
||||
if (SELFTEST) {
|
||||
// The first list() has to finish before there is anything to look at.
|
||||
win.webContents.once('did-finish-load', () => setTimeout(() => {
|
||||
selftest().catch((err) => { console.log(`SELFTEST ERROR ${err.message}`); app.exit(1) })
|
||||
}, 6000))
|
||||
}
|
||||
|
||||
// Nothing in this app should ever navigate away or open a second window; a
|
||||
// link the user clicks goes to their browser instead.
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (/^https:\/\//.test(url)) shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
win.webContents.on('will-navigate', (event, url) => {
|
||||
if (url !== win.webContents.getURL()) {
|
||||
event.preventDefault()
|
||||
if (/^https:\/\//.test(url)) shell.openExternal(url)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- IPC ------------------------------------------------------------------
|
||||
|
||||
ipcMain.handle('app:state', () => {
|
||||
const prefs = loadPrefs()
|
||||
const python = store.findPython()
|
||||
current = store.findStore()
|
||||
// An engine that predates `--json` cannot be driven from a window; the client
|
||||
// says so and offers to refresh it rather than failing on the first call.
|
||||
const engine = current ? store.engineVersion(current) : null
|
||||
return {
|
||||
locale: i18n.pick(prefs.locale || app.getLocale()),
|
||||
languages: i18n.languages,
|
||||
strings: i18n.dict(prefs.locale || app.getLocale()),
|
||||
python: python ? python.version : null,
|
||||
store: current ? { id: current.id, home: current.home } : null,
|
||||
engine: engine ? { text: engine.text, ok: engine.ok } : null,
|
||||
minEngine: store.MIN_ENGINE.join('.'),
|
||||
defaultHome: store.defaultHome(),
|
||||
version: app.getVersion()
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('app:setLocale', (_event, locale) => {
|
||||
const prefs = loadPrefs()
|
||||
prefs.locale = i18n.pick(locale)
|
||||
savePrefs(prefs)
|
||||
return { locale: prefs.locale, strings: i18n.dict(prefs.locale) }
|
||||
})
|
||||
|
||||
ipcMain.handle('store:list', () => guarded(() => store.list(current, hooks())))
|
||||
ipcMain.handle('store:paths', () => guarded(() => store.paths(current, hooks())))
|
||||
|
||||
ipcMain.handle('store:sync', (_event, names) =>
|
||||
guarded(() => store.sync(current, Array.isArray(names) ? names : [], hooks())))
|
||||
|
||||
ipcMain.handle('store:remove', (_event, name) =>
|
||||
guarded(() => store.remove(current, String(name), hooks())))
|
||||
|
||||
ipcMain.handle('store:bootstrap', () => guarded(async () => {
|
||||
const home = store.defaultHome()
|
||||
const result = await bootstrap.install(home, { onLog: (line) => send('store:log', line) })
|
||||
current = { engine: 'desktop', id: path.basename(home).replace(/-desktop$/, ''), ...result }
|
||||
return { id: current.id, home: current.home }
|
||||
}))
|
||||
|
||||
/**
|
||||
* Launch what was installed.
|
||||
*
|
||||
* A hosted title is a URL, so it goes to the browser. A native one is whatever
|
||||
* the store recorded: on macOS the app bundle through `open`, elsewhere the
|
||||
* executable from its own directory — the same working directory the menu entry
|
||||
* uses, because games load their assets relative to it.
|
||||
*/
|
||||
ipcMain.handle('store:launch', async (_event, game) => {
|
||||
if (!game) return false
|
||||
if (game.mode === 'web' && game.url) {
|
||||
await shell.openExternal(game.url)
|
||||
return true
|
||||
}
|
||||
const target = game.menu_entry || game.exe
|
||||
if (!target || !fs.existsSync(target)) return false
|
||||
if (process.platform === 'darwin' && target.endsWith('.app')) {
|
||||
spawn('open', [target], { detached: true, stdio: 'ignore' }).unref()
|
||||
return true
|
||||
}
|
||||
if (process.platform === 'win32' || target.endsWith('.desktop')) {
|
||||
const error = await shell.openPath(target)
|
||||
if (!error) return true
|
||||
}
|
||||
const exe = game.exe || target
|
||||
spawn(exe, [], { cwd: path.dirname(exe), detached: true, stdio: 'ignore' }).unref()
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('app:openFolder', async (_event, dir) => {
|
||||
if (!dir) return false
|
||||
const error = await shell.openPath(dir)
|
||||
return !error
|
||||
})
|
||||
|
||||
ipcMain.handle('app:openExternal', async (_event, url) => {
|
||||
if (!/^https:\/\//.test(String(url))) return false
|
||||
await shell.openExternal(String(url))
|
||||
return true
|
||||
})
|
||||
|
||||
// --- lifecycle ------------------------------------------------------------
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
if (win) {
|
||||
if (win.isMinimized()) win.restore()
|
||||
win.focus()
|
||||
}
|
||||
})
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow()
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
dialog.showErrorBox('WarpEngine Store', String(reason && reason.message ? reason.message : reason))
|
||||
})
|
||||
}
|
||||
@@ -1,59 +1,93 @@
|
||||
{
|
||||
"name": "warp-engine-desktop-gui",
|
||||
"productName": "WarpEngine Store",
|
||||
"version": "1.0.0",
|
||||
"description": "Graphical client for a WarpEngine desktop store: install the catalog into your own application menu.",
|
||||
"name": "warp-engine-client",
|
||||
"productName": "WarpEngine Client",
|
||||
"version": "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>",
|
||||
"homepage": "https://git.teletypegames.org/stores/warp-engine-desktop-gui",
|
||||
"main": "main.js",
|
||||
"homepage": "https://git.teletypegames.org/stores/warp-engine-client",
|
||||
"main": "build/main/main.js",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"smoke": "node scripts/smoke.js",
|
||||
"dist": "electron-builder",
|
||||
"dist:mac": "electron-builder --mac",
|
||||
"dist:win": "electron-builder --win",
|
||||
"dist:linux": "electron-builder --linux",
|
||||
"uitest": "electron . --selftest"
|
||||
"build": "tsc -p tsconfig.build.json && node scripts/build-assets.mjs",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"start": "npm run build && electron .",
|
||||
"smoke": "npm run build && node build/scripts/SmokeTest.js",
|
||||
"uitest": "npm run build && electron . --selftest",
|
||||
"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",
|
||||
"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",
|
||||
"electron": "^43.4.0",
|
||||
"electron-builder": "^26.15.3"
|
||||
"electron-builder": "^26.15.3",
|
||||
"esbuild": "^0.28.2",
|
||||
"eslint": "^10.8.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.67.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "org.teletypegames.warpstore.gui",
|
||||
"productName": "WarpEngine Store",
|
||||
"productName": "WarpEngine Client",
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"lib/**/*",
|
||||
"renderer/**/*"
|
||||
"build/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.games",
|
||||
"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"
|
||||
},
|
||||
"appImage": {
|
||||
"artifactName": "WarpEngineClient-${version}-${arch}.${ext}"
|
||||
},
|
||||
"afterPack": "scripts/after-pack.js",
|
||||
"directories": {
|
||||
"buildResources": "resources"
|
||||
}
|
||||
},
|
||||
"allowScripts": {
|
||||
"electron@43.4.0": true
|
||||
"electron@43.4.0": true,
|
||||
"esbuild@0.28.2": true
|
||||
},
|
||||
"warpEngine": {
|
||||
"registryUrl": "https://teletypegames.org/api/stores"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
'use strict'
|
||||
// The whole surface the renderer gets. No Node, no fs, no child_process — just
|
||||
// these calls and two event streams.
|
||||
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('storeApi', {
|
||||
state: () => ipcRenderer.invoke('app:state'),
|
||||
setLocale: (locale) => ipcRenderer.invoke('app:setLocale', locale),
|
||||
|
||||
list: () => ipcRenderer.invoke('store:list'),
|
||||
paths: () => ipcRenderer.invoke('store:paths'),
|
||||
sync: (names) => ipcRenderer.invoke('store:sync', names),
|
||||
remove: (name) => ipcRenderer.invoke('store:remove', name),
|
||||
bootstrap: () => ipcRenderer.invoke('store:bootstrap'),
|
||||
launch: (game) => ipcRenderer.invoke('store:launch', game),
|
||||
|
||||
openFolder: (dir) => ipcRenderer.invoke('app:openFolder', dir),
|
||||
openExternal: (url) => ipcRenderer.invoke('app:openExternal', url),
|
||||
|
||||
// Streams from the running CLI: `log` is a line a person can read, `event` is
|
||||
// one of the store's JSON progress events.
|
||||
onLog: (fn) => ipcRenderer.on('store:log', (_e, line) => fn(line)),
|
||||
onEvent: (fn) => ipcRenderer.on('store:event', (_e, event) => fn(event)),
|
||||
onBusy: (fn) => ipcRenderer.on('store:busy', (_e, value) => fn(value))
|
||||
})
|
||||
@@ -1,321 +0,0 @@
|
||||
'use strict'
|
||||
// The whole renderer. No framework and no build step: the app is a grid of
|
||||
// cards, and every action is one call over the bridge in preload.js.
|
||||
|
||||
const api = window.storeApi
|
||||
const el = (id) => document.getElementById(id)
|
||||
|
||||
let T = {} // the active string table
|
||||
let games = []
|
||||
let paths = null
|
||||
let busy = false
|
||||
let plan = null // { total, done } while a sync is running
|
||||
|
||||
// --- helpers --------------------------------------------------------------
|
||||
|
||||
function text (node, value) {
|
||||
node.textContent = value == null ? '' : String(value)
|
||||
}
|
||||
|
||||
function imageUrl (game) {
|
||||
if (!game.image_url) return null
|
||||
if (/^https?:\/\//.test(game.image_url)) return game.image_url
|
||||
const base = (paths && paths.store && paths.store.base_url) || ''
|
||||
return base ? `${base}${game.image_url}` : null
|
||||
}
|
||||
|
||||
function logLine (line) {
|
||||
const box = el('log-lines')
|
||||
const row = document.createElement('div')
|
||||
row.className = 'log-line'
|
||||
text(row, line)
|
||||
box.appendChild(row)
|
||||
while (box.childElementCount > 400) box.removeChild(box.firstChild)
|
||||
box.scrollTop = box.scrollHeight
|
||||
}
|
||||
|
||||
function setBusy (value) {
|
||||
busy = value
|
||||
for (const node of document.querySelectorAll('button')) {
|
||||
if (node.id === 'log-toggle') continue
|
||||
node.disabled = value
|
||||
}
|
||||
const progress = el('progress')
|
||||
if (!value) {
|
||||
progress.hidden = true
|
||||
plan = null
|
||||
}
|
||||
}
|
||||
|
||||
function showProgress (label) {
|
||||
const progress = el('progress')
|
||||
progress.hidden = false
|
||||
text(progress, label)
|
||||
}
|
||||
|
||||
// --- the card grid --------------------------------------------------------
|
||||
|
||||
function card (game) {
|
||||
const node = document.createElement('article')
|
||||
node.className = 'card'
|
||||
if (game.installed) node.classList.add('is-installed')
|
||||
|
||||
const art = document.createElement('div')
|
||||
art.className = 'art'
|
||||
const src = imageUrl(game)
|
||||
if (src) {
|
||||
const img = document.createElement('img')
|
||||
img.src = src
|
||||
img.alt = ''
|
||||
img.loading = 'lazy'
|
||||
art.appendChild(img)
|
||||
} else {
|
||||
const glyph = document.createElement('span')
|
||||
glyph.className = 'art-glyph'
|
||||
text(glyph, game.title.slice(0, 1).toUpperCase())
|
||||
art.appendChild(glyph)
|
||||
}
|
||||
node.appendChild(art)
|
||||
|
||||
const body = document.createElement('div')
|
||||
body.className = 'body'
|
||||
|
||||
const title = document.createElement('h2')
|
||||
text(title, game.title)
|
||||
body.appendChild(title)
|
||||
|
||||
const meta = document.createElement('div')
|
||||
meta.className = 'meta'
|
||||
const mode = document.createElement('span')
|
||||
mode.className = `badge badge-${game.mode}`
|
||||
text(mode, game.mode === 'web' ? T.hosted : T.native)
|
||||
mode.title = game.mode === 'web' ? T.hostedHint : T.nativeHint
|
||||
meta.appendChild(mode)
|
||||
const platform = document.createElement('span')
|
||||
platform.className = 'badge badge-plain'
|
||||
text(platform, game.platform)
|
||||
meta.appendChild(platform)
|
||||
const version = document.createElement('span')
|
||||
version.className = 'version'
|
||||
text(version, game.installed && game.installed_version
|
||||
? `${game.installed_version} · ${T.installed}`
|
||||
: game.version)
|
||||
meta.appendChild(version)
|
||||
body.appendChild(meta)
|
||||
|
||||
if (game.desc) {
|
||||
const desc = document.createElement('p')
|
||||
desc.className = 'desc'
|
||||
text(desc, game.desc)
|
||||
body.appendChild(desc)
|
||||
}
|
||||
|
||||
const actions = document.createElement('div')
|
||||
actions.className = 'actions'
|
||||
|
||||
if (game.installed && !game.update_available) {
|
||||
const play = document.createElement('button')
|
||||
play.className = 'btn btn-primary'
|
||||
text(play, game.mode === 'web' ? T.open : T.play)
|
||||
play.addEventListener('click', () => api.launch(game))
|
||||
actions.appendChild(play)
|
||||
} else {
|
||||
const install = document.createElement('button')
|
||||
install.className = 'btn btn-primary'
|
||||
text(install, game.update_available ? T.update : T.install)
|
||||
install.addEventListener('click', () => runSync([game.name]))
|
||||
actions.appendChild(install)
|
||||
}
|
||||
|
||||
if (game.installed) {
|
||||
const remove = document.createElement('button')
|
||||
remove.className = 'btn btn-ghost'
|
||||
text(remove, T.remove)
|
||||
remove.addEventListener('click', () => runRemove(game.name))
|
||||
actions.appendChild(remove)
|
||||
}
|
||||
|
||||
body.appendChild(actions)
|
||||
node.appendChild(body)
|
||||
return node
|
||||
}
|
||||
|
||||
function renderGrid () {
|
||||
const grid = el('grid')
|
||||
grid.replaceChildren(...games.map(card))
|
||||
grid.hidden = games.length === 0
|
||||
const empty = el('empty')
|
||||
empty.hidden = games.length !== 0
|
||||
text(empty, T.noGames)
|
||||
}
|
||||
|
||||
function renderPaths () {
|
||||
const box = el('log-paths')
|
||||
box.replaceChildren()
|
||||
if (!paths) return
|
||||
const line = document.createElement('div')
|
||||
line.className = 'paths-line'
|
||||
text(line, `${T.paths}: ${paths.store_folder} · ${paths.menu_group}`)
|
||||
box.appendChild(line)
|
||||
|
||||
for (const [label, dir] of [[T.openStoreFolder, paths.store_folder],
|
||||
[T.openMenuFolder, paths.menu_group]]) {
|
||||
const button = document.createElement('button')
|
||||
button.className = 'btn btn-tiny'
|
||||
text(button, label)
|
||||
button.addEventListener('click', () => api.openFolder(dir))
|
||||
box.appendChild(button)
|
||||
}
|
||||
}
|
||||
|
||||
// --- actions --------------------------------------------------------------
|
||||
|
||||
async function refresh () {
|
||||
try {
|
||||
const result = await api.list()
|
||||
games = result.games || []
|
||||
paths = result.paths || paths
|
||||
renderGrid()
|
||||
renderPaths()
|
||||
for (const reason of result.skipped || []) logLine(`skipped ${reason}`)
|
||||
} catch (err) {
|
||||
logLine(String(err && err.message ? err.message : err))
|
||||
}
|
||||
}
|
||||
|
||||
async function runSync (names) {
|
||||
try {
|
||||
await api.sync(names || [])
|
||||
} catch (err) {
|
||||
logLine(String(err && err.message ? err.message : err))
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
async function runRemove (name) {
|
||||
try {
|
||||
await api.remove(name)
|
||||
} catch (err) {
|
||||
logLine(String(err && err.message ? err.message : err))
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
// --- gate: no python, or no store yet -------------------------------------
|
||||
|
||||
function showGate (title, body, action, link) {
|
||||
el('grid').hidden = true
|
||||
el('empty').hidden = true
|
||||
const gate = el('gate')
|
||||
gate.hidden = false
|
||||
text(el('gate-title'), title)
|
||||
text(el('gate-body'), body)
|
||||
const button = el('gate-action')
|
||||
button.hidden = !action
|
||||
if (action) {
|
||||
text(button, action.label)
|
||||
button.onclick = action.onClick
|
||||
}
|
||||
const anchor = el('gate-link')
|
||||
anchor.hidden = !link
|
||||
if (link) {
|
||||
text(anchor, link.label)
|
||||
anchor.onclick = (event) => { event.preventDefault(); api.openExternal(link.url) }
|
||||
}
|
||||
}
|
||||
|
||||
function hideGate () {
|
||||
el('gate').hidden = true
|
||||
}
|
||||
|
||||
// --- boot -----------------------------------------------------------------
|
||||
|
||||
function applyStrings (strings) {
|
||||
T = strings
|
||||
text(el('app-name'), T.appName)
|
||||
text(el('sync-all'), T.syncAll)
|
||||
text(el('refresh'), T.refresh)
|
||||
text(el('log-toggle'), T.log)
|
||||
renderPaths()
|
||||
if (games.length) renderGrid()
|
||||
}
|
||||
|
||||
async function boot () {
|
||||
const state = await api.state()
|
||||
applyStrings(state.strings)
|
||||
|
||||
const select = el('locale')
|
||||
select.replaceChildren(...state.languages.map((code) => {
|
||||
const option = document.createElement('option')
|
||||
option.value = code
|
||||
option.textContent = code.toUpperCase()
|
||||
if (code === state.locale) option.selected = true
|
||||
return option
|
||||
}))
|
||||
select.addEventListener('change', async () => {
|
||||
const next = await api.setLocale(select.value)
|
||||
applyStrings(next.strings)
|
||||
})
|
||||
|
||||
if (!state.python) {
|
||||
showGate(T.noPythonTitle, T.noPythonBody, null,
|
||||
{ label: T.pythonLink, url: 'https://www.python.org/downloads/' })
|
||||
return
|
||||
}
|
||||
|
||||
const setUpStore = async () => {
|
||||
showProgress(T.setupWorking)
|
||||
try {
|
||||
await api.bootstrap()
|
||||
hideGate()
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
logLine(String(err && err.message ? err.message : err))
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.store) {
|
||||
showGate(T.setupTitle, `${T.setupBody}\n\n${state.defaultHome}`,
|
||||
{ label: T.setupAction, onClick: async () => { await setUpStore(); await runSync([]) } })
|
||||
return
|
||||
}
|
||||
|
||||
if (state.engine && !state.engine.ok) {
|
||||
showGate(T.oldEngineTitle,
|
||||
`${T.oldEngineBody}\n\n${state.engine.text} → ${state.minEngine}`,
|
||||
{ label: T.oldEngineAction, onClick: setUpStore })
|
||||
return
|
||||
}
|
||||
|
||||
text(el('store-id'), state.store.id)
|
||||
hideGate()
|
||||
await refresh()
|
||||
}
|
||||
|
||||
el('sync-all').addEventListener('click', () => runSync([]))
|
||||
el('refresh').addEventListener('click', () => refresh())
|
||||
el('log-toggle').addEventListener('click', () => {
|
||||
const box = el('log-lines')
|
||||
box.hidden = !box.hidden
|
||||
el('log-toggle').setAttribute('aria-expanded', String(!box.hidden))
|
||||
})
|
||||
|
||||
api.onLog(logLine)
|
||||
api.onBusy(setBusy)
|
||||
api.onEvent((event) => {
|
||||
if (event.event === 'plan') {
|
||||
plan = { total: event.count, done: 0 }
|
||||
showProgress(`0 ${T.of} ${event.count}`)
|
||||
} else if (event.event === 'begin' && plan) {
|
||||
showProgress(`${plan.done + 1} ${T.of} ${plan.total} · ${event.title}`)
|
||||
} else if (event.event === 'installed' && plan) {
|
||||
plan.done += 1
|
||||
logLine(`${event.title} — ${event.changed ? T.installed : T.upToDate}`)
|
||||
} else if (event.event === 'failed') {
|
||||
logLine(`${event.name}: ${T.failed} — ${event.error}`)
|
||||
} else if (event.event === 'removed') {
|
||||
logLine(`${event.name} — ${T.removed}`)
|
||||
}
|
||||
})
|
||||
|
||||
boot()
|
||||
@@ -1,49 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!-- Nothing is loaded from the network except box art, and no inline code
|
||||
runs: the app ships its own script and stylesheet. -->
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' https: data:; font-src 'self'; connect-src 'none'">
|
||||
<title>WarpEngine Store</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<div class="bar-title">
|
||||
<span class="logo" aria-hidden="true">▚</span>
|
||||
<span id="app-name">WarpEngine Store</span>
|
||||
<span class="store-id" id="store-id"></span>
|
||||
</div>
|
||||
<div class="bar-actions">
|
||||
<span class="progress" id="progress" hidden></span>
|
||||
<button id="sync-all" class="btn btn-primary" disabled></button>
|
||||
<button id="refresh" class="btn" disabled></button>
|
||||
<select id="locale" class="select" aria-label="Language"></select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Shown instead of the grid when there is nothing to drive yet. -->
|
||||
<section id="gate" class="gate" hidden>
|
||||
<h1 id="gate-title"></h1>
|
||||
<p id="gate-body"></p>
|
||||
<div class="gate-actions">
|
||||
<button id="gate-action" class="btn btn-primary" hidden></button>
|
||||
<a id="gate-link" class="link" href="#" hidden></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main id="grid" class="grid" hidden></main>
|
||||
|
||||
<section id="empty" class="empty" hidden></section>
|
||||
|
||||
<footer class="log">
|
||||
<button id="log-toggle" class="log-toggle" aria-expanded="false"></button>
|
||||
<div class="log-lines" id="log-lines" hidden></div>
|
||||
<div class="log-paths" id="log-paths"></div>
|
||||
</footer>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,188 +0,0 @@
|
||||
/* One dark theme, no assets: the box art is the only image the app loads. */
|
||||
:root {
|
||||
--bg: #11151c;
|
||||
--panel: #182029;
|
||||
--panel-2: #1e2732;
|
||||
--line: #2a3440;
|
||||
--ink: #e8eef5;
|
||||
--ink-dim: #93a4b8;
|
||||
--accent: #37b98a;
|
||||
--accent-ink: #05130d;
|
||||
--warn: #e0a44a;
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- top bar ------------------------------------------------------------ */
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 18px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex: none;
|
||||
}
|
||||
.bar-title { display: flex; align-items: baseline; gap: 10px; font-weight: 700; }
|
||||
.logo { color: var(--accent); font-size: 18px; }
|
||||
.store-id {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
color: var(--ink-dim);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
}
|
||||
.bar-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.progress { color: var(--ink-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* --- controls ----------------------------------------------------------- */
|
||||
.btn {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 7px 14px;
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s, transform .05s;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: #26313e; border-color: #3a4757; }
|
||||
.btn:active:not(:disabled) { transform: scale(.97); }
|
||||
.btn:disabled { opacity: .45; cursor: default; }
|
||||
.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); }
|
||||
.btn-tiny { padding: 3px 9px; font-size: 12px; font-weight: 500; }
|
||||
.select {
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.link { color: var(--accent); cursor: pointer; text-decoration: underline; }
|
||||
|
||||
/* --- gate (no python, or no store yet) ---------------------------------- */
|
||||
.gate {
|
||||
margin: auto;
|
||||
max-width: 520px;
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
.gate h1 { font-size: 20px; margin: 0 0 10px; }
|
||||
.gate p { color: var(--ink-dim); white-space: pre-line; margin: 0 0 20px; word-break: break-all; }
|
||||
.gate-actions { display: flex; gap: 14px; justify-content: center; align-items: center; }
|
||||
|
||||
/* --- the grid ----------------------------------------------------------- */
|
||||
.grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
align-content: start;
|
||||
}
|
||||
.empty { margin: auto; color: var(--ink-dim); }
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.card.is-installed { border-color: #2f5a49; }
|
||||
|
||||
.art {
|
||||
aspect-ratio: 4 / 3;
|
||||
background: #0d1117;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.art img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.art-glyph { font-size: 44px; font-weight: 700; color: #263341; }
|
||||
|
||||
.body { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
|
||||
.body h2 { font-size: 15px; margin: 0; }
|
||||
.meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.badge-app { color: var(--accent); border-color: #2f5a49; }
|
||||
.badge-web { color: var(--warn); border-color: #5a4a2f; }
|
||||
.version { font-size: 12px; color: var(--ink-dim); margin-left: auto; }
|
||||
.desc {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-dim);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.actions { display: flex; gap: 8px; margin-top: auto; }
|
||||
|
||||
/* --- log ---------------------------------------------------------------- */
|
||||
.log {
|
||||
flex: none;
|
||||
background: var(--panel);
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 8px 18px 10px;
|
||||
}
|
||||
.log-toggle {
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-dim);
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0 0 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.log-lines {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11.5px;
|
||||
color: var(--ink-dim);
|
||||
background: #0d1117;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.log-line { white-space: pre-wrap; word-break: break-all; }
|
||||
.log-paths { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.paths-line {
|
||||
font-size: 11.5px;
|
||||
color: var(--ink-dim);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
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,32 @@
|
||||
'use strict'
|
||||
// Ad-hoc sign the macOS bundle after packing.
|
||||
//
|
||||
// Without this the bundle carries only the linker's ad-hoc signature on the main
|
||||
// executable, with no resource seal — `codesign --verify` says "code has no
|
||||
// resources but signature indicates they must be present". That runs fine
|
||||
// locally, but a browser download adds the quarantine flag, Gatekeeper evaluates
|
||||
// the broken seal, and macOS reports the app as *damaged* rather than merely
|
||||
// unverified. The first release shipped exactly that.
|
||||
//
|
||||
// An ad-hoc signature is not a Developer ID and does not notarise anything: the
|
||||
// user still has to right-click ▸ Open the first time. It is the difference
|
||||
// between "unidentified developer" and "move it to the Bin".
|
||||
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
|
||||
exports.default = async function afterPack (context) {
|
||||
if (context.electronPlatformName !== 'darwin') return
|
||||
if (process.platform !== 'darwin') {
|
||||
console.log(' • ad-hoc signing skipped reason=codesign only exists on macOS')
|
||||
return
|
||||
}
|
||||
|
||||
const app = path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`)
|
||||
// --deep is the pragmatic choice for ad-hoc signing a bundle with nested
|
||||
// frameworks and helpers; Apple discourages it for real identities, where the
|
||||
// inner-to-outer order matters.
|
||||
execFileSync('codesign', ['--force', '--deep', '--sign', '-', app], { stdio: 'inherit' })
|
||||
execFileSync('codesign', ['--verify', '--deep', '--strict', '--verbose=1', app], { stdio: 'inherit' })
|
||||
console.log(` • ad-hoc signed ${app}`)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// The two bundles and the two static files.
|
||||
//
|
||||
// tsc compiles the main process, where CommonJS and `require` are fine. The preload
|
||||
// and the renderer cannot work that way: a sandboxed preload may not require its own
|
||||
// modules, and a module script over file:// is blocked by the page's own origin
|
||||
// rules. So both are bundled into one file each — the layering stays in src/, the
|
||||
// window gets a single script.
|
||||
import { build } from 'esbuild'
|
||||
import { copyFile, mkdir } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)))
|
||||
const outDir = join(root, 'build')
|
||||
|
||||
const bundles = [
|
||||
{
|
||||
label: 'preload',
|
||||
entryPoints: [join(root, 'src/preload/preload.ts')],
|
||||
outfile: join(outDir, 'preload/preload.js'),
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
// Provided by Electron at runtime; bundling it would break the sandbox.
|
||||
external: ['electron']
|
||||
},
|
||||
{
|
||||
label: 'renderer',
|
||||
entryPoints: [join(root, 'src/renderer/main.ts')],
|
||||
outfile: join(outDir, 'renderer/app.js'),
|
||||
platform: 'browser',
|
||||
format: 'iife',
|
||||
external: []
|
||||
}
|
||||
]
|
||||
|
||||
for (const bundle of bundles) {
|
||||
await build({
|
||||
entryPoints: bundle.entryPoints,
|
||||
outfile: bundle.outfile,
|
||||
bundle: true,
|
||||
platform: bundle.platform,
|
||||
format: bundle.format,
|
||||
external: bundle.external,
|
||||
target: 'es2023',
|
||||
logLevel: 'warning'
|
||||
})
|
||||
console.log(`bundled ${bundle.label} -> ${bundle.outfile.replace(`${root}/`, '')}`)
|
||||
}
|
||||
|
||||
await mkdir(join(outDir, 'renderer'), { recursive: true })
|
||||
for (const asset of ['index.html', 'style.css']) {
|
||||
await copyFile(join(root, 'src/renderer', asset), join(outDir, 'renderer', asset))
|
||||
console.log(`copied ${asset}`)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/bin/sh
|
||||
# Attach built packages to the Gitea release for this tag.
|
||||
#
|
||||
# The local publisher (scripts/release.sh) drives `tea`, which is logged in
|
||||
# interactively on a workstation. CI has no such session: it has a token and curl. The
|
||||
# two are deliberately separate scripts rather than one with two ways to authenticate —
|
||||
# each is short enough to read in full.
|
||||
#
|
||||
# scripts/ci-upload.sh every package in dist/
|
||||
# scripts/ci-upload.sh dist/one.deb just these
|
||||
#
|
||||
# Authenticates with the `gitea_token` secret when there is one, and otherwise with the
|
||||
# credential Woodpecker gives every step for cloning — so a release needs no secret.
|
||||
#
|
||||
# Creates the release when the tag has none, with RELEASE_NOTES.md as its body. That is
|
||||
# the flow: a `vX.Y.Z` tag starts this pipeline, which publishes the release with the
|
||||
# Linux and Windows packages in it, and the macOS package is pushed on top afterwards by
|
||||
# `make release` from a Mac.
|
||||
set -eu
|
||||
|
||||
FORGE="${FORGE_API:-https://git.teletypegames.org/api/v1}"
|
||||
REPO="${REPO:-${CI_REPO:-}}"
|
||||
TAG="${TAG:-${CI_COMMIT_TAG:-}}"
|
||||
DIST="${DIST:-dist}"
|
||||
NOTES="${NOTES:-RELEASE_NOTES.md}"
|
||||
|
||||
say() { echo "[ci-upload] $*"; }
|
||||
die() { echo "[ci-upload] error: $*" >&2; exit 1; }
|
||||
|
||||
# Who to be. A `gitea_token` secret wins when there is one; otherwise the credential
|
||||
# Woodpecker already hands every step for cloning is used, which is an access token of
|
||||
# the repository's owner — so publishing needs no secret of its own. Gitea accepts a
|
||||
# personal access token as `token …` and an OAuth one as `Bearer …`, and which of the two
|
||||
# this is depends on how Woodpecker was set up, so the scheme is probed once rather than
|
||||
# assumed.
|
||||
TOKEN="${GITEA_TOKEN:-${CI_NETRC_PASSWORD:-}}"
|
||||
[ -n "$TOKEN" ] || die "no credential: set GITEA_TOKEN, or run this where Woodpecker provides CI_NETRC_PASSWORD"
|
||||
[ -n "$REPO" ] || die "cannot work out the repository — set REPO=owner/name"
|
||||
[ -n "$TAG" ] || die "cannot work out the tag — set TAG=v1.2.3"
|
||||
|
||||
AUTH=""
|
||||
for scheme in token Bearer; do
|
||||
if curl -fsS -H "Authorization: $scheme $TOKEN" "$FORGE/user" >/dev/null 2>&1; then
|
||||
AUTH="Authorization: $scheme $TOKEN"
|
||||
say "authenticated with the $scheme scheme"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ -n "$AUTH" ] || die "the credential was refused by $FORGE — it cannot read /user"
|
||||
|
||||
api() {
|
||||
method="$1"; path="$2"; shift 2
|
||||
curl -fsS -X "$method" -H "$AUTH" "$FORGE$path" "$@"
|
||||
}
|
||||
|
||||
# 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
|
||||
for given in "$@"; do printf '%s\n' "$given"; done > "$LIST"
|
||||
else
|
||||
# What this pipeline builds. The macOS packages are attached from the Mac that can
|
||||
# sign them, so they are not listed here even when they happen to be present.
|
||||
find "$DIST" -maxdepth 1 -type f \
|
||||
\( -name '*.AppImage' -o -name '*.deb' -o -name '*.exe' \) 2>/dev/null | sort > "$LIST" || true
|
||||
fi
|
||||
[ -s "$LIST" ] || die "no Linux or Windows packages in $DIST"
|
||||
|
||||
say "$REPO $TAG"
|
||||
|
||||
# `curl -f` fails on the 404 a missing release answers, so the lookup is allowed to
|
||||
# fail and judged by what came back rather than by its exit status.
|
||||
release_id="$(curl -sS -H "$AUTH" "$FORGE/repos/$REPO/releases/tags/$TAG" | jq -r '.id // empty')"
|
||||
|
||||
if [ -z "$release_id" ]; then
|
||||
say "no release for $TAG yet — creating it"
|
||||
title="$(jq -r '(.productName // .name) + " " + (.version)' package.json)"
|
||||
notes=''
|
||||
[ -f "$NOTES" ] && notes="$(cat "$NOTES")"
|
||||
# The body goes through jq rather than string concatenation: release notes are
|
||||
# markdown with quotes and newlines in them.
|
||||
payload="$(jq -n --arg tag "$TAG" --arg title "$title" --arg body "$notes" \
|
||||
'{tag_name: $tag, name: $title, body: $body, draft: false, prerelease: false}')"
|
||||
release_id="$(api POST "/repos/$REPO/releases" \
|
||||
-H 'Content-Type: application/json' -d "$payload" | jq -r '.id // empty')"
|
||||
[ -n "$release_id" ] || die "the release for $TAG could not be created"
|
||||
else
|
||||
say "the release already exists"
|
||||
fi
|
||||
|
||||
while IFS= read -r asset; do
|
||||
[ -n "$asset" ] || continue
|
||||
[ -f "$asset" ] || die "no such file: $asset"
|
||||
name="$(basename "$asset")"
|
||||
encoded="$(printf '%s' "$name" | jq -sRr @uri)"
|
||||
|
||||
# Replace rather than refuse, so re-running a build lands.
|
||||
existing="$(api GET "/repos/$REPO/releases/$release_id/assets" |
|
||||
jq -r --arg name "$name" '.[] | select(.name == $name) | .id')"
|
||||
for id in $existing; do
|
||||
say "replacing $name"
|
||||
api DELETE "/repos/$REPO/releases/$release_id/assets/$id" >/dev/null
|
||||
done
|
||||
|
||||
say "uploading $name"
|
||||
api POST "/repos/$REPO/releases/$release_id/assets?name=$encoded" \
|
||||
-F "attachment=@$asset" >/dev/null
|
||||
done < "$LIST"
|
||||
|
||||
say "done:"
|
||||
api GET "/repos/$REPO/releases/$release_id" |
|
||||
jq -r '.assets[] | " \(.name) \(.size / 1000000 | floor) MB"'
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
# Fail on a package that is too small to be one.
|
||||
#
|
||||
# Written after a Wine build died halfway and left a 162 KB stub named like the real
|
||||
# installer: `ls` was happy, the step passed, and the release would have carried a file
|
||||
# that cannot be run. An Electron package is ~100 MB — anything under a tenth of that
|
||||
# did not finish.
|
||||
#
|
||||
# scripts/ci-verify-packages.sh '*.AppImage' '*.deb'
|
||||
set -eu
|
||||
|
||||
DIST="${DIST:-dist}"
|
||||
MIN_BYTES="${MIN_BYTES:-10000000}"
|
||||
|
||||
die() { echo "[verify] error: $*" >&2; exit 1; }
|
||||
|
||||
[ "$#" -gt 0 ] || die "no patterns given"
|
||||
|
||||
for pattern in "$@"; do
|
||||
found=0
|
||||
# One path per line: package names contain spaces.
|
||||
find "$DIST" -maxdepth 1 -type f -name "$pattern" | sort > /tmp/verify-list
|
||||
while IFS= read -r file; do
|
||||
[ -n "$file" ] || continue
|
||||
found=1
|
||||
size="$(wc -c < "$file" | tr -d ' ')"
|
||||
if [ "$size" -lt "$MIN_BYTES" ]; then
|
||||
die "$file is only $size bytes — the build did not finish"
|
||||
fi
|
||||
echo "[verify] $(basename "$file"): $size bytes"
|
||||
done < /tmp/verify-list
|
||||
[ "$found" -eq 1 ] || die "no $pattern in $DIST"
|
||||
done
|
||||
|
||||
rm -f /tmp/verify-list
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/bin/sh
|
||||
# Publish the built packages as a Gitea release.
|
||||
#
|
||||
# The version comes from package.json, so the tag follows whatever `npm version`
|
||||
# set — there is nothing to keep in sync by hand. The release is created if it is
|
||||
# not there yet, and an attachment with a name already on it is replaced rather
|
||||
# than refused, which is what makes a rebuild-and-upload repeatable.
|
||||
#
|
||||
# scripts/release.sh every package in dist/
|
||||
# scripts/release.sh dist/foo.dmg just these
|
||||
#
|
||||
# Assumes `tea` is installed and logged in (see the devarea repo: `make tea`).
|
||||
set -eu
|
||||
|
||||
LOGIN="${TEA_LOGIN:-ttg}"
|
||||
NOTES="${NOTES:-RELEASE_NOTES.md}"
|
||||
DIST="${DIST:-dist}"
|
||||
|
||||
say() { echo "[release] $*"; }
|
||||
die() { echo "[release] error: $*" >&2; exit 1; }
|
||||
|
||||
command -v tea >/dev/null 2>&1 || die "tea is not installed — see the devarea repo, 'make tea'"
|
||||
command -v node >/dev/null 2>&1 || die "node is required"
|
||||
[ -f package.json ] || die "run this from the repository root"
|
||||
|
||||
# Version and title from package.json, read with node. This project needs no interpreter
|
||||
# beyond the one it already builds with — the app itself carries no Python any more, and
|
||||
# neither should the script that ships it.
|
||||
VERSION="$(node -p 'require("./package.json").version')"
|
||||
TITLE="$(node -p 'const d = require("./package.json"); (d.productName || d.name) + " " + d.version')"
|
||||
TAG="${TAG:-v$VERSION}"
|
||||
[ -n "$VERSION" ] || die "cannot read the version from package.json"
|
||||
[ -n "$TITLE" ] || die "cannot work out a release title"
|
||||
|
||||
# The repository is whatever this checkout pushes to, so a fork publishes to the
|
||||
# fork without editing anything.
|
||||
REPO="${REPO:-$(git remote get-url origin 2>/dev/null |
|
||||
sed -e 's#.*[:/]\([^/]*/[^/]*\)$#\1#' -e 's#\.git$##')}"
|
||||
[ -n "$REPO" ] || die "cannot work out the Gitea repo — set REPO=owner/name"
|
||||
|
||||
# What to upload: the arguments, or the packages in dist/ that belong to *this*
|
||||
# version. Two things this has to get right:
|
||||
#
|
||||
# - 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. 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
|
||||
for given in "$@"; do printf '%s\n' "$given"; done > "$LIST"
|
||||
else
|
||||
find "$DIST" -maxdepth 1 -type f -name "*$VERSION*" \
|
||||
\( -name '*.dmg' -o -name '*-mac.zip' -o -name '*.exe' -o -name '*.AppImage' -o -name '*.deb' \) \
|
||||
2>/dev/null | sort > "$LIST" || true
|
||||
fi
|
||||
[ -s "$LIST" ] || die "no $VERSION packages in $DIST — run 'make dist' first"
|
||||
|
||||
say "$REPO $TAG (version $VERSION), login $LOGIN"
|
||||
|
||||
# The release id, or empty when there is no such tag. `tea api` exits 0 even for a
|
||||
# 404 — it answers {"message":"not found"} — so the body is what has to be read.
|
||||
release_id() {
|
||||
tea api "/repos/$REPO/releases/tags/$TAG" 2>/dev/null | node -e '
|
||||
try {
|
||||
console.log(JSON.parse(require("fs").readFileSync(0, "utf8")).id || "")
|
||||
} catch {
|
||||
// Not JSON, or no such release: an empty answer is the "no release yet" case.
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
# --- the release itself ----------------------------------------------------
|
||||
if [ -n "$(release_id)" ]; then
|
||||
say "the release already exists"
|
||||
else
|
||||
say "creating the release: $TITLE"
|
||||
if [ -f "$NOTES" ]; then
|
||||
tea releases create --login "$LOGIN" --repo "$REPO" --tag "$TAG" \
|
||||
--title "$TITLE" --note-file "$NOTES" >/dev/null
|
||||
else
|
||||
say "no $NOTES — the release gets a one-line note"
|
||||
tea releases create --login "$LOGIN" --repo "$REPO" --tag "$TAG" \
|
||||
--title "$TITLE" --note "Packages built from $TAG." >/dev/null
|
||||
fi
|
||||
fi
|
||||
|
||||
RELEASE_ID="$(release_id)"
|
||||
[ -n "$RELEASE_ID" ] || die "the release $TAG could not be created or found"
|
||||
|
||||
# --- the attachments -------------------------------------------------------
|
||||
# Anything already attached under this name, dropped: replacing rather than
|
||||
# refusing is what makes a rebuild-and-upload repeatable. Called before every
|
||||
# attempt, so a retry cannot leave two copies behind.
|
||||
drop_existing() {
|
||||
ids="$(tea api "/repos/$REPO/releases/$RELEASE_ID/assets" | node -e '
|
||||
const name = process.argv[1]
|
||||
for (const asset of JSON.parse(require("fs").readFileSync(0, "utf8"))) {
|
||||
if (asset.name === name) console.log(asset.id)
|
||||
}
|
||||
' -- "$1")"
|
||||
for id in $ids; do
|
||||
say "replacing $1"
|
||||
tea api -X DELETE "/repos/$REPO/releases/$RELEASE_ID/assets/$id" >/dev/null
|
||||
done
|
||||
}
|
||||
|
||||
while IFS= read -r asset; do
|
||||
[ -n "$asset" ] || continue
|
||||
[ -f "$asset" ] || die "no such file: $asset"
|
||||
name="$(basename "$asset")"
|
||||
size="$(node -e 'console.log((require("fs").statSync(process.argv[1]).size / 1e6).toFixed(0) + " MB")' -- "$asset")"
|
||||
|
||||
# Retried, because a 100 MB upload does fail on its own: publishing 1.2.0 got
|
||||
# "invalid username, password or token" on the second package while the first
|
||||
# had just gone up with the same token, and the identical command succeeded on
|
||||
# the next run. One flake should not cost a rebuild.
|
||||
attempt=1
|
||||
while :; do
|
||||
drop_existing "$name"
|
||||
say "uploading $name ($size) — large packages take a few minutes"
|
||||
if tea releases assets create --login "$LOGIN" --repo "$REPO" "$TAG" "$asset" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
[ "$attempt" -lt 3 ] || die "$name could not be uploaded after $attempt attempts"
|
||||
attempt=$((attempt + 1))
|
||||
say "that failed — attempt $attempt of 3"
|
||||
done
|
||||
done < "$LIST"
|
||||
|
||||
say "done:"
|
||||
tea api "/repos/$REPO/releases/$RELEASE_ID" | node -e '
|
||||
const release = JSON.parse(require("fs").readFileSync(0, "utf8"))
|
||||
for (const asset of release.assets || []) {
|
||||
console.log(` ${asset.name} ${(asset.size / 1e6).toFixed(0)} MB`)
|
||||
}
|
||||
console.log(` ${release.html_url}`)
|
||||
'
|
||||
@@ -1,103 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict'
|
||||
// Drives the bridge without Electron: no window, no packaging, just the part
|
||||
// that talks to the store. This is where an integration mistake shows up first,
|
||||
// so it is the check to run after touching lib/store.js or the CLI.
|
||||
//
|
||||
// npm run smoke the store installed on this machine
|
||||
// SMOKE_HOME=/path/to/store-home npm run smoke a sandbox store
|
||||
|
||||
const path = require('node:path')
|
||||
const fs = require('node:fs')
|
||||
const store = require('../lib/store')
|
||||
const i18n = require('../lib/i18n')
|
||||
|
||||
function ok (label, value) {
|
||||
console.log(` ok ${label}${value === undefined ? '' : `: ${value}`}`)
|
||||
}
|
||||
function bad (label, value) {
|
||||
console.log(` FAIL ${label}${value === undefined ? '' : `: ${value}`}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
async function main () {
|
||||
console.log('warp-engine-desktop-gui smoke test')
|
||||
|
||||
const python = store.findPython()
|
||||
if (python) ok('python', python.version)
|
||||
else return bad('python', 'not found — the store cannot run')
|
||||
|
||||
for (const lang of i18n.languages) {
|
||||
const dict = i18n.dict(lang)
|
||||
const missing = Object.keys(i18n.STRINGS[i18n.FALLBACK]).filter((k) => !dict[k])
|
||||
if (missing.length) bad(`strings:${lang}`, `missing ${missing.join(', ')}`)
|
||||
else ok(`strings:${lang}`, `${Object.keys(dict).length} keys`)
|
||||
}
|
||||
|
||||
let target = null
|
||||
if (process.env.SMOKE_HOME) {
|
||||
const home = path.resolve(process.env.SMOKE_HOME)
|
||||
target = {
|
||||
engine: 'desktop',
|
||||
id: path.basename(home).replace(/-desktop$/, ''),
|
||||
home,
|
||||
script: path.join(home, 'desktop_store.py'),
|
||||
config: path.join(home, 'config.json')
|
||||
}
|
||||
for (const file of [target.script, target.config]) {
|
||||
if (!fs.existsSync(file)) return bad('SMOKE_HOME', `${file} is missing`)
|
||||
}
|
||||
ok('store (SMOKE_HOME)', target.home)
|
||||
} else {
|
||||
const stores = store.findStores()
|
||||
if (!stores.length) {
|
||||
console.log(' skip no store installed — run the app once, or set SMOKE_HOME')
|
||||
console.log(` it would be installed in ${store.defaultHome()}`)
|
||||
return
|
||||
}
|
||||
target = stores[0]
|
||||
ok('store found', `${target.id} in ${target.home}`)
|
||||
}
|
||||
|
||||
const logs = []
|
||||
const paths = await store.paths(target, { onLog: (l) => logs.push(l) })
|
||||
if (paths.os && paths.store_folder) ok('paths', `${paths.os} → ${paths.store_folder}`)
|
||||
else bad('paths', JSON.stringify(paths))
|
||||
|
||||
const listing = await store.list(target, { onLog: (l) => logs.push(l) })
|
||||
const games = listing.games || []
|
||||
if (!games.length) return bad('list', 'no games came back')
|
||||
|
||||
const modes = games.reduce((acc, g) => {
|
||||
acc[g.mode] = (acc[g.mode] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
ok('list', `${games.length} titles (${Object.entries(modes).map(([m, n]) => `${m}:${n}`).join(', ')})`)
|
||||
|
||||
const required = ['name', 'title', 'platform', 'version', 'mode', 'kind', 'installed', 'update_available']
|
||||
const broken = games.filter((g) => required.some((k) => g[k] === undefined))
|
||||
if (broken.length) bad('game shape', `${broken.length} entries miss a field`)
|
||||
else ok('game shape', required.join(', '))
|
||||
|
||||
const hosted = games.filter((g) => g.mode === 'web')
|
||||
if (hosted.length && !hosted.every((g) => /^https?:\/\//.test(g.url || ''))) {
|
||||
bad('hosted urls', 'a web title has no usable url')
|
||||
} else if (hosted.length) {
|
||||
ok('hosted urls', hosted[0].url)
|
||||
}
|
||||
|
||||
const installed = games.filter((g) => g.installed)
|
||||
ok('installed', `${installed.length} of ${games.length}`)
|
||||
if (installed.length) {
|
||||
const withTarget = installed.filter((g) => g.menu_entry || g.exe || g.url)
|
||||
if (withTarget.length !== installed.length) bad('launch targets', 'an installed title has nothing to launch')
|
||||
else ok('launch targets', 'every installed title has one')
|
||||
}
|
||||
|
||||
if (logs.length) ok('stderr log', `${logs.length} lines (kept off stdout)`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.log(` FAIL ${err && err.code ? err.code : 'error'}: ${err && err.message ? err.message : err}`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { readAccessVerdict, type CatalogPrice } from '../../domain/models/CatalogAccess'
|
||||
import type { Game } from '../../domain/models/Game'
|
||||
import type { GameDto } from '../../shared/contracts/dto/GameDto'
|
||||
|
||||
const ABSOLUTE_URL = /^https?:\/\//
|
||||
|
||||
/**
|
||||
* A title as the window may see it.
|
||||
*
|
||||
* 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 {
|
||||
return {
|
||||
name: game.name,
|
||||
title: game.title,
|
||||
platform: game.platform,
|
||||
version: game.version,
|
||||
mode: game.mode,
|
||||
kind: game.kind,
|
||||
description: game.description,
|
||||
author: game.author,
|
||||
imageUrl: this.resolveImageUrl(game, catalogBaseUrl),
|
||||
installed: game.installed,
|
||||
updateAvailable: game.updateAvailable,
|
||||
installedVersion: game.installedVersion,
|
||||
launchable: this.isLaunchable(game),
|
||||
installable: game.installable,
|
||||
unavailableReason: game.unavailableReason,
|
||||
unavailableDetail: game.unavailableDetail,
|
||||
accessVerdict: readAccessVerdict(game.access),
|
||||
priceLabel: formatPrice(game.access?.price ?? null),
|
||||
purchaseUrl: game.access?.purchaseUrl ?? null
|
||||
}
|
||||
}
|
||||
|
||||
public toDtoList (games: readonly Game[], catalogBaseUrl: string): readonly GameDto[] {
|
||||
return games.map((game: Game): GameDto => this.toDto(game, catalogBaseUrl))
|
||||
}
|
||||
|
||||
private resolveImageUrl (game: Game, catalogBaseUrl: string): string | null {
|
||||
if (game.imagePath === null) return null
|
||||
if (ABSOLUTE_URL.test(game.imagePath)) return game.imagePath
|
||||
return catalogBaseUrl.length > 0 ? `${catalogBaseUrl}${game.imagePath}` : null
|
||||
}
|
||||
|
||||
private isLaunchable (game: Game): boolean {
|
||||
if (!game.installed) return false
|
||||
if (game.mode === 'web') return game.hostedUrl !== null
|
||||
return game.menuEntryPath !== null || game.executablePath !== null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A price as a person reads it, in the currency the catalog named.
|
||||
*
|
||||
* `Intl` with the *catalog's* currency and the system locale: the store decides what it
|
||||
* charges in, the reader's machine decides where the symbol and the separators go.
|
||||
* There is no conversion here and there must not be — inventing an exchange rate would
|
||||
* be quoting a price nobody agreed to.
|
||||
*/
|
||||
function formatPrice (price: CatalogPrice | null): string | null {
|
||||
if (price === null) return null
|
||||
if (price.amountCents <= 0) return null
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency', currency: price.currency
|
||||
}).format(price.amountCents / 100)
|
||||
} catch {
|
||||
// An unknown currency code: better the number and the code than nothing at all.
|
||||
return `${(price.amountCents / 100).toFixed(2)} ${price.currency}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
||||
import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto'
|
||||
|
||||
export class InstalledStoreDtoMapper {
|
||||
public toDto (store: InstalledStore): InstalledStoreDto {
|
||||
return { id: store.id, name: store.name, home: store.home, engine: store.engine }
|
||||
}
|
||||
|
||||
public toDtoList (stores: readonly InstalledStore[]): readonly InstalledStoreDto[] {
|
||||
return stores.map((store: InstalledStore): InstalledStoreDto => this.toDto(store))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { RegistryStore } from '../../domain/models/RegistryStore'
|
||||
import { deriveStoreId } from '../../domain/models/StoreIdentity'
|
||||
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
|
||||
|
||||
export class RegistryStoreDtoMapper {
|
||||
public toDto (store: RegistryStore): RegistryStoreDto {
|
||||
return {
|
||||
name: store.name,
|
||||
catalogUrl: store.catalogUrl,
|
||||
storeId: deriveStoreId(store)
|
||||
}
|
||||
}
|
||||
|
||||
public toDtoList (stores: readonly RegistryStore[]): readonly RegistryStoreDto[] {
|
||||
return stores.map((store: RegistryStore): RegistryStoreDto => this.toDto(store))
|
||||
}
|
||||
|
||||
/** The window hands a record straight back when asking for an install. */
|
||||
public toModel (dto: RegistryStoreDto): RegistryStore {
|
||||
return { name: dto.name, catalogUrl: dto.catalogUrl }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { StorePaths } from '../../domain/models/StorePaths'
|
||||
import type { StorePathsDto } from '../../shared/contracts/dto/StorePathsDto'
|
||||
|
||||
export class StorePathsDtoMapper {
|
||||
public toDto (paths: StorePaths): StorePathsDto {
|
||||
return {
|
||||
operatingSystem: paths.operatingSystem,
|
||||
architecture: paths.architecture,
|
||||
storeFolder: paths.storeFolder,
|
||||
menuGroup: paths.menuGroup,
|
||||
catalogBaseUrl: paths.catalogBaseUrl,
|
||||
storeName: paths.storeName,
|
||||
storeId: paths.storeId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
||||
import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment'
|
||||
import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto'
|
||||
import { TranslationCatalog } from '../../shared/i18n/TranslationCatalog'
|
||||
import { InstalledStoreDtoMapper } from '../mappers/InstalledStoreDtoMapper'
|
||||
import type { PreferencesService } from './PreferencesService'
|
||||
import type { StoreProvisioningService } from './StoreProvisioningService'
|
||||
import type { StoreSelectionService } from './StoreSelectionService'
|
||||
|
||||
/**
|
||||
* Everything the window needs before it can paint anything, in one answer.
|
||||
*
|
||||
* One call rather than six, because the first frame should not be a sequence of round
|
||||
* trips. There is one decision left in it — whether a store is set up yet — now that
|
||||
* the engine ships with the application and cannot be missing or out of date.
|
||||
*/
|
||||
export class ApplicationStateService {
|
||||
public constructor (
|
||||
private readonly preferences: PreferencesService,
|
||||
private readonly selection: StoreSelectionService,
|
||||
private readonly provisioning: StoreProvisioningService,
|
||||
private readonly environment: ApplicationEnvironment,
|
||||
private readonly translations: TranslationCatalog = new TranslationCatalog(),
|
||||
private readonly storeMapper: InstalledStoreDtoMapper = new InstalledStoreDtoMapper()
|
||||
) {}
|
||||
|
||||
public readState (): AppStateDto {
|
||||
const locale = this.preferences.readLocale()
|
||||
const stores = this.selection.listStores()
|
||||
const current: InstalledStore | null = this.selection.findCurrentStore()
|
||||
|
||||
return {
|
||||
locale,
|
||||
locales: this.translations.locales,
|
||||
messages: this.translations.readBundle(locale),
|
||||
navigationOpen: this.preferences.readNavigationOpen(),
|
||||
currentStore: current === null ? null : this.storeMapper.toDto(current),
|
||||
stores: this.storeMapper.toDtoList(stores),
|
||||
registryUrl: this.provisioning.registryUrl,
|
||||
defaultStoreRoot: this.selection.readDefaultStoreRoot(),
|
||||
appVersion: this.environment.readVersion()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { CatalogListing } from '../../domain/models/CatalogListing'
|
||||
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
|
||||
import type { Game } from '../../domain/models/Game'
|
||||
import type { StorePaths } from '../../domain/models/StorePaths'
|
||||
import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
|
||||
import type { StoreSelectionService } from './StoreSelectionService'
|
||||
|
||||
/**
|
||||
* The catalog of the store that is open.
|
||||
*
|
||||
* The last listing is kept so a launch can be resolved by name: the window asks
|
||||
* for "pong", and the paths it would need to start it never leave this process.
|
||||
*/
|
||||
export class CatalogService {
|
||||
private lastListing: CatalogListing | null = null
|
||||
|
||||
public constructor (
|
||||
private readonly catalogGateway: StoreCatalogGateway,
|
||||
private readonly selection: StoreSelectionService
|
||||
) {}
|
||||
|
||||
public async listGames (progress?: EngineProgressListener): Promise<CatalogListing> {
|
||||
const listing = await this.catalogGateway.listGames(this.selection.requireCurrentStore(), progress)
|
||||
this.lastListing = listing
|
||||
return listing
|
||||
}
|
||||
|
||||
public async readPaths (progress?: EngineProgressListener): Promise<StorePaths> {
|
||||
return this.catalogGateway.readPaths(this.selection.requireCurrentStore(), progress)
|
||||
}
|
||||
|
||||
public async syncGames (names: readonly string[], progress?: EngineProgressListener): Promise<void> {
|
||||
await this.catalogGateway.syncGames(this.selection.requireCurrentStore(), names, progress)
|
||||
this.forgetListing()
|
||||
}
|
||||
|
||||
public async removeGame (name: string, progress?: EngineProgressListener): Promise<void> {
|
||||
await this.catalogGateway.removeGame(this.selection.requireCurrentStore(), name, progress)
|
||||
this.forgetListing()
|
||||
}
|
||||
|
||||
public findGame (name: string): Game | null {
|
||||
return this.lastListing?.games.find((game: Game): boolean => game.name === name) ?? null
|
||||
}
|
||||
|
||||
public findCatalogBaseUrl (): string {
|
||||
return this.lastListing?.paths?.catalogBaseUrl ?? ''
|
||||
}
|
||||
|
||||
/** After a write the listing is stale; the window refreshes anyway. */
|
||||
public forgetListing (): void {
|
||||
this.lastListing = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { GameLauncher } from '../../domain/ports/GameLauncher'
|
||||
import type { CatalogService } from './CatalogService'
|
||||
|
||||
/**
|
||||
* Starting a title the window asked for by name.
|
||||
*
|
||||
* The name is all the window has; the launch target comes from the catalog this
|
||||
* process last read.
|
||||
*/
|
||||
export class GameLaunchService {
|
||||
public constructor (
|
||||
private readonly launcher: GameLauncher,
|
||||
private readonly catalog: CatalogService
|
||||
) {}
|
||||
|
||||
public async launchGame (name: string): Promise<boolean> {
|
||||
const game = this.catalog.findGame(name)
|
||||
if (game === null) return false
|
||||
return this.launcher.launchGame(game)
|
||||
}
|
||||
|
||||
public async openFolder (directory: string): Promise<boolean> {
|
||||
return this.launcher.openFolder(directory)
|
||||
}
|
||||
|
||||
public async openUrl (url: string): Promise<boolean> {
|
||||
return this.launcher.openUrl(url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Preferences } from '../../domain/models/Preferences'
|
||||
import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment'
|
||||
import type { PreferencesRepository } from '../../domain/ports/PreferencesRepository'
|
||||
import { TranslationCatalog } from '../../shared/i18n/TranslationCatalog'
|
||||
import type { Locale } from '../../shared/i18n/MessageBundle'
|
||||
|
||||
/**
|
||||
* What the client remembers, and what it falls back to.
|
||||
*
|
||||
* The reads never fail: an unreadable file, a language that no longer exists and a
|
||||
* fresh install all produce the same defaults.
|
||||
*/
|
||||
export class PreferencesService {
|
||||
public constructor (
|
||||
private readonly repository: PreferencesRepository,
|
||||
private readonly environment: ApplicationEnvironment,
|
||||
private readonly translations: TranslationCatalog = new TranslationCatalog()
|
||||
) {}
|
||||
|
||||
public readLocale (): Locale {
|
||||
const stored = this.repository.read().locale
|
||||
return this.translations.resolveLocale(stored ?? this.environment.readSystemLocale())
|
||||
}
|
||||
|
||||
public updateLocale (candidate: string): Locale {
|
||||
const locale = this.translations.resolveLocale(candidate)
|
||||
this.merge({ locale })
|
||||
return locale
|
||||
}
|
||||
|
||||
public readNavigationOpen (): boolean {
|
||||
return this.repository.read().navigationOpen ?? true
|
||||
}
|
||||
|
||||
public updateNavigationOpen (open: boolean): boolean {
|
||||
this.merge({ navigationOpen: open })
|
||||
return open
|
||||
}
|
||||
|
||||
public readStoreHome (): string | null {
|
||||
return this.repository.read().storeHome ?? null
|
||||
}
|
||||
|
||||
public updateStoreHome (home: string): void {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
|
||||
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
||||
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'
|
||||
|
||||
/**
|
||||
* Getting a store onto this machine.
|
||||
*
|
||||
* Which stores exist is the site's answer — this asks the registry and installs
|
||||
* what was chosen, into the folder the shell installer would have used. The newly
|
||||
* installed store becomes the open one, so the window can carry straight on.
|
||||
*/
|
||||
export class StoreProvisioningService {
|
||||
public constructor (
|
||||
private readonly registry: StoreRegistryRepository,
|
||||
private readonly installer: StoreEngineInstaller,
|
||||
private readonly stores: InstalledStoreRepository,
|
||||
private readonly selection: StoreSelectionService,
|
||||
private readonly catalogGateway: StoreCatalogGateway
|
||||
) {}
|
||||
|
||||
public get registryUrl (): string {
|
||||
return this.registry.sourceUrl
|
||||
}
|
||||
|
||||
public async listAvailableStores (): Promise<readonly RegistryStore[]> {
|
||||
return this.registry.listStores()
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the chosen store.
|
||||
*
|
||||
* The window's choice is taken at face value, which is safe because a record is only a
|
||||
* name and a catalog: there is no path in it and nothing that decides what may be
|
||||
* deleted. The store's own configuration is written by the installer from the engine's
|
||||
* defaults, so the renderer cannot influence where anything lands.
|
||||
*/
|
||||
public async installStore (
|
||||
store: RegistryStore,
|
||||
progress?: EngineProgressListener
|
||||
): Promise<InstalledStore> {
|
||||
const home = this.stores.resolveDefaultHome(deriveStoreId(store))
|
||||
const installed = await this.installer.installEngine(home, store, progress)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { StoreMissingError } from '../../domain/errors/StoreMissingError'
|
||||
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
||||
import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository'
|
||||
import type { PreferencesService } from './PreferencesService'
|
||||
|
||||
/**
|
||||
* Which store is open.
|
||||
*
|
||||
* A machine can carry several: two catalogs, or the same catalog installed twice.
|
||||
* The remembered one wins, so the window reopens where it was left; the current
|
||||
* store is cached because every catalog call needs it and re-scanning the disk per
|
||||
* call would be silly.
|
||||
*/
|
||||
export class StoreSelectionService {
|
||||
private current: InstalledStore | null = null
|
||||
|
||||
public constructor (
|
||||
private readonly stores: InstalledStoreRepository,
|
||||
private readonly preferences: PreferencesService
|
||||
) {}
|
||||
|
||||
public listStores (): readonly InstalledStore[] {
|
||||
return this.stores.findAll()
|
||||
}
|
||||
|
||||
/** The store to drive, remembering the choice across runs. Null when there is none. */
|
||||
public findCurrentStore (): InstalledStore | null {
|
||||
const known = this.stores.findAll()
|
||||
const preferredHome = this.preferences.readStoreHome()
|
||||
const remembered = preferredHome === null
|
||||
? undefined
|
||||
: known.find((store: InstalledStore): boolean => store.home === preferredHome)
|
||||
this.current = remembered ?? known[0] ?? null
|
||||
return this.current
|
||||
}
|
||||
|
||||
public requireCurrentStore (): InstalledStore {
|
||||
const store = this.current ?? this.findCurrentStore()
|
||||
if (store === null) throw new StoreMissingError()
|
||||
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)
|
||||
this.current = store
|
||||
this.preferences.updateStoreHome(store.home)
|
||||
return store
|
||||
}
|
||||
|
||||
/** Adopt a store that was just installed, without a disk scan. */
|
||||
public adoptStore (store: InstalledStore): InstalledStore {
|
||||
this.current = store
|
||||
this.preferences.updateStoreHome(store.home)
|
||||
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,10 @@
|
||||
import { DomainError } from './DomainError'
|
||||
|
||||
/** A second engine call while one is running. The store writes files; two writers race. */
|
||||
export class BusyError extends DomainError {
|
||||
public override readonly code: string = 'BUSY'
|
||||
|
||||
public constructor () {
|
||||
super('the store is busy with another operation')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* The base for every error this application raises on purpose.
|
||||
*
|
||||
* `code` is what crosses the bridge: the window shows its own sentence for a code
|
||||
* it knows, and the message only ever ends up in the log drawer.
|
||||
*/
|
||||
export abstract class DomainError extends Error {
|
||||
public abstract readonly code: string
|
||||
|
||||
protected constructor (message: string) {
|
||||
super(message)
|
||||
this.name = new.target.name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { DomainError } from './DomainError'
|
||||
|
||||
/** The engine ran and failed: a non-zero exit, or a process that never started. */
|
||||
export class EngineInvocationError extends DomainError {
|
||||
public override readonly code: string = 'ENGINE_FAILED'
|
||||
|
||||
public constructor (message: string, public readonly exitCode: number | null = null) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { DomainError } from './DomainError'
|
||||
|
||||
export class RegistryUnavailableError extends DomainError {
|
||||
public override readonly code: string = 'REGISTRY_UNAVAILABLE'
|
||||
|
||||
public constructor (public readonly sourceUrl: string, reason: string) {
|
||||
super(`${sourceUrl}: ${reason}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { DomainError } from './DomainError'
|
||||
|
||||
export class StoreMissingError extends DomainError {
|
||||
public override readonly code: string = 'STORE_MISSING'
|
||||
|
||||
public constructor (home?: string) {
|
||||
super(home === undefined ? 'no store is installed yet' : `no store at ${home}`)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Game } from './Game'
|
||||
import type { StoreAccount } from './StoreAccount'
|
||||
import type { StorePaths } from './StorePaths'
|
||||
|
||||
/** One reading of a store's catalog. */
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* The three places a desktop store writes, resolved for this machine.
|
||||
*
|
||||
* `installRoot` holds the payloads and `menuDirectory` the launchers the user
|
||||
* actually sees. `sources` records where each answer came from — a default, the
|
||||
* config or an override — because "why is my menu entry there" is the first
|
||||
* question anyone asks of a store that writes outside its own folder.
|
||||
*/
|
||||
export interface DesktopLayout {
|
||||
readonly operatingSystem: string
|
||||
readonly installRoot: string
|
||||
readonly menuDirectory: string
|
||||
readonly iconDirectory: string | null
|
||||
readonly sources: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
/** Where each OS keeps installed programs and its application menu. */
|
||||
export const OPERATING_SYSTEM_PATHS: Readonly<Record<string, Readonly<Record<string, string | null>>>> = {
|
||||
linux: {
|
||||
installRoot: '$XDG_DATA_HOME|~/.local/share',
|
||||
menuDirectory: '$XDG_DATA_HOME|~/.local/share/applications',
|
||||
iconDirectory: '$XDG_DATA_HOME|~/.local/share/icons'
|
||||
},
|
||||
darwin: {
|
||||
installRoot: '~/Library/Application Support',
|
||||
menuDirectory: '~/Applications',
|
||||
iconDirectory: null
|
||||
},
|
||||
windows: {
|
||||
installRoot: '$LOCALAPPDATA|~/AppData/Local',
|
||||
menuDirectory: '$APPDATA|~/AppData/Roaming/Microsoft/Windows/Start Menu/Programs',
|
||||
iconDirectory: null
|
||||
}
|
||||
}
|
||||
|
||||
/** A file name the OS will accept, from a human title. */
|
||||
export function toSafeFileName (text: string): string {
|
||||
return text.trim().replace(/[\\/:*?"<>|]/g, '_') || 'game'
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { SyncEventDto } from '../../shared/contracts/dto/SyncEventDto'
|
||||
|
||||
/**
|
||||
* How a long-running engine call reports itself.
|
||||
*
|
||||
* `onLog` is a line a person can read (the engine's stderr), `onEvent` one of its
|
||||
* JSON progress events. Both are optional: a caller that only wants the result
|
||||
* passes neither.
|
||||
*/
|
||||
export interface EngineProgressListener {
|
||||
readonly onLog?: (line: string) => void
|
||||
readonly onEvent?: (event: SyncEventDto) => void
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { CatalogAccess } from './CatalogAccess'
|
||||
|
||||
/** How a title runs: unpacked on this machine, or served as a web build. */
|
||||
export type GameMode = 'app' | 'web'
|
||||
|
||||
/**
|
||||
* Why a title cannot be installed here, as the engine codes it.
|
||||
*
|
||||
* `platformOff` is the store not carrying that platform at all — a C64 cartridge on a
|
||||
* desktop — and the other three are about this machine or this catalog: no asset kind
|
||||
* for the os and architecture, no release carrying it, or the adapter refusing it.
|
||||
*/
|
||||
export type UnavailableReason = 'platformOff' | 'hostAsset' | 'noAsset' | 'vetoed'
|
||||
|
||||
/**
|
||||
* A catalog entry, with what the store did about it on this machine.
|
||||
*
|
||||
* The launch targets live here and nowhere nearer the window: resolving what to
|
||||
* open is the main process's job.
|
||||
*/
|
||||
export interface Game {
|
||||
readonly name: string
|
||||
readonly title: string
|
||||
readonly platform: string
|
||||
readonly version: string
|
||||
readonly mode: GameMode
|
||||
readonly kind: string
|
||||
readonly description: string
|
||||
readonly author: string
|
||||
/** Relative to the catalog's base URL, as published. */
|
||||
readonly imagePath: string | null
|
||||
readonly installed: boolean
|
||||
readonly updateAvailable: boolean
|
||||
readonly installedVersion: string | null
|
||||
readonly menuEntryPath: string | null
|
||||
readonly executablePath: string | null
|
||||
readonly hostedUrl: string | null
|
||||
/**
|
||||
* False for a title this machine cannot install. It is still listed: a catalog that
|
||||
* hides what your machine cannot run leaves you wondering which of the two is small.
|
||||
*/
|
||||
readonly installable: boolean
|
||||
readonly unavailableReason: UnavailableReason | null
|
||||
/** The engine's sentence for it, for a tooltip or the log. */
|
||||
readonly unavailableDetail: string | null
|
||||
/**
|
||||
* What the catalog says about getting it, or null where it said nothing.
|
||||
*
|
||||
* Kept separate from `installable`: that one is about this machine — no build for
|
||||
* this architecture — and this one is about this person. A title can be perfectly
|
||||
* installable and still not yours.
|
||||
*/
|
||||
readonly access: CatalogAccess | null
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* The machine the store is running on, and how a config value depends on it.
|
||||
*
|
||||
* A native binary only starts on the architecture it was built for, so the release
|
||||
* picker has to know: an x86_64 build installs on a Raspberry Pi and then does
|
||||
* nothing, which is worse than not offering it at all.
|
||||
*/
|
||||
export interface HostMachine {
|
||||
readonly operatingSystem: string
|
||||
readonly architecture: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A config value that may differ per machine.
|
||||
*
|
||||
* A plain value is the same everywhere — that is how a `cartridge` is described,
|
||||
* being data for an emulator. Where it differs, the value is a map keyed by host.
|
||||
*/
|
||||
export type HostSpecific<TValue> = TValue | Readonly<Record<string, TValue>>
|
||||
|
||||
/**
|
||||
* Resolve a host-specific value; the most specific key wins.
|
||||
*
|
||||
* {"linux-aarch64": …, "aarch64": …, "linux": …, "*": …}
|
||||
*
|
||||
* With no matching key and no `*` the answer is `null`, and the caller reports the
|
||||
* title as unavailable rather than installing something that cannot run.
|
||||
*/
|
||||
export function resolveForHost<TValue> (
|
||||
value: HostSpecific<TValue> | null | undefined,
|
||||
host: HostMachine
|
||||
): TValue | null {
|
||||
if (value === null || value === undefined) return null
|
||||
if (!isHostMap(value)) return value
|
||||
for (const key of hostKeys(host)) {
|
||||
const found = value[key]
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function hostKeys (host: HostMachine): readonly string[] {
|
||||
return [
|
||||
`${host.operatingSystem}-${host.architecture}`,
|
||||
host.architecture,
|
||||
host.operatingSystem,
|
||||
'*'
|
||||
]
|
||||
}
|
||||
|
||||
export function describeHost (host: HostMachine): string {
|
||||
return `${host.operatingSystem}/${host.architecture}`
|
||||
}
|
||||
|
||||
/**
|
||||
* A host map, as opposed to a value that happens to be an object.
|
||||
*
|
||||
* Only plain objects are maps: an array is a value here — `kind` may be a list of
|
||||
* asset kinds in order of preference, and that is not keyed by anything.
|
||||
*/
|
||||
function isHostMap<TValue> (
|
||||
value: HostSpecific<TValue>
|
||||
): value is Readonly<Record<string, TValue>> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { SelectedGame } from './SelectedGame'
|
||||
|
||||
/**
|
||||
* One title as it exists on this machine: what was written, and where.
|
||||
*
|
||||
* This is the shape `state.json` carries, keyed `<scope>:<name>`. Every path in it
|
||||
* is something the store put there and may therefore delete — which is why an
|
||||
* uninstall reads the record rather than guessing at paths.
|
||||
*
|
||||
* The catalog's `access` block is deliberately *not* part of it. Whether somebody may
|
||||
* download a title is the server's answer to a question asked now; a copy of it on disk
|
||||
* would go stale the moment a purchase or a refund happened, and a stale "yes" is the
|
||||
* dangerous direction. What is installed stays installed either way.
|
||||
*/
|
||||
export interface InstalledRecord extends Omit<SelectedGame, 'access'> {
|
||||
/** The unpacked archive's directory; null for a hosted entry, which has none. */
|
||||
readonly payload: string | null
|
||||
readonly executable: string | null
|
||||
/** `bundle` for a macOS `.app` the archive already contained, `exe` for a binary. */
|
||||
readonly executableKind: string | null
|
||||
readonly icon: string | null
|
||||
/** The menu entry actually written, which is not always the one intended. */
|
||||
readonly menuEntry: string | null
|
||||
readonly url: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The scope a state record belongs to.
|
||||
*
|
||||
* `scope` is what the engine writes today. `system` is what the Batocera store
|
||||
* wrote before the shared core existed, and there the two were the same string —
|
||||
* so an installed machine keeps working without a migration.
|
||||
*/
|
||||
export function recordScope (scope: string | null, system: string | null): string {
|
||||
return scope ?? system ?? ''
|
||||
}
|
||||
|
||||
/** Two scopes may both carry a game called `foo` — key on both. */
|
||||
export function gameKey (record: { readonly name: string; readonly scope: string }): string {
|
||||
return `${record.scope}:${record.name}`
|
||||
}
|
||||
|
||||
/** Resolve user-typed `name` or `scope:name` arguments to state keys. */
|
||||
export function matchStateKeys (
|
||||
installed: ReadonlyMap<string, InstalledRecord>,
|
||||
names: readonly string[]
|
||||
): readonly string[] {
|
||||
const wanted = new Set(names.map((name: string): string => name.toLowerCase()))
|
||||
const keys: string[] = []
|
||||
for (const [key, record] of installed) {
|
||||
if (wanted.has(key.toLowerCase()) || wanted.has(record.name.toLowerCase())) keys.push(key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
/** Keep only the games the user named, by bare name or `scope:name`. */
|
||||
export function limitToNames (
|
||||
games: readonly SelectedGame[],
|
||||
names: readonly string[]
|
||||
): readonly SelectedGame[] {
|
||||
const wanted = new Set(names.map((name: string): string => name.toLowerCase()))
|
||||
return games.filter((game: SelectedGame): boolean =>
|
||||
wanted.has(game.name.toLowerCase()) || wanted.has(gameKey(game).toLowerCase()))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* A store set up on this machine.
|
||||
*
|
||||
* A home and a config, which is all a store is now that the engine is part of this
|
||||
* application: the home holds the state and the catalog cache, and the config says
|
||||
* what the store offers and where its games go.
|
||||
*/
|
||||
export interface InstalledStore {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly home: string
|
||||
readonly configPath: string
|
||||
readonly engine: string
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Locale } from '../../shared/i18n/MessageBundle'
|
||||
|
||||
/** What the client remembers between runs. Every field optional: a fresh install has none. */
|
||||
export interface Preferences {
|
||||
readonly locale?: Locale
|
||||
readonly navigationOpen?: boolean
|
||||
/** The home of the store last opened, so the window reopens where it was left. */
|
||||
readonly storeHome?: string
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* A store the site's registry offers: a name and a catalog.
|
||||
*
|
||||
* That is the whole record, and it is enough. How a store behaves is not the registry's
|
||||
* business — this client carries its own store engine, whose defaults cover the
|
||||
* host-to-asset mapping, the install modes, the platforms and the behaviour — so what
|
||||
* was actually missing from those defaults is identity, and identity is all this is.
|
||||
*
|
||||
* Keeping two stores on one machine out of each other's files is a subfolder, derived
|
||||
* here from the store's own slug rather than told to us by a server.
|
||||
*/
|
||||
export interface RegistryStore {
|
||||
readonly name: string
|
||||
readonly catalogUrl: string
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { CatalogAccess } from './CatalogAccess'
|
||||
import type { UnavailableReason } from './Game'
|
||||
|
||||
/**
|
||||
* A catalog entry the store can install here, with the release it chose.
|
||||
*
|
||||
* `scope` is how the host groups its library — for a desktop that is the catalog
|
||||
* platform. It is what `state.json` is keyed by (`<scope>:<name>`), so two scopes
|
||||
* can carry a game of the same name without colliding.
|
||||
*/
|
||||
export interface SelectedGame {
|
||||
readonly name: string
|
||||
readonly scope: string
|
||||
readonly platform: string
|
||||
readonly kind: string
|
||||
readonly version: string
|
||||
readonly asset: string
|
||||
/**
|
||||
* The catalog-side path, kept because not every asset is a download: an `html`
|
||||
* build is a hosted directory, and the entry is a link to it.
|
||||
*/
|
||||
readonly assetPath: string
|
||||
readonly title: string
|
||||
readonly description: string
|
||||
readonly author: string
|
||||
readonly imageUrl: string | null
|
||||
readonly createdAt: string | null
|
||||
/** `app` for a native archive, `web` for a hosted page. */
|
||||
readonly mode: string
|
||||
/** What the catalog says about getting it; null from an engine that cannot say. */
|
||||
readonly access: CatalogAccess | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A title the store offers but this machine cannot install.
|
||||
*
|
||||
* A store that hides these is lying about its catalog by omission: "there is
|
||||
* nothing for your machine" is an answer, and an absent entry is not. Titles the
|
||||
* store *chooses* not to offer — the wrong status, an `only`/`exclude` list — are
|
||||
* not here, because that is editorial rather than a limitation of the machine.
|
||||
*/
|
||||
export interface UnavailableEntry {
|
||||
readonly name: string
|
||||
readonly title: string
|
||||
readonly platform: string
|
||||
readonly description: string
|
||||
readonly author: string
|
||||
readonly imageUrl: string | null
|
||||
readonly version: string
|
||||
readonly reason: UnavailableReason
|
||||
/** The sentence behind the code, for a tooltip or the log. */
|
||||
readonly detail: string
|
||||
/** Carried here too: a title with no build for this machine can still have a price. */
|
||||
readonly access: CatalogAccess | null
|
||||
}
|
||||
|
||||
/** What a survey of the catalog found: installable, why not, and what was skipped. */
|
||||
export interface CatalogSurvey {
|
||||
readonly games: readonly SelectedGame[]
|
||||
readonly skipped: readonly string[]
|
||||
readonly unavailable: readonly UnavailableEntry[]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* What one catalog's server says about itself.
|
||||
*
|
||||
* This is how the client stops being built for a particular store. Whether there is a
|
||||
* sign-in here, where it lives, whether any title can be gated — all of it used to be
|
||||
* knowledge the client would have had to carry, and a client that carries it works for
|
||||
* exactly one catalog. Now the server answers, and the same binary serves any of them.
|
||||
*
|
||||
* Every field is optional in practice: an engine older than 0.5 has no descriptor at
|
||||
* all, and `DEFAULT_SERVICE_DESCRIPTOR` is what that means — a plain catalog, nothing
|
||||
* gated, nobody to sign in as. That is what this client always assumed.
|
||||
*/
|
||||
export interface ServiceDescriptor {
|
||||
readonly engineVersion: string | null
|
||||
/** Whether any title in this catalog can require an entitlement. */
|
||||
readonly catalogGated: boolean
|
||||
/** Null where the server offers no sign-in, which is most of them. */
|
||||
readonly auth: AuthDescriptor | null
|
||||
}
|
||||
|
||||
export interface AuthDescriptor {
|
||||
/** The device authorization grant, for a client with no browser of its own. */
|
||||
readonly device: DeviceAuthDescriptor
|
||||
}
|
||||
|
||||
export interface DeviceAuthDescriptor {
|
||||
/** Where to ask for a code pair. */
|
||||
readonly authorizeUrl: string
|
||||
/** Where to poll for the token. */
|
||||
readonly tokenUrl: string
|
||||
/** Where to throw the token away again. */
|
||||
readonly revokeUrl: string | null
|
||||
/** Where a person takes the code, opened in their own browser. */
|
||||
readonly verificationUrl: string
|
||||
/** Seconds the server asks the client to wait between polls. */
|
||||
readonly interval: number
|
||||
}
|
||||
|
||||
export const DEFAULT_SERVICE_DESCRIPTOR: ServiceDescriptor = {
|
||||
engineVersion: null,
|
||||
catalogGated: false,
|
||||
auth: null
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Whether this machine is signed in to one store, and whether it could be.
|
||||
*
|
||||
* Two booleans rather than one, because the interesting case is the first being false:
|
||||
* most catalogs have no sign-in at all, and a client that shows a greyed-out "Sign in"
|
||||
* on them is telling people about a door that does not exist.
|
||||
*/
|
||||
export interface StoreAccount {
|
||||
readonly signInAvailable: boolean
|
||||
readonly signedIn: boolean
|
||||
}
|
||||
|
||||
export const NO_ACCOUNT: StoreAccount = { signInAvailable: false, signedIn: false }
|
||||
|
||||
/** What to show a person while they finish signing in somewhere else. */
|
||||
export interface SignInPrompt {
|
||||
/** Opaque to the window: it is the client's half of the exchange, not the person's. */
|
||||
readonly deviceCode: string
|
||||
/** The short one, shown on screen and typed into a browser. */
|
||||
readonly userCode: string
|
||||
/** Opened in the person's own browser. */
|
||||
readonly verificationUrl: string
|
||||
readonly intervalSeconds: number
|
||||
readonly expiresInSeconds: number
|
||||
}
|
||||
|
||||
/** How a sign-in ended. `cancelled` is this side giving up, `denied` is the person. */
|
||||
export type SignInOutcome = 'signedIn' | 'denied' | 'expired' | 'cancelled'
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { HostSpecific } from './HostMachine'
|
||||
|
||||
/**
|
||||
* How one store behaves: what it offers, where things land, how it talks to the API.
|
||||
*
|
||||
* This is the shape of a store's `config.json` after it has been merged onto the
|
||||
* defaults below. On disk the file is snake_case, which is the format the store
|
||||
* repositories publish; `StoreConfigurationReader` is the only place that knows it.
|
||||
*/
|
||||
export interface StoreConfiguration {
|
||||
readonly store: StoreDescriptor
|
||||
readonly paths: PathsConfiguration
|
||||
readonly install: InstallConfiguration
|
||||
readonly catalog: CatalogConfiguration
|
||||
/** Which catalog platforms this store offers at all, by platform name. */
|
||||
readonly platforms: Readonly<Record<string, PlatformConfiguration>>
|
||||
readonly behavior: BehaviorConfiguration
|
||||
}
|
||||
|
||||
export interface StoreDescriptor {
|
||||
/** Short slug: names the store home, the log prefix and the launcher files. */
|
||||
readonly id: string
|
||||
/** Human-readable: the Start-menu folder on Windows, and what the user sees. */
|
||||
readonly name: string
|
||||
readonly baseUrl: string
|
||||
readonly api: ApiConfiguration
|
||||
}
|
||||
|
||||
export interface ApiConfiguration {
|
||||
readonly catalog: string
|
||||
readonly download: string
|
||||
}
|
||||
|
||||
export interface PathsConfiguration {
|
||||
/** All null means "work it out from the OS"; set one to pin it. */
|
||||
readonly installRoot: string | null
|
||||
readonly menuDirectory: string | null
|
||||
readonly iconDirectory: string | null
|
||||
/**
|
||||
* Our own folder inside the install root: the prune boundary, and what keeps two
|
||||
* stores on one machine out of each other's files.
|
||||
*/
|
||||
readonly subfolder: string
|
||||
}
|
||||
|
||||
export interface AssetSpecification {
|
||||
/** One kind, or several in order of preference. */
|
||||
readonly kind: HostSpecific<string | readonly string[]> | null
|
||||
readonly extension: HostSpecific<string> | null
|
||||
}
|
||||
|
||||
export interface InstallConfiguration {
|
||||
/** Tried in this order; the first that has an asset wins. */
|
||||
readonly modes: readonly string[]
|
||||
readonly specifications: Readonly<Record<string, AssetSpecification>>
|
||||
}
|
||||
|
||||
export interface CatalogConfiguration {
|
||||
readonly statuses: readonly string[]
|
||||
readonly ownerId: number | null
|
||||
readonly only: readonly string[]
|
||||
readonly exclude: readonly string[]
|
||||
}
|
||||
|
||||
export interface PlatformConfiguration {
|
||||
readonly enabled: boolean
|
||||
}
|
||||
|
||||
export interface BehaviorConfiguration {
|
||||
readonly prune: boolean
|
||||
/** Seconds. */
|
||||
readonly timeout: number
|
||||
readonly insecure: boolean
|
||||
}
|
||||
|
||||
export const APP_MODE = 'app'
|
||||
export const WEB_MODE = 'web'
|
||||
|
||||
/**
|
||||
* What a store gets when its config says nothing.
|
||||
*
|
||||
* These were the desktop engine's own defaults, and they stay the defaults: a store
|
||||
* that publishes no `config.json` installs on the strength of this table alone, so
|
||||
* what a store actually has to supply is identity — a slug, a name and a catalog.
|
||||
*/
|
||||
export const DEFAULT_STORE_CONFIGURATION: StoreConfiguration = {
|
||||
store: {
|
||||
id: 'warp',
|
||||
name: 'WarpEngine Store',
|
||||
baseUrl: 'https://example.org',
|
||||
api: { catalog: '/api/software', download: '/api/download' }
|
||||
},
|
||||
paths: {
|
||||
installRoot: null,
|
||||
menuDirectory: null,
|
||||
iconDirectory: null,
|
||||
subfolder: 'warp'
|
||||
},
|
||||
install: {
|
||||
modes: [APP_MODE, WEB_MODE],
|
||||
specifications: {
|
||||
// Which asset to unpack, per host. The most specific key wins; a list is
|
||||
// tried in order of preference. On Apple Silicon `mac_universal` comes first
|
||||
// and `mac_x64` last, because that one needs Rosetta.
|
||||
[APP_MODE]: {
|
||||
kind: {
|
||||
'linux-x86_64': 'linux_x64',
|
||||
'linux-aarch64': 'linux_arm64',
|
||||
'linux-armhf': 'linux_armhf',
|
||||
'linux-x86': 'linux_x86',
|
||||
'windows-x86_64': ['win_x64', 'win_x86'],
|
||||
'windows-x86': 'win_x86',
|
||||
'darwin-aarch64': ['mac_universal', 'mac_arm64', 'mac_x64'],
|
||||
'darwin-x86_64': ['mac_universal', 'mac_x64']
|
||||
},
|
||||
extension: '.zip'
|
||||
},
|
||||
// A browser build is hosted, not downloaded: there is no archive for it, so
|
||||
// the entry opens the published page. It needs the network to play.
|
||||
[WEB_MODE]: { kind: 'html', extension: '' }
|
||||
}
|
||||
},
|
||||
catalog: {
|
||||
statuses: ['released', 'archived'],
|
||||
ownerId: null,
|
||||
only: [],
|
||||
exclude: []
|
||||
},
|
||||
platforms: {
|
||||
ebitengine: { enabled: true },
|
||||
godot: { enabled: true },
|
||||
love: { enabled: true },
|
||||
bevy: { enabled: true },
|
||||
tic80: { enabled: true },
|
||||
phaser: { enabled: true },
|
||||
// A cartridge is data for an emulator: nothing a desktop menu can launch. The
|
||||
// Batocera and RetroArch stores are where those belong.
|
||||
c64: { enabled: false }
|
||||
},
|
||||
behavior: { prune: true, timeout: 60, insecure: false }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* A store engine this client knows how to drive.
|
||||
*
|
||||
* There is one today: the desktop engine, which is the code in
|
||||
* `infrastructure/engine`. The table stays because a second host — RetroArch
|
||||
* playlists rather than menu entries — would be a second entry and a second
|
||||
* `StoreCatalogGateway`, not a second code path through the application.
|
||||
*
|
||||
* `homeSuffix` is load-bearing rather than cosmetic: it is how an existing store
|
||||
* home is recognised, so it has to keep saying `-desktop`.
|
||||
*/
|
||||
export interface StoreEngine {
|
||||
readonly id: string
|
||||
/** The installer names a store home `<store id><homeSuffix>`. */
|
||||
readonly homeSuffix: string
|
||||
readonly launcherSuffix: string
|
||||
}
|
||||
|
||||
export const DESKTOP_STORE_ENGINE: StoreEngine = {
|
||||
id: 'desktop',
|
||||
homeSuffix: '-desktop',
|
||||
launcherSuffix: '-desktop-store'
|
||||
}
|
||||
|
||||
export const STORE_ENGINES: readonly StoreEngine[] = [DESKTOP_STORE_ENGINE]
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { RegistryStore } from './RegistryStore'
|
||||
|
||||
/**
|
||||
* A store id, from whatever the registry gave us.
|
||||
*
|
||||
* The id names the store home, the folder games land in and the launcher files, so it
|
||||
* has to be short and filesystem-safe. Two sources, in order of how much they were
|
||||
* meant to be a name:
|
||||
*
|
||||
* 1. the catalog host — `https://teletypegames.org` becomes `teletypegames`;
|
||||
* 2. the display name, slugged, as a last resort.
|
||||
*
|
||||
* Derived rather than carried, and derived from the catalog: the catalog is what a store
|
||||
* *is*, so two records naming the same catalog are the same store and land in the same
|
||||
* place, which is what keeps a reinstall from orphaning what is already there.
|
||||
*/
|
||||
export function deriveStoreId (store: RegistryStore): string {
|
||||
return toSlug(readHostLabel(store.catalogUrl)) || toSlug(store.name) || 'store'
|
||||
}
|
||||
|
||||
/** `https://www.teletypegames.org/x` -> `teletypegames`. */
|
||||
function readHostLabel (catalogUrl: string): string {
|
||||
try {
|
||||
const host = new URL(catalogUrl).hostname.replace(/^www\./, '')
|
||||
return host.split('.')[0] ?? ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function toSlug (value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** The store's resolved locations on this machine, as the engine reports them. */
|
||||
export interface StorePaths {
|
||||
readonly operatingSystem: string
|
||||
readonly architecture: string
|
||||
readonly installRoot: string
|
||||
readonly menuDirectory: string
|
||||
readonly storeFolder: string
|
||||
readonly menuGroup: string
|
||||
readonly catalogBaseUrl: string
|
||||
readonly storeName: string
|
||||
readonly storeId: string
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Which WarpEngine a catalog is served by, and whether this client knows it.
|
||||
*
|
||||
* Every WarpEngine API response carries the engine's version in a header, set before
|
||||
* the action runs so that even an error response has it. That is what lets a client
|
||||
* branch on the engine's age without a round trip to ask — and this file is where the
|
||||
* branching starts.
|
||||
*/
|
||||
|
||||
/** `WarpEngine::VERSION_HEADER` on the server side. */
|
||||
export const WARP_ENGINE_VERSION_HEADER = 'warpengine-version'
|
||||
|
||||
/**
|
||||
* The engine versions this client is written against, oldest first.
|
||||
*
|
||||
* Minor precision, because that is the granularity the engine changes its API at: a
|
||||
* patch release fixes something behind the same shapes. Adding an entry here is a
|
||||
* compile error until `selectCatalogDialect` says which dialect it gets, which is the
|
||||
* point — a new engine version should not be able to arrive silently.
|
||||
*/
|
||||
export const SUPPORTED_WARP_ENGINE_VERSIONS = ['0.2', '0.3', '0.4', '0.5'] as const
|
||||
|
||||
export type SupportedWarpEngineVersion = typeof SUPPORTED_WARP_ENGINE_VERSIONS[number]
|
||||
|
||||
/**
|
||||
* Why the version this client will use is not simply the one the server named.
|
||||
*
|
||||
* - `exact` — the header named a version in the supported list;
|
||||
* - `absent` — no header at all. An engine older than 0.4.0 does not send one, so
|
||||
* this means "old", not "broken", and the oldest dialect is the honest
|
||||
* reading of it;
|
||||
* - `older` — a version below everything here: same treatment, but it said so;
|
||||
* - `newer` — a version above everything here. The newest dialect is tried anyway,
|
||||
* because listing nothing is worse than listing what still parses, but
|
||||
* this is the case worth putting in the log.
|
||||
*/
|
||||
export type WarpEngineVersionMatch = 'exact' | 'absent' | 'older' | 'newer'
|
||||
|
||||
export interface WarpEngineVersion {
|
||||
/** As the header spelled it, or null when there was none. */
|
||||
readonly text: string | null
|
||||
/** The supported version whose dialect will be used. Never null: one always applies. */
|
||||
readonly resolved: SupportedWarpEngineVersion
|
||||
readonly match: WarpEngineVersionMatch
|
||||
readonly supported: boolean
|
||||
}
|
||||
|
||||
const OLDEST: SupportedWarpEngineVersion = SUPPORTED_WARP_ENGINE_VERSIONS[0]
|
||||
const NEWEST: SupportedWarpEngineVersion =
|
||||
SUPPORTED_WARP_ENGINE_VERSIONS[SUPPORTED_WARP_ENGINE_VERSIONS.length - 1] ?? OLDEST
|
||||
|
||||
/**
|
||||
* Read the header into a decision.
|
||||
*
|
||||
* An unparseable value is treated as an absent one: a header that does not look like a
|
||||
* version tells us nothing about the engine, and guessing from a malformed string is
|
||||
* worse than admitting we do not know.
|
||||
*/
|
||||
export function readWarpEngineVersion (headerValue: string | null): WarpEngineVersion {
|
||||
if (headerValue === null || headerValue.trim().length === 0) {
|
||||
return { text: null, resolved: OLDEST, match: 'absent', supported: false }
|
||||
}
|
||||
const text = headerValue.trim()
|
||||
const numbers = parseVersion(text)
|
||||
if (numbers === null) {
|
||||
return { text, resolved: OLDEST, match: 'absent', supported: false }
|
||||
}
|
||||
|
||||
const key = `${String(numbers[0])}.${String(numbers[1])}`
|
||||
const exact = SUPPORTED_WARP_ENGINE_VERSIONS
|
||||
.find((candidate: SupportedWarpEngineVersion): boolean => candidate === key)
|
||||
if (exact !== undefined) {
|
||||
return { text, resolved: exact, match: 'exact', supported: true }
|
||||
}
|
||||
|
||||
const newer = compareVersions(numbers, parseVersion(NEWEST) ?? [0, 0]) > 0
|
||||
return newer
|
||||
? { text, resolved: NEWEST, match: 'newer', supported: false }
|
||||
: { text, resolved: OLDEST, match: 'older', supported: false }
|
||||
}
|
||||
|
||||
/** One sentence for the log, which is where an unsupported engine has to show up. */
|
||||
export function describeWarpEngineVersion (version: WarpEngineVersion): string {
|
||||
const supported = SUPPORTED_WARP_ENGINE_VERSIONS.join(', ')
|
||||
switch (version.match) {
|
||||
case 'exact':
|
||||
return `WarpEngine ${version.text ?? ''}`
|
||||
case 'absent':
|
||||
return 'the catalog sent no WarpEngine-Version header — reading it as ' +
|
||||
`${OLDEST}, which is what an engine older than 0.4.0 is`
|
||||
case 'older':
|
||||
return `WarpEngine ${version.text ?? ''} is older than anything this client knows ` +
|
||||
`(${supported}) — reading it as ${OLDEST}`
|
||||
case 'newer':
|
||||
return `WarpEngine ${version.text ?? ''} is newer than this client knows ` +
|
||||
`(${supported}) — reading it as ${NEWEST}, so some titles may be missed`
|
||||
}
|
||||
}
|
||||
|
||||
function parseVersion (text: string): readonly [number, number] | null {
|
||||
const match = /(\d+)\.(\d+)/.exec(text)
|
||||
if (match === null) return null
|
||||
return [Number(match[1]), Number(match[2])]
|
||||
}
|
||||
|
||||
function compareVersions (left: readonly [number, number], right: readonly [number, number]): number {
|
||||
if (left[0] !== right[0]) return left[0] - right[0]
|
||||
return left[1] - right[1]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** What the host application knows about itself: its version, locale and storage. */
|
||||
export interface ApplicationEnvironment {
|
||||
readVersion: () => string
|
||||
readSystemLocale: () => string
|
||||
resolveUserDataPath: (fileName: string) => string
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Game } from '../models/Game'
|
||||
|
||||
/** Opening things outside this application: a game, a folder, a page. */
|
||||
export interface GameLauncher {
|
||||
launchGame: (game: Game) => Promise<boolean>
|
||||
openFolder: (directory: string) => Promise<boolean>
|
||||
openUrl: (url: string) => Promise<boolean>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { InstalledStore } from '../models/InstalledStore'
|
||||
|
||||
/** The stores present on this machine, wherever the shell installer would put them. */
|
||||
export interface InstalledStoreRepository {
|
||||
findAll: () => readonly InstalledStore[]
|
||||
findByHome: (home: string) => InstalledStore | null
|
||||
/** 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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Preferences } from '../models/Preferences'
|
||||
|
||||
export interface PreferencesRepository {
|
||||
read: () => Preferences
|
||||
write: (preferences: Preferences) => void
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* The store engine, as an interface.
|
||||
*
|
||||
* Every catalog operation this client performs is one call on this port. The engine
|
||||
* behind it runs in this process, but nothing above this line knows that either —
|
||||
* the port is what let it stop being a child process without a change up here.
|
||||
*/
|
||||
export interface StoreCatalogGateway {
|
||||
listGames: (store: InstalledStore, progress?: EngineProgressListener) => Promise<CatalogListing>
|
||||
readPaths: (store: InstalledStore, progress?: EngineProgressListener) => Promise<StorePaths>
|
||||
syncGames: (store: InstalledStore, names: readonly string[], progress?: EngineProgressListener) => Promise<void>
|
||||
removeGame: (store: InstalledStore, name: string, progress?: EngineProgressListener) => Promise<void>
|
||||
/**
|
||||
* 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,17 @@
|
||||
import type { EngineProgressListener } from '../models/EngineProgress'
|
||||
import type { InstalledStore } from '../models/InstalledStore'
|
||||
import type { RegistryStore } from '../models/RegistryStore'
|
||||
|
||||
/**
|
||||
* Setting up a store where there is none.
|
||||
*
|
||||
* This is why the client exists on Windows at all: the store's own installer is
|
||||
* `curl … | sh`, which Windows does not have.
|
||||
*/
|
||||
export interface StoreEngineInstaller {
|
||||
installEngine: (
|
||||
home: string,
|
||||
store: RegistryStore,
|
||||
progress?: EngineProgressListener
|
||||
) => Promise<InstalledStore>
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { RegistryStore } from '../models/RegistryStore'
|
||||
|
||||
/** Which stores exist at all — the site's answer, not this client's. */
|
||||
export interface StoreRegistryRepository {
|
||||
readonly sourceUrl: string
|
||||
listStores: () => Promise<readonly RegistryStore[]>
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { inflateRawSync } from 'node:zlib'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* A zip reader over `node:zlib`, because Node has no zip and this client has no
|
||||
* runtime dependencies.
|
||||
*
|
||||
* Only what the store actually needs to read: the archives come from our own
|
||||
* release pipeline, so `stored` and `deflate` are the only compression methods
|
||||
* that occur and there is no encryption to support. What is *not* optional is the
|
||||
* unix mode — the archive records the executable bit and without it nothing we
|
||||
* install can start, so every entry is read from the central directory, which is
|
||||
* the only place that carries it.
|
||||
*
|
||||
* Symlinks are written as regular files holding their target path. That is also
|
||||
* what Python's `ZipFile.extractall` does, so an archive that installed before
|
||||
* installs the same way now.
|
||||
*/
|
||||
|
||||
const END_OF_CENTRAL_DIRECTORY = 0x06054b50
|
||||
const CENTRAL_FILE_HEADER = 0x02014b50
|
||||
const LOCAL_FILE_HEADER = 0x04034b50
|
||||
|
||||
const END_OF_CENTRAL_DIRECTORY_SIZE = 22
|
||||
const CENTRAL_FILE_HEADER_SIZE = 46
|
||||
const LOCAL_FILE_HEADER_SIZE = 30
|
||||
|
||||
/** A zip comment is a u16 length, so the record cannot start further back than this. */
|
||||
const MAX_COMMENT_SIZE = 0xffff
|
||||
|
||||
const STORED = 0
|
||||
const DEFLATED = 8
|
||||
|
||||
/** The u16/u32 sentinels a zip64 archive puts in the fields it has outgrown. */
|
||||
const ZIP64_U16 = 0xffff
|
||||
const ZIP64_U32 = 0xffffffff
|
||||
|
||||
const DEFAULT_FILE_MODE = 0o644
|
||||
const DEFAULT_DIRECTORY_MODE = 0o755
|
||||
const EXECUTABLE_BITS = 0o111
|
||||
|
||||
export interface ZipEntry {
|
||||
readonly fileName: string
|
||||
readonly directory: boolean
|
||||
readonly compressionMethod: number
|
||||
readonly compressedSize: number
|
||||
readonly uncompressedSize: number
|
||||
/** From the external attributes' high word; 0 when the archive carries no unix mode. */
|
||||
readonly unixMode: number
|
||||
readonly localHeaderOffset: number
|
||||
}
|
||||
|
||||
export class ZipArchive {
|
||||
private constructor (
|
||||
private readonly buffer: Buffer,
|
||||
public readonly entries: readonly ZipEntry[]
|
||||
) {}
|
||||
|
||||
public static open (archivePath: string): ZipArchive {
|
||||
const buffer = fs.readFileSync(archivePath)
|
||||
return new ZipArchive(buffer, readCentralDirectory(buffer, archivePath))
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpack everything into `destination`, restoring the executable bit.
|
||||
*
|
||||
* Every entry path is resolved and checked against the destination before it is
|
||||
* written: a zip may name `../` and this store writes into the user's own data
|
||||
* directory.
|
||||
*/
|
||||
public extractAll (destination: string): void {
|
||||
const root = path.resolve(destination)
|
||||
fs.mkdirSync(root, { recursive: true })
|
||||
|
||||
for (const entry of this.entries) {
|
||||
const target = path.resolve(root, entry.fileName)
|
||||
if (target !== root && !target.startsWith(root + path.sep)) {
|
||||
throw new Error(`${entry.fileName} would be written outside ${root}`)
|
||||
}
|
||||
if (entry.directory) {
|
||||
fs.mkdirSync(target, { recursive: true, mode: DEFAULT_DIRECTORY_MODE })
|
||||
continue
|
||||
}
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||
fs.writeFileSync(target, this.readEntry(entry), { mode: this.fileMode(entry) })
|
||||
}
|
||||
}
|
||||
|
||||
/** One entry's bytes, decompressed. */
|
||||
public readEntry (entry: ZipEntry): Buffer {
|
||||
const signature = this.buffer.readUInt32LE(entry.localHeaderOffset)
|
||||
if (signature !== LOCAL_FILE_HEADER) {
|
||||
throw new Error(`${entry.fileName}: no local header at ${String(entry.localHeaderOffset)}`)
|
||||
}
|
||||
// The local header's own sizes may be zero when a data descriptor follows, so
|
||||
// the lengths come from the central directory and only the two variable-length
|
||||
// fields are read here.
|
||||
const nameLength = this.buffer.readUInt16LE(entry.localHeaderOffset + 26)
|
||||
const extraLength = this.buffer.readUInt16LE(entry.localHeaderOffset + 28)
|
||||
const start = entry.localHeaderOffset + LOCAL_FILE_HEADER_SIZE + nameLength + extraLength
|
||||
const raw = this.buffer.subarray(start, start + entry.compressedSize)
|
||||
|
||||
if (entry.compressionMethod === STORED) return Buffer.from(raw)
|
||||
if (entry.compressionMethod === DEFLATED) return inflateRawSync(raw)
|
||||
throw new Error(`${entry.fileName}: unsupported compression method ${String(entry.compressionMethod)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* The mode to write a file with.
|
||||
*
|
||||
* A zip made on Windows carries no unix mode at all, and there the archive
|
||||
* simply cannot tell us — 0644 is the safe answer, and `find_program` looks for
|
||||
* `.exe` on that host anyway rather than for the executable bit.
|
||||
*/
|
||||
private fileMode (entry: ZipEntry): number {
|
||||
if (entry.unixMode === 0) return DEFAULT_FILE_MODE
|
||||
const permissions = entry.unixMode & 0o7777
|
||||
if (permissions === 0) return DEFAULT_FILE_MODE
|
||||
return (permissions & EXECUTABLE_BITS) === 0 ? permissions : permissions | EXECUTABLE_BITS
|
||||
}
|
||||
}
|
||||
|
||||
function readCentralDirectory (buffer: Buffer, archivePath: string): readonly ZipEntry[] {
|
||||
const end = findEndOfCentralDirectory(buffer, archivePath)
|
||||
const entryCount = buffer.readUInt16LE(end + 10)
|
||||
const directoryOffset = buffer.readUInt32LE(end + 16)
|
||||
|
||||
if (entryCount === ZIP64_U16 || directoryOffset === ZIP64_U32) {
|
||||
throw new Error(`${archivePath} is a zip64 archive, which this reader does not support`)
|
||||
}
|
||||
|
||||
const entries: ZipEntry[] = []
|
||||
let cursor = directoryOffset
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (cursor + CENTRAL_FILE_HEADER_SIZE > buffer.length) {
|
||||
throw new Error(`${archivePath}: the central directory ends mid-entry`)
|
||||
}
|
||||
if (buffer.readUInt32LE(cursor) !== CENTRAL_FILE_HEADER) {
|
||||
throw new Error(`${archivePath}: no central directory entry at ${String(cursor)}`)
|
||||
}
|
||||
const nameLength = buffer.readUInt16LE(cursor + 28)
|
||||
const extraLength = buffer.readUInt16LE(cursor + 30)
|
||||
const commentLength = buffer.readUInt16LE(cursor + 32)
|
||||
const fileName = buffer.toString('utf8', cursor + CENTRAL_FILE_HEADER_SIZE, cursor + CENTRAL_FILE_HEADER_SIZE + nameLength)
|
||||
|
||||
entries.push({
|
||||
fileName,
|
||||
directory: fileName.endsWith('/'),
|
||||
compressionMethod: buffer.readUInt16LE(cursor + 10),
|
||||
compressedSize: buffer.readUInt32LE(cursor + 20),
|
||||
uncompressedSize: buffer.readUInt32LE(cursor + 24),
|
||||
unixMode: buffer.readUInt32LE(cursor + 38) >>> 16,
|
||||
localHeaderOffset: buffer.readUInt32LE(cursor + 42)
|
||||
})
|
||||
cursor += CENTRAL_FILE_HEADER_SIZE + nameLength + extraLength + commentLength
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/** The record is at the end, behind a comment of unknown length, so scan backwards. */
|
||||
function findEndOfCentralDirectory (buffer: Buffer, archivePath: string): number {
|
||||
const earliest = Math.max(0, buffer.length - END_OF_CENTRAL_DIRECTORY_SIZE - MAX_COMMENT_SIZE)
|
||||
for (let offset = buffer.length - END_OF_CENTRAL_DIRECTORY_SIZE; offset >= earliest; offset -= 1) {
|
||||
if (buffer.readUInt32LE(offset) === END_OF_CENTRAL_DIRECTORY) return offset
|
||||
}
|
||||
throw new Error(`${archivePath} is not a zip archive`)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { asRecord, readRecord, readString } from '../json/JsonRecord'
|
||||
|
||||
/**
|
||||
* What was decided when this package was built.
|
||||
*
|
||||
* The registry address is the one thing about a particular site left in the client, and
|
||||
* a build for a different site should not need a different source tree. So it is a field
|
||||
* in `package.json`, which `electron-builder` can overwrite at packaging time:
|
||||
*
|
||||
* make dist STORES_API=https://staging.example.org/api/stores
|
||||
*
|
||||
* Read from the package.json that ships inside the app, so a packaged build answers with
|
||||
* what it was built with. A runtime `STORES_API` still wins over it — that is for trying
|
||||
* something out, this is for shipping it.
|
||||
*/
|
||||
export class BuildConfiguration {
|
||||
private cached: Readonly<Record<string, unknown>> | null = null
|
||||
|
||||
public readRegistryUrl (): string | null {
|
||||
const section = readRecord(this.read(), 'warpEngine')
|
||||
if (section === null) return null
|
||||
const url = readString(section, 'registryUrl').trim()
|
||||
return url.length > 0 ? url : null
|
||||
}
|
||||
|
||||
private read (): Readonly<Record<string, unknown>> {
|
||||
if (this.cached !== null) return this.cached
|
||||
// build/infrastructure/config → the package root, packaged or not.
|
||||
const candidates = [
|
||||
path.join(__dirname, '..', '..', '..', 'package.json'),
|
||||
path.join(__dirname, '..', '..', 'package.json')
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const parsed = asRecord(JSON.parse(fs.readFileSync(candidate, 'utf8')))
|
||||
if (parsed !== null) {
|
||||
this.cached = parsed
|
||||
return parsed
|
||||
}
|
||||
} catch {
|
||||
// Try the next one; a missing package.json is only fatal if none is found.
|
||||
}
|
||||
}
|
||||
this.cached = {}
|
||||
return this.cached
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import path from 'node:path'
|
||||
import type { App } from 'electron'
|
||||
import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment'
|
||||
|
||||
/** The host application, as the services see it. Keeps `electron` out of them. */
|
||||
export class ElectronApplicationEnvironment implements ApplicationEnvironment {
|
||||
public constructor (private readonly app: App) {}
|
||||
|
||||
public readVersion (): string {
|
||||
return this.app.getVersion()
|
||||
}
|
||||
|
||||
public readSystemLocale (): string {
|
||||
return this.app.getLocale()
|
||||
}
|
||||
|
||||
public resolveUserDataPath (fileName: string): string {
|
||||
return path.join(this.app.getPath('userData'), fileName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { Shell } from 'electron'
|
||||
import type { Game } from '../../domain/models/Game'
|
||||
import type { GameLauncher } from '../../domain/ports/GameLauncher'
|
||||
|
||||
/**
|
||||
* Launching what was installed.
|
||||
*
|
||||
* A hosted title is a URL, so it goes to the browser. A native one is whatever the
|
||||
* store recorded: on macOS the app bundle through `open`, elsewhere the executable
|
||||
* from its own directory — the same working directory the menu entry uses, because
|
||||
* games load their assets relative to it.
|
||||
*/
|
||||
export class ElectronGameLauncher implements GameLauncher {
|
||||
public constructor (private readonly shell: Shell) {}
|
||||
|
||||
public async launchGame (game: Game): Promise<boolean> {
|
||||
if (game.mode === 'web' && game.hostedUrl !== null) {
|
||||
await this.shell.openExternal(game.hostedUrl)
|
||||
return true
|
||||
}
|
||||
|
||||
const target = game.menuEntryPath ?? game.executablePath
|
||||
if (target === null || !fs.existsSync(target)) return false
|
||||
|
||||
if (process.platform === 'darwin' && target.endsWith('.app')) {
|
||||
this.spawnDetached('open', [target], path.dirname(target))
|
||||
return true
|
||||
}
|
||||
|
||||
if (process.platform === 'win32' || target.endsWith('.desktop')) {
|
||||
const failure = await this.shell.openPath(target)
|
||||
if (failure === '') return true
|
||||
}
|
||||
|
||||
const executable = game.executablePath ?? target
|
||||
this.spawnDetached(executable, [], path.dirname(executable))
|
||||
return true
|
||||
}
|
||||
|
||||
public async openFolder (directory: string): Promise<boolean> {
|
||||
if (directory.length === 0) return false
|
||||
const failure = await this.shell.openPath(directory)
|
||||
return failure === ''
|
||||
}
|
||||
|
||||
public async openUrl (url: string): Promise<boolean> {
|
||||
if (!url.startsWith('https://')) return false
|
||||
await this.shell.openExternal(url)
|
||||
return true
|
||||
}
|
||||
|
||||
private spawnDetached (command: string, commandArguments: readonly string[], cwd: string): void {
|
||||
spawn(command, [...commandArguments], { cwd, detached: true, stdio: 'ignore' }).unref()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { safeStorage } from 'electron'
|
||||
import type { CredentialRepository } from '../../domain/ports/CredentialRepository'
|
||||
import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment'
|
||||
|
||||
const FILE_NAME = 'credentials.json'
|
||||
|
||||
/**
|
||||
* Tokens in the OS keychain's own encryption, in the application's data directory.
|
||||
*
|
||||
* Not in the store home next to `config.json` and `state.json`: those two are the
|
||||
* store's public description of itself and its record of what it installed, both
|
||||
* meant to be read and both copied around when somebody moves a library. A password
|
||||
* does not belong in either.
|
||||
*
|
||||
* `safeStorage` is Electron's wrapper over the platform keychain (Keychain on macOS,
|
||||
* libsecret on Linux, DPAPI on Windows). Where it is unavailable — a Linux box with no
|
||||
* secret service — this stores nothing at all rather than falling back to plain text.
|
||||
* The cost is signing in again next run; the alternative is a readable token on disk
|
||||
* for somebody who thought it was encrypted.
|
||||
*/
|
||||
export class SafeStorageCredentialRepository implements CredentialRepository {
|
||||
public constructor (private readonly environment: ApplicationEnvironment) {}
|
||||
|
||||
public readToken (storeId: string): string | null {
|
||||
if (!this.available()) return null
|
||||
const encoded = this.readAll()[storeId]
|
||||
if (typeof encoded !== 'string') return null
|
||||
|
||||
try {
|
||||
return safeStorage.decryptString(Buffer.from(encoded, 'base64'))
|
||||
} catch {
|
||||
// A token encrypted under a keychain this machine no longer has. Signing in
|
||||
// again is the only way through, and an unreadable entry is not worth an error.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
public writeToken (storeId: string, token: string): void {
|
||||
if (!this.available()) return
|
||||
|
||||
const all = { ...this.readAll() }
|
||||
all[storeId] = safeStorage.encryptString(token).toString('base64')
|
||||
this.writeAll(all)
|
||||
}
|
||||
|
||||
public clearToken (storeId: string): void {
|
||||
const all = this.readAll()
|
||||
if (!(storeId in all)) return
|
||||
|
||||
// Rebuilt without the key rather than deleted from a copy: the linter forbids a
|
||||
// dynamic delete, and this says the same thing without pretending the object was
|
||||
// ever mutable.
|
||||
const remaining = Object.fromEntries(
|
||||
Object.entries(all).filter(([key]: readonly [string, unknown]): boolean => key !== storeId)
|
||||
)
|
||||
this.writeAll(remaining)
|
||||
}
|
||||
|
||||
public available (): boolean {
|
||||
try {
|
||||
return safeStorage.isEncryptionAvailable()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private readAll (): Record<string, unknown> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(fs.readFileSync(this.filePath(), 'utf8'))
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
private writeAll (all: Record<string, unknown>): void {
|
||||
try {
|
||||
const target = this.filePath()
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||
// 0600 as well as the encryption: defence in depth costs one argument here, and
|
||||
// the file is only ever read by this application.
|
||||
fs.writeFileSync(target, `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 })
|
||||
} catch {
|
||||
// A token that could not be saved means signing in again next run, which is not
|
||||
// worth stopping the application for.
|
||||
}
|
||||
}
|
||||
|
||||
private filePath (): string {
|
||||
return this.environment.resolveUserDataPath(FILE_NAME)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { StoreConfiguration } from '../../domain/models/StoreConfiguration'
|
||||
import {
|
||||
WARP_ENGINE_VERSION_HEADER, describeWarpEngineVersion, readWarpEngineVersion,
|
||||
type WarpEngineVersion
|
||||
} from '../../domain/models/WarpEngineVersion'
|
||||
import { describe, type StoreFileSystem } from '../files/StoreFileSystem'
|
||||
import { StoreHttpClient } from '../http/StoreHttpClient'
|
||||
import { asRecord } from '../json/JsonRecord'
|
||||
|
||||
const CLIENT_VERSION = '2.0.0'
|
||||
|
||||
const IMAGE_EXTENSIONS: Readonly<Record<string, string>> = {
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/webp': '.webp',
|
||||
'image/gif': '.gif'
|
||||
}
|
||||
|
||||
export interface DownloadedImage {
|
||||
readonly body: Buffer
|
||||
readonly extension: string
|
||||
}
|
||||
|
||||
/** A catalog, and which engine served it. */
|
||||
export interface FetchedCatalog {
|
||||
readonly catalog: unknown
|
||||
readonly engineVersion: WarpEngineVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog API, as this store talks to it.
|
||||
*
|
||||
* Every URL the store fetches is built here, and the catalog is cached next to the
|
||||
* config so a sync survives an outage — the machine that lost its network still has
|
||||
* a library, and being told what is installed matters more than being current.
|
||||
*/
|
||||
export class CatalogClient {
|
||||
private readonly http: StoreHttpClient
|
||||
|
||||
public constructor (
|
||||
private readonly configuration: StoreConfiguration,
|
||||
private readonly files: StoreFileSystem,
|
||||
private readonly cachePath: string,
|
||||
private readonly log: (line: string) => void,
|
||||
/**
|
||||
* The bearer token to send, asked for per request rather than held.
|
||||
*
|
||||
* Every call this client makes goes to the catalog's own host, so the credential
|
||||
* belongs on all of them: the catalog needs it to say what this person owns, and
|
||||
* the download needs it to be allowed at all.
|
||||
*/
|
||||
bearerToken: () => string | null = (): null => null
|
||||
) {
|
||||
this.http = new StoreHttpClient({
|
||||
userAgent: `warp-engine-client/${CLIENT_VERSION} (${configuration.store.id})`,
|
||||
timeout: configuration.behavior.timeout,
|
||||
insecure: configuration.behavior.insecure,
|
||||
bearerToken
|
||||
})
|
||||
}
|
||||
|
||||
/** The same HTTP client, for the service descriptor and the sign-in flow. */
|
||||
public httpClient (): StoreHttpClient {
|
||||
return this.http
|
||||
}
|
||||
|
||||
public apiUrl (endpoint: 'catalog' | 'download', parameters?: Readonly<Record<string, string>>): string {
|
||||
const { baseUrl, api } = this.configuration.store
|
||||
const url = `${baseUrl}/${api[endpoint].replace(/^\/+/, '')}`
|
||||
if (parameters === undefined) return url
|
||||
return `${url}?${new URLSearchParams(parameters).toString()}`
|
||||
}
|
||||
|
||||
/** `GET /api/download` rather than `/file/`, so downloads are counted. */
|
||||
public downloadUrl (asset: string): string {
|
||||
return this.apiUrl('download', { path: asset })
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog, and the version of the engine that served it.
|
||||
*
|
||||
* The version comes from the response header, so it costs no extra request. A cache
|
||||
* hit has no header — the cache file holds the catalog exactly as the engine sent it,
|
||||
* which is the format the shell engine wrote and worth keeping — and then the version
|
||||
* is reported as absent, which resolves to the oldest dialect this client knows.
|
||||
*/
|
||||
public async fetchCatalog (): Promise<FetchedCatalog> {
|
||||
const ownerId = this.configuration.catalog.ownerId
|
||||
const url = this.apiUrl('catalog', ownerId === null ? undefined : { owner_id: String(ownerId) })
|
||||
try {
|
||||
const { body, headers } = await this.http.readBytes(url)
|
||||
const catalog = asRecord(JSON.parse(body.toString('utf8')))
|
||||
if (catalog === null) throw new Error('the catalog is not a JSON object')
|
||||
this.files.writeJson(this.cachePath, catalog)
|
||||
|
||||
const engineVersion = readWarpEngineVersion(headers[WARP_ENGINE_VERSION_HEADER] ?? null)
|
||||
const sentence = describeWarpEngineVersion(engineVersion)
|
||||
if (engineVersion.supported) this.log(sentence)
|
||||
else this.log(`warning: ${sentence}`)
|
||||
return { catalog, engineVersion }
|
||||
} catch (error: unknown) {
|
||||
const cached = asRecord(this.files.readJson(this.cachePath))
|
||||
if (cached === null) throw new Error(`cannot fetch the catalog from ${url}: ${describe(error)}`)
|
||||
this.log(`warning: catalog fetch failed (${describe(error)}) — using the cached copy`)
|
||||
return { catalog: cached, engineVersion: readWarpEngineVersion(null) }
|
||||
}
|
||||
}
|
||||
|
||||
public async downloadAsset (asset: string, destination: string): Promise<number> {
|
||||
return await this.http.download(this.downloadUrl(asset), destination)
|
||||
}
|
||||
|
||||
/**
|
||||
* Box art, or null if it cannot be had.
|
||||
*
|
||||
* Missing box art is not a reason to fail an install: the menu entry gets the
|
||||
* generic icon and the game still starts.
|
||||
*/
|
||||
public async downloadImage (imageUrl: string, name: string): Promise<DownloadedImage | null> {
|
||||
try {
|
||||
const { body, contentType } = await this.http.readBytes(this.absoluteImageUrl(imageUrl))
|
||||
const mediaType = contentType.split(';')[0]?.trim().toLowerCase() ?? ''
|
||||
return { body, extension: IMAGE_EXTENSIONS[mediaType] ?? '.png' }
|
||||
} catch (error: unknown) {
|
||||
this.log(`warning: box art for ${name} failed: ${describe(error)}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** The catalog gives a server-relative `imageUrl`; absolute ones pass through. */
|
||||
public absoluteImageUrl (imageUrl: string): string {
|
||||
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) return imageUrl
|
||||
return this.configuration.store.baseUrl + imageUrl
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describeHost, resolveForHost, type HostMachine } from '../../domain/models/HostMachine'
|
||||
import type { UnavailableReason } from '../../domain/models/Game'
|
||||
import { gameKey } from '../../domain/models/InstalledRecord'
|
||||
import type {
|
||||
CatalogSurvey, SelectedGame, UnavailableEntry
|
||||
} from '../../domain/models/SelectedGame'
|
||||
import type { AssetSpecification, StoreConfiguration } from '../../domain/models/StoreConfiguration'
|
||||
import type { CatalogEntry, CatalogSoftware } from './dialects/CatalogDialect'
|
||||
import { pickRelease } from './ReleasePicker'
|
||||
|
||||
/**
|
||||
* The catalog, turned into what this machine can and cannot install.
|
||||
*
|
||||
* Two decisions layered on each other. The inner one asks, per install mode, "which
|
||||
* titles have *this* asset for this host"; the outer one asks it once per mode and
|
||||
* merges the answers in the config's mode order, so a title with no native build for
|
||||
* this machine is still installable as a hosted page. That fallback is what makes a
|
||||
* desktop store the only one that can carry the whole catalog.
|
||||
*
|
||||
* A title counts as unavailable only when every mode failed it, and then the *first*
|
||||
* mode's verdict is the one kept: `app` comes first, so a person is told "no native
|
||||
* build for this machine" rather than the web mode's complaint about the same title.
|
||||
*
|
||||
* The entries arrive already typed, from whichever `CatalogDialect` the serving engine
|
||||
* version selected — nothing here knows what the catalog's JSON looks like.
|
||||
*/
|
||||
export class CatalogSurveyor {
|
||||
public constructor (
|
||||
private readonly configuration: StoreConfiguration,
|
||||
private readonly log: (line: string) => void
|
||||
) {}
|
||||
|
||||
public survey (entries: readonly CatalogEntry[], host: HostMachine): CatalogSurvey {
|
||||
const chosen = new Map<string, SelectedGame>()
|
||||
const unmet = new Map<string, UnavailableEntry>()
|
||||
const reasonsByName = new Map<string, string[]>()
|
||||
|
||||
for (const mode of this.configuration.install.modes) {
|
||||
const specification = this.configuration.install.specifications[mode]
|
||||
if (specification === undefined) {
|
||||
this.log(`warning: install mode '${mode}' has no asset spec — ignoring it`)
|
||||
continue
|
||||
}
|
||||
const pass = this.surveyMode(entries, host, mode, specification)
|
||||
|
||||
for (const game of pass.games) {
|
||||
const key = gameKey(game)
|
||||
if (!chosen.has(key)) chosen.set(key, game)
|
||||
}
|
||||
for (const [name, reason] of pass.reasons) {
|
||||
const collected = reasonsByName.get(name) ?? []
|
||||
collected.push(`${mode}: ${reason}`)
|
||||
reasonsByName.set(name, collected)
|
||||
}
|
||||
for (const entry of pass.unavailable) {
|
||||
if (!unmet.has(entry.name)) unmet.set(entry.name, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// A title that some later mode could serve is not skipped at all: nobody needs to
|
||||
// hear that the native build was missing when the game installed anyway.
|
||||
const installedNames = new Set([...chosen.values()].map((game: SelectedGame): string => game.name))
|
||||
const skipped = [...reasonsByName.entries()]
|
||||
.filter(([name]: readonly [string, readonly string[]]): boolean => !installedNames.has(name))
|
||||
.map(([name, reasons]: readonly [string, readonly string[]]): string =>
|
||||
`${name}: ${reasons.join('; ')}`)
|
||||
|
||||
return {
|
||||
games: [...chosen.values()],
|
||||
skipped,
|
||||
unavailable: [...unmet.values()]
|
||||
.filter((entry: UnavailableEntry): boolean => !installedNames.has(entry.name))
|
||||
}
|
||||
}
|
||||
|
||||
/** One pass over the catalog, asking for one mode's asset. */
|
||||
private surveyMode (
|
||||
entries: readonly CatalogEntry[],
|
||||
host: HostMachine,
|
||||
mode: string,
|
||||
specification: AssetSpecification
|
||||
): ModeSurvey {
|
||||
const { statuses, only, exclude } = this.filters()
|
||||
const games: SelectedGame[] = []
|
||||
const reasons: [string, string][] = []
|
||||
const unavailable: UnavailableEntry[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const software = entry.software
|
||||
const name = software.name
|
||||
|
||||
// Editorial filters: a title excluded here is in none of the three lists, because
|
||||
// that is the store's choice rather than a limit of the machine.
|
||||
if (statuses.size > 0 && !statuses.has(software.status.toLowerCase())) continue
|
||||
if (only.size > 0 && !only.has(name.toLowerCase())) continue
|
||||
if (exclude.has(name.toLowerCase())) continue
|
||||
|
||||
const platform = this.configuration.platforms[software.platform]
|
||||
if (platform?.enabled !== true) {
|
||||
// Reported rather than hidden: to somebody looking at a catalog, a platform
|
||||
// switched off reads as "not supported here".
|
||||
unavailable.push(toUnavailable(entry, 'platformOff',
|
||||
`${software.platform} is not carried by this store`))
|
||||
continue
|
||||
}
|
||||
|
||||
const wanted = readKinds(resolveForHost(specification.kind, host))
|
||||
if (wanted.length === 0) {
|
||||
const reason = `${software.platform} has no asset kind for ${describeHost(host)}`
|
||||
reasons.push([name, reason])
|
||||
unavailable.push(toUnavailable(entry, 'hostAsset', reason))
|
||||
continue
|
||||
}
|
||||
const extension = resolveForHost(specification.extension, host) ?? ''
|
||||
|
||||
const release = pickRelease(entry.releaseCandidates, wanted, extension)
|
||||
if (release === null) {
|
||||
const reason = `no '${wanted.join('/')}' asset in any release`
|
||||
reasons.push([name, reason])
|
||||
unavailable.push(toUnavailable(entry, 'noAsset', reason))
|
||||
continue
|
||||
}
|
||||
|
||||
games.push({
|
||||
name,
|
||||
scope: software.platform,
|
||||
platform: software.platform,
|
||||
kind: release.kind,
|
||||
version: release.version,
|
||||
asset: release.assetName,
|
||||
assetPath: release.assetPath,
|
||||
title: software.title,
|
||||
description: software.description,
|
||||
author: software.author,
|
||||
imageUrl: software.imageUrl,
|
||||
createdAt: release.createdAt,
|
||||
mode,
|
||||
access: entry.access
|
||||
})
|
||||
}
|
||||
return { games, reasons, unavailable }
|
||||
}
|
||||
|
||||
private filters (): CatalogFilters {
|
||||
const lower = (values: readonly string[]): ReadonlySet<string> =>
|
||||
new Set(values.map((value: string): string => value.toLowerCase()))
|
||||
return {
|
||||
statuses: lower(this.configuration.catalog.statuses),
|
||||
only: lower(this.configuration.catalog.only),
|
||||
exclude: lower(this.configuration.catalog.exclude)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CatalogFilters {
|
||||
readonly statuses: ReadonlySet<string>
|
||||
readonly only: ReadonlySet<string>
|
||||
readonly exclude: ReadonlySet<string>
|
||||
}
|
||||
|
||||
interface ModeSurvey {
|
||||
readonly games: readonly SelectedGame[]
|
||||
/** `[name, reason]`, kept per name so several modes' complaints can be joined. */
|
||||
readonly reasons: readonly (readonly [string, string])[]
|
||||
readonly unavailable: readonly UnavailableEntry[]
|
||||
}
|
||||
|
||||
/** One unavailable record, with enough for a client to draw a card. */
|
||||
function toUnavailable (
|
||||
entry: CatalogEntry,
|
||||
reason: UnavailableReason,
|
||||
detail: string
|
||||
): UnavailableEntry {
|
||||
const software: CatalogSoftware = entry.software
|
||||
return {
|
||||
access: entry.access,
|
||||
name: software.name,
|
||||
title: software.title,
|
||||
platform: software.platform,
|
||||
description: software.description,
|
||||
author: software.author,
|
||||
imageUrl: software.imageUrl,
|
||||
version: entry.latestRelease?.version ?? '',
|
||||
reason,
|
||||
detail
|
||||
}
|
||||
}
|
||||
|
||||
function readKinds (value: string | readonly string[] | null): readonly string[] {
|
||||
if (value === null) return []
|
||||
const kinds = typeof value === 'string' ? [value] : value
|
||||
return kinds.filter((kind: string): boolean => kind.length > 0)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import path from 'node:path'
|
||||
import {
|
||||
OPERATING_SYSTEM_PATHS, toSafeFileName, type DesktopLayout
|
||||
} from '../../domain/models/DesktopLayout'
|
||||
import type { InstalledRecord } from '../../domain/models/InstalledRecord'
|
||||
import type { SelectedGame } from '../../domain/models/SelectedGame'
|
||||
import type { StoreConfiguration } from '../../domain/models/StoreConfiguration'
|
||||
import { expandHome, expandPathSpecification } from '../files/StoreFileSystem'
|
||||
import type { HostMachineDetector } from './HostMachineDetector'
|
||||
|
||||
/**
|
||||
* Where this store writes on this machine.
|
||||
*
|
||||
* The XDG and Windows environment variables are the correct answer when they are
|
||||
* set, and a hardcoded path is only the fallback: a machine that moved its data
|
||||
* directory should still get its own menu. A value pinned in the config beats both.
|
||||
*/
|
||||
export class DesktopLayoutResolver {
|
||||
public constructor (
|
||||
private readonly configuration: StoreConfiguration,
|
||||
private readonly hosts: HostMachineDetector
|
||||
) {}
|
||||
|
||||
public resolveLayout (): DesktopLayout {
|
||||
const operatingSystem = this.hosts.findHost().operatingSystem
|
||||
const defaults = OPERATING_SYSTEM_PATHS[operatingSystem]
|
||||
if (defaults === undefined) {
|
||||
throw new Error(
|
||||
`no desktop layout is known for '${operatingSystem}' — set paths.install_root and paths.menu_dir`)
|
||||
}
|
||||
const sources: Record<string, string> = { os: operatingSystem }
|
||||
|
||||
const pick = (key: 'installRoot' | 'menuDirectory' | 'iconDirectory'): string | null => {
|
||||
const configured = this.configuration.paths[key]
|
||||
if (configured !== null && configured.length > 0) {
|
||||
sources[key] = 'config'
|
||||
return path.resolve(expandHome(configured))
|
||||
}
|
||||
sources[key] = 'default'
|
||||
return expandPathSpecification(defaults[key] ?? null)
|
||||
}
|
||||
|
||||
const installRoot = pick('installRoot')
|
||||
const menuDirectory = pick('menuDirectory')
|
||||
const iconDirectory = pick('iconDirectory')
|
||||
if (installRoot === null || menuDirectory === null) {
|
||||
throw new Error(`cannot resolve the install root or menu directory for '${operatingSystem}'`)
|
||||
}
|
||||
return { operatingSystem, installRoot, menuDirectory, iconDirectory, sources }
|
||||
}
|
||||
|
||||
/** The only payload subtree this store may delete from. */
|
||||
public ownedRoot (layout: DesktopLayout): string {
|
||||
return path.join(layout.installRoot, this.configuration.paths.subfolder)
|
||||
}
|
||||
|
||||
public payloadDirectory (layout: DesktopLayout, game: SelectedGame | InstalledRecord): string {
|
||||
return path.join(this.ownedRoot(layout), game.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Box art path.
|
||||
*
|
||||
* Kept inside our own folder rather than the shared icon theme, so an uninstall
|
||||
* never has to reach into it.
|
||||
*/
|
||||
public iconPath (layout: DesktopLayout, game: SelectedGame | InstalledRecord): string {
|
||||
return path.join(this.ownedRoot(layout), 'icons', `${game.name}.png`)
|
||||
}
|
||||
|
||||
/** Windows puts programs in a Start-menu folder; the others do not. */
|
||||
public menuGroup (layout: DesktopLayout): string {
|
||||
if (layout.operatingSystem === 'windows') {
|
||||
return path.join(layout.menuDirectory, toSafeFileName(this.configuration.store.name))
|
||||
}
|
||||
return layout.menuDirectory
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { DeviceAuthDescriptor } from '../../domain/models/ServiceDescriptor'
|
||||
import type { StoreHttpClient } from '../http/StoreHttpClient'
|
||||
import { asRecord, readNumber, readOptionalString, readString } from '../json/JsonRecord'
|
||||
|
||||
/** What the server said when asked for a code pair. */
|
||||
export interface DeviceCodeRequest {
|
||||
readonly deviceCode: string
|
||||
/** Short enough to read off this screen and type into a browser. */
|
||||
readonly userCode: string
|
||||
readonly verificationUrl: string
|
||||
readonly intervalSeconds: number
|
||||
readonly expiresInSeconds: number
|
||||
}
|
||||
|
||||
export type DeviceSignInState = 'pending' | 'approved' | 'denied' | 'expired'
|
||||
|
||||
export interface DevicePollResult {
|
||||
readonly state: DeviceSignInState
|
||||
/** Present exactly once: on the poll that finds the grant newly approved. */
|
||||
readonly token: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The device authorization grant, client side.
|
||||
*
|
||||
* The client has no browser of its own, so it cannot host a login form without asking
|
||||
* somebody to type a password into a window that is not one. Instead it asks for a pair
|
||||
* of codes, shows the short one, sends the person to the server's own page, and polls
|
||||
* with the long one until it is answered.
|
||||
*
|
||||
* Every address comes from the service descriptor rather than from here. That is the
|
||||
* point: this class knows the *shape* of the flow, which is the engine's, and nothing
|
||||
* about any particular store's addresses.
|
||||
*/
|
||||
export class DeviceSignInClient {
|
||||
public constructor (
|
||||
private readonly http: StoreHttpClient,
|
||||
private readonly device: DeviceAuthDescriptor
|
||||
) {}
|
||||
|
||||
public async requestCode (clientName: string): Promise<DeviceCodeRequest> {
|
||||
const { json } = await this.http.requestJson(this.device.authorizeUrl, {
|
||||
method: 'POST',
|
||||
payload: { client_name: clientName }
|
||||
})
|
||||
const record = asRecord(json)
|
||||
if (record === null) throw new Error('the server did not answer with a device code')
|
||||
|
||||
const deviceCode = readOptionalString(record, 'deviceCode')
|
||||
const userCode = readOptionalString(record, 'userCode')
|
||||
if (deviceCode === null || userCode === null) {
|
||||
throw new Error('the server did not answer with a device code')
|
||||
}
|
||||
|
||||
return {
|
||||
deviceCode,
|
||||
userCode,
|
||||
verificationUrl: readOptionalString(record, 'verificationUrl') ?? this.device.verificationUrl,
|
||||
// The server's own pacing wins over the descriptor's: it knows what it can take.
|
||||
intervalSeconds: Math.max(1, readNumber(record, 'interval', this.device.interval)),
|
||||
expiresInSeconds: Math.max(1, readNumber(record, 'expiresIn', 600))
|
||||
}
|
||||
}
|
||||
|
||||
public async poll (deviceCode: string): Promise<DevicePollResult> {
|
||||
// 404 is a real answer here — the grant was swept or never existed — so it is read
|
||||
// rather than thrown, and reported as expired: from the client's side those are the
|
||||
// same situation, and both mean start again.
|
||||
const { json, statusCode } = await this.http.requestJson(this.device.tokenUrl, {
|
||||
method: 'POST',
|
||||
payload: { device_code: deviceCode },
|
||||
accept: [ 404, 410 ]
|
||||
})
|
||||
if (statusCode !== 200) return { state: 'expired', token: null }
|
||||
|
||||
const record = asRecord(json)
|
||||
if (record === null) return { state: 'pending', token: null }
|
||||
|
||||
return {
|
||||
state: toState(readString(record, 'state')),
|
||||
token: readOptionalString(record, 'token')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signing out: the token this client carries is revoked at the server.
|
||||
*
|
||||
* There is no token argument because there is nowhere to put one — the credential
|
||||
* rides on the request as a bearer header, from the same supplier every other call
|
||||
* uses. Best effort on purpose: the token is thrown away locally either way, and a
|
||||
* server that cannot be reached must not leave somebody stuck signed in.
|
||||
*/
|
||||
public async revoke (): Promise<boolean> {
|
||||
if (this.device.revokeUrl === null) return false
|
||||
|
||||
try {
|
||||
const { statusCode } = await this.http.requestJson(this.device.revokeUrl, {
|
||||
method: 'DELETE',
|
||||
accept: [ 204, 401 ]
|
||||
})
|
||||
return statusCode === 204
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toState (value: string): DeviceSignInState {
|
||||
switch (value) {
|
||||
case 'approved':
|
||||
case 'denied':
|
||||
case 'expired':
|
||||
return value
|
||||
default:
|
||||
return 'pending'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { DesktopLayout } from '../../domain/models/DesktopLayout'
|
||||
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
|
||||
import { gameKey, type InstalledRecord } from '../../domain/models/InstalledRecord'
|
||||
import type { SelectedGame } from '../../domain/models/SelectedGame'
|
||||
import { APP_MODE, type StoreConfiguration } from '../../domain/models/StoreConfiguration'
|
||||
import { describe, type StoreFileSystem } from '../files/StoreFileSystem'
|
||||
import type { CatalogClient } from './CatalogClient'
|
||||
import type { DesktopLayoutResolver } from './DesktopLayoutResolver'
|
||||
import type { LauncherWriter } from './launchers/LauncherWriter'
|
||||
import type { PayloadInstaller } from './PayloadInstaller'
|
||||
|
||||
/**
|
||||
* Installing and uninstalling one title, and the sweep over all of them.
|
||||
*
|
||||
* The rule the whole class turns on: an install writes three things — a payload, an
|
||||
* icon and a menu entry — and the record of what was written is what an uninstall
|
||||
* reads. Nothing is ever deleted by reconstructing a path from a name, because a
|
||||
* path built from a name is a guess and this runs inside the user's home directory.
|
||||
*/
|
||||
export class GameInstaller {
|
||||
public constructor (
|
||||
private readonly configuration: StoreConfiguration,
|
||||
private readonly layouts: DesktopLayoutResolver,
|
||||
private readonly payloads: PayloadInstaller,
|
||||
private readonly launchers: LauncherWriter,
|
||||
private readonly catalog: CatalogClient,
|
||||
private readonly files: StoreFileSystem,
|
||||
private readonly log: (line: string) => void
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Install every game in `games`, updating `installed` in place.
|
||||
*
|
||||
* Returns whether anything changed, which is what decides between "done" and
|
||||
* "already up to date" — a sync that rewrites nothing should say so.
|
||||
*/
|
||||
public async installAll (
|
||||
layout: DesktopLayout,
|
||||
games: readonly SelectedGame[],
|
||||
installed: Map<string, InstalledRecord>,
|
||||
prune: boolean,
|
||||
progress: EngineProgressListener
|
||||
): Promise<boolean> {
|
||||
let changed = false
|
||||
let failed = 0
|
||||
progress.onEvent?.({ event: 'plan', count: games.length })
|
||||
|
||||
for (const game of games) {
|
||||
const key = gameKey(game)
|
||||
let previous = installed.get(key) ?? null
|
||||
|
||||
// A different asset or a different mode is not an update in place: the old
|
||||
// payload and the old kind of menu entry both have to go first.
|
||||
if (previous !== null && (previous.asset !== game.asset || previous.mode !== game.mode)) {
|
||||
this.log(`${game.name}: ${previous.version} (${previous.mode}) -> ` +
|
||||
`${game.version} (${game.mode}), removing the old install`)
|
||||
this.removeGame(layout, previous)
|
||||
previous = null
|
||||
changed = true
|
||||
}
|
||||
|
||||
progress.onEvent?.({ event: 'begin', name: game.name, title: game.title })
|
||||
try {
|
||||
const result = await this.installGame(layout, game, previous)
|
||||
progress.onEvent?.({
|
||||
event: 'installed', name: game.name, title: game.title, changed: result.changed
|
||||
})
|
||||
if (result.changed || !sameRecord(installed.get(key) ?? null, result.record)) changed = true
|
||||
installed.set(key, result.record)
|
||||
} catch (error: unknown) {
|
||||
failed += 1
|
||||
const reason = describe(error)
|
||||
this.log(`warning: ${game.name} failed: ${reason}`)
|
||||
progress.onEvent?.({ event: 'failed', name: game.name, error: reason })
|
||||
}
|
||||
}
|
||||
|
||||
if (prune) {
|
||||
const keep = new Set(games.map((game: SelectedGame): string => gameKey(game)))
|
||||
for (const [key, record] of [...installed]) {
|
||||
if (keep.has(key)) continue
|
||||
this.log(`pruning ${key} (no longer in the catalog or filtered out)`)
|
||||
this.removeGame(layout, record)
|
||||
progress.onEvent?.({ event: 'pruned', name: record.name })
|
||||
installed.delete(key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
progress.onEvent?.({ event: 'finished', installed: installed.size, failed })
|
||||
return changed
|
||||
}
|
||||
|
||||
/** Install one title in its chosen mode. */
|
||||
public async installGame (
|
||||
layout: DesktopLayout,
|
||||
game: SelectedGame,
|
||||
previous: InstalledRecord | null
|
||||
): Promise<{ readonly record: InstalledRecord; readonly changed: boolean }> {
|
||||
const payloadDirectory = this.layouts.payloadDirectory(layout, game)
|
||||
let changed = false
|
||||
let payload: string | null = null
|
||||
let executable: string | null = null
|
||||
let executableKind: string | null = null
|
||||
let url: string | null = null
|
||||
|
||||
if (game.mode === APP_MODE) {
|
||||
if (previous !== null && isStillInstalled(previous, game, payloadDirectory)) {
|
||||
executable = previous.executable
|
||||
executableKind = previous.executableKind
|
||||
} else {
|
||||
const size = await this.payloads.unpack(game, payloadDirectory)
|
||||
changed = true
|
||||
const program = this.payloads.findProgram(payloadDirectory, game.name, layout.operatingSystem)
|
||||
if (program === null) {
|
||||
fs.rmSync(payloadDirectory, { recursive: true, force: true })
|
||||
throw new Error(`no program was found inside ${game.asset}`)
|
||||
}
|
||||
executable = program.executablePath
|
||||
executableKind = program.kind
|
||||
this.log(`installed ${game.name} ${game.version} (${String(size)} bytes) -> ${payloadDirectory}`)
|
||||
}
|
||||
payload = payloadDirectory
|
||||
} else {
|
||||
// Nothing to unpack: the browser build is hosted, so the entry is a link.
|
||||
url = this.launchers.webUrl(game)
|
||||
if (previous?.url !== url) {
|
||||
changed = true
|
||||
this.log(`added ${game.name} ${game.version} as a web entry -> ${url}`)
|
||||
}
|
||||
}
|
||||
|
||||
const withoutMenu: InstalledRecord = {
|
||||
...game,
|
||||
payload,
|
||||
executable,
|
||||
executableKind,
|
||||
icon: await this.installIcon(layout, game),
|
||||
menuEntry: null,
|
||||
url
|
||||
}
|
||||
const menuEntry = this.launchers.writeLauncher(layout, withoutMenu)
|
||||
if ((previous?.menuEntry ?? null) !== menuEntry) changed = true
|
||||
|
||||
return { record: { ...withoutMenu, menuEntry }, changed }
|
||||
}
|
||||
|
||||
/** Delete one title's payload, icon and menu entry — and nothing else. */
|
||||
public removeGame (layout: DesktopLayout, record: InstalledRecord): void {
|
||||
const owned = this.layouts.ownedRoot(layout)
|
||||
for (const target of [record.payload, record.icon]) {
|
||||
if (target !== null) this.files.removeWithin(target, owned)
|
||||
}
|
||||
if (record.menuEntry !== null) {
|
||||
this.files.removeWithin(record.menuEntry, layout.menuDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove everything this store installed, folders included. */
|
||||
public purge (layout: DesktopLayout, installed: Map<string, InstalledRecord>): void {
|
||||
for (const [key, record] of [...installed]) {
|
||||
this.removeGame(layout, record)
|
||||
installed.delete(key)
|
||||
}
|
||||
const owned = this.layouts.ownedRoot(layout)
|
||||
this.files.pruneEmptyDirectories([path.join(owned, 'icons'), owned], layout.installRoot)
|
||||
if (layout.operatingSystem === 'windows') {
|
||||
this.files.pruneEmptyDirectories([this.layouts.menuGroup(layout)], layout.menuDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
private async installIcon (layout: DesktopLayout, game: SelectedGame): Promise<string | null> {
|
||||
if (game.imageUrl === null) return null
|
||||
const iconPath = this.layouts.iconPath(layout, game)
|
||||
if (hasContent(iconPath)) return iconPath
|
||||
|
||||
const image = await this.catalog.downloadImage(game.imageUrl, game.name)
|
||||
if (image === null) return null
|
||||
// The name says .png because that is what a desktop entry and `sips` expect; a
|
||||
// non-PNG cover still displays on Linux, which sniffs the content.
|
||||
this.files.writeAtomic(iconPath, image.body)
|
||||
return iconPath
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the previous install is still there and still the right one.
|
||||
*
|
||||
* All four conditions matter: the same asset, a payload directory that exists, a
|
||||
* recorded executable, and that executable still on disk. A user who deleted the
|
||||
* folder by hand should get a reinstall rather than a menu entry that does nothing.
|
||||
*/
|
||||
function isStillInstalled (
|
||||
previous: InstalledRecord,
|
||||
game: SelectedGame,
|
||||
payloadDirectory: string
|
||||
): boolean {
|
||||
return previous.asset === game.asset &&
|
||||
fs.existsSync(payloadDirectory) &&
|
||||
previous.executable !== null &&
|
||||
fs.existsSync(previous.executable)
|
||||
}
|
||||
|
||||
function hasContent (filePath: string): boolean {
|
||||
try {
|
||||
return fs.statSync(filePath).size > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function sameRecord (left: InstalledRecord | null, right: InstalledRecord): boolean {
|
||||
return left !== null && JSON.stringify(left) === JSON.stringify(right)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { HostMachine } from '../../domain/models/HostMachine'
|
||||
|
||||
/**
|
||||
* This machine, as the release picker needs to know it.
|
||||
*
|
||||
* Node already normalises what `uname -m` reports, but not to the names the catalog
|
||||
* uses, and the catalog's names are the ones the asset kinds are keyed by.
|
||||
*/
|
||||
export class HostMachineDetector {
|
||||
private cached: HostMachine | null = null
|
||||
|
||||
public findHost (): HostMachine {
|
||||
this.cached ??= {
|
||||
operatingSystem: readOperatingSystem(),
|
||||
architecture: readArchitecture()
|
||||
}
|
||||
return this.cached
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Node's names for the architectures the catalog has a name for.
|
||||
*
|
||||
* Anything not in the table passes through unchanged: it will match no asset kind,
|
||||
* and the survey then reports the titles as unavailable on this machine — which is
|
||||
* the honest answer for a host nobody has built for.
|
||||
*/
|
||||
const ARCHITECTURE_NAMES: Readonly<Record<string, string>> = {
|
||||
x64: 'x86_64',
|
||||
arm64: 'aarch64',
|
||||
arm: 'armhf',
|
||||
ia32: 'x86'
|
||||
}
|
||||
|
||||
const OPERATING_SYSTEM_NAMES: Readonly<Record<string, string>> = {
|
||||
darwin: 'darwin',
|
||||
win32: 'windows',
|
||||
linux: 'linux'
|
||||
}
|
||||
|
||||
function readArchitecture (): string {
|
||||
return ARCHITECTURE_NAMES[process.arch] ?? process.arch
|
||||
}
|
||||
|
||||
function readOperatingSystem (): string {
|
||||
return OPERATING_SYSTEM_NAMES[process.platform] ?? process.platform
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import path from 'node:path'
|
||||
import type { CatalogListing } from '../../domain/models/CatalogListing'
|
||||
import type { DesktopLayout } from '../../domain/models/DesktopLayout'
|
||||
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
|
||||
import type { Game, GameMode } from '../../domain/models/Game'
|
||||
import {
|
||||
gameKey, limitToNames, matchStateKeys, type InstalledRecord
|
||||
} from '../../domain/models/InstalledRecord'
|
||||
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
||||
import type {
|
||||
CatalogSurvey, SelectedGame, UnavailableEntry
|
||||
} from '../../domain/models/SelectedGame'
|
||||
import type { ServiceDescriptor } from '../../domain/models/ServiceDescriptor'
|
||||
import type { SignInPrompt, StoreAccount } from '../../domain/models/StoreAccount'
|
||||
import { APP_MODE, WEB_MODE, type StoreConfiguration } from '../../domain/models/StoreConfiguration'
|
||||
import type { StorePaths } from '../../domain/models/StorePaths'
|
||||
import type { CredentialRepository } from '../../domain/ports/CredentialRepository'
|
||||
import type { 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'
|
||||
|
||||
const STATE_FILE_NAME = 'state.json'
|
||||
const CATALOG_CACHE_FILE_NAME = 'catalog.json'
|
||||
|
||||
/**
|
||||
* The store engine, in process.
|
||||
*
|
||||
* This is the whole of what used to be `desktop_store.py` and `warpstore.py` driven
|
||||
* as a child process: the catalog, the release choice, the host match, the unpacking,
|
||||
* the menu entry and the state. Nothing is serialised to JSON lines and parsed back,
|
||||
* so the progress events below are the typed events the window already expects, and
|
||||
* a `Game` is built rather than read out of somebody else's field names.
|
||||
*
|
||||
* What has *not* changed is what lands on disk. `config.json` and `state.json` keep
|
||||
* the shell engine's snake_case shape, so a machine whose library was installed by
|
||||
* the CLI keeps it.
|
||||
*/
|
||||
export class NativeStoreCatalogGateway implements StoreCatalogGateway {
|
||||
private readonly hosts = new HostMachineDetector()
|
||||
|
||||
/**
|
||||
* The credentials are injected because they are the host's to keep: on a desktop the
|
||||
* OS keychain, in the smoke test a map in memory. Nothing here knows which.
|
||||
*/
|
||||
public constructor (
|
||||
private readonly credentials: CredentialRepository = NO_CREDENTIALS,
|
||||
private readonly stores: InstalledStoreRepository = NO_STORES
|
||||
) {}
|
||||
|
||||
public async listGames (
|
||||
store: InstalledStore,
|
||||
progress: EngineProgressListener = {}
|
||||
): Promise<CatalogListing> {
|
||||
const engine = this.openStore(store, progress)
|
||||
const host = this.hosts.findHost()
|
||||
engine.log(`host: ${host.operatingSystem}/${host.architecture}`)
|
||||
|
||||
const [ descriptor, entries ] = await Promise.all([
|
||||
engine.service.fetchDescriptor(), this.readEntries(engine)
|
||||
])
|
||||
const survey = engine.surveyor.survey(entries, host)
|
||||
const installed = engine.state.readState()
|
||||
|
||||
// One list, both kinds: a client that hides what it cannot install leaves the
|
||||
// visitor wondering whether the catalog is small or their machine is unusual.
|
||||
const games = [
|
||||
...survey.games.map((game: SelectedGame): Game => this.toGame(engine, game, installed)),
|
||||
...survey.unavailable.map((entry: UnavailableEntry): Game => toUnavailableGame(entry))
|
||||
].sort((left: Game, right: Game): number =>
|
||||
left.title.toLowerCase().localeCompare(right.title.toLowerCase()))
|
||||
|
||||
return {
|
||||
games,
|
||||
skipped: survey.skipped,
|
||||
paths: this.toPaths(engine),
|
||||
account: toAccount(descriptor, this.credentials.readToken(store.id))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this store's things go.
|
||||
*
|
||||
* Synchronous work behind an async port, which is deliberate: the port was shaped
|
||||
* for a child process and stays shaped for one, so a future engine that has to go
|
||||
* to the network for this needs no change above.
|
||||
*/
|
||||
public readPaths (
|
||||
store: InstalledStore,
|
||||
progress: EngineProgressListener = {}
|
||||
): Promise<StorePaths> {
|
||||
return Promise.resolve(this.toPaths(this.openStore(store, progress)))
|
||||
}
|
||||
|
||||
public async syncGames (
|
||||
store: InstalledStore,
|
||||
names: readonly string[],
|
||||
progress: EngineProgressListener = {}
|
||||
): Promise<void> {
|
||||
const engine = this.openStore(store, progress)
|
||||
const survey: CatalogSurvey = engine.surveyor.survey(
|
||||
await this.readEntries(engine), this.hosts.findHost())
|
||||
for (const reason of survey.skipped) engine.log(`skipped ${reason}`)
|
||||
|
||||
const wanted = names.length > 0 ? limitToNames(survey.games, names) : survey.games
|
||||
const installed = engine.state.readState()
|
||||
// Pruning is for a full sync only: asked for two titles, the store must not take
|
||||
// the absence of the rest as a reason to uninstall them.
|
||||
const prune = engine.configuration.behavior.prune && names.length === 0
|
||||
|
||||
const changed = await engine.installer.installAll(
|
||||
engine.layout, wanted, installed, prune, progress)
|
||||
engine.state.writeState(installed)
|
||||
if (!changed) {
|
||||
engine.log('already up to date')
|
||||
return
|
||||
}
|
||||
engine.launchers.refreshMenu(engine.layout)
|
||||
engine.log(`done — the games are in ${engine.layouts.menuGroup(engine.layout)}`)
|
||||
}
|
||||
|
||||
public removeGame (
|
||||
store: InstalledStore,
|
||||
name: string,
|
||||
progress: EngineProgressListener = {}
|
||||
): Promise<void> {
|
||||
const engine = this.openStore(store, progress)
|
||||
const installed = engine.state.readState()
|
||||
const keys = matchStateKeys(installed, [name])
|
||||
if (keys.length === 0) {
|
||||
engine.log(`not installed: ${name}`)
|
||||
return Promise.resolve()
|
||||
}
|
||||
for (const key of keys) {
|
||||
const record = installed.get(key)
|
||||
if (record === undefined) continue
|
||||
engine.installer.removeGame(engine.layout, record)
|
||||
progress.onEvent?.({ event: 'removed', name: record.name })
|
||||
installed.delete(key)
|
||||
}
|
||||
engine.state.writeState(installed)
|
||||
engine.launchers.refreshMenu(engine.layout)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* This is the one place a WarpEngine version turns into behaviour: the header decides
|
||||
* which dialect parses the response, and everything downstream sees typed entries.
|
||||
*/
|
||||
private async readEntries (engine: StoreEngineContext): Promise<readonly CatalogEntry[]> {
|
||||
const fetched: FetchedCatalog = await engine.catalog.fetchCatalog()
|
||||
return selectCatalogDialect(fetched.engineVersion.resolved).listEntries(fetched.catalog)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the engine for one store.
|
||||
*
|
||||
* Per call rather than cached: the config on disk is the authority and the user may
|
||||
* have edited it, and a store's home is cheap to read.
|
||||
*/
|
||||
private openStore (store: InstalledStore, progress: EngineProgressListener): StoreEngineContext {
|
||||
const log = (line: string): void => { progress.onLog?.(`[${store.id}-store] ${line}`) }
|
||||
const files = new StoreFileSystem(`${store.id}-store`, log)
|
||||
const configuration = new StoreConfigurationReader(files).readConfiguration(store.configPath)
|
||||
|
||||
const layouts = new DesktopLayoutResolver(configuration, this.hosts)
|
||||
const layout = layouts.resolveLayout()
|
||||
const catalog = new CatalogClient(
|
||||
configuration, files, path.join(store.home, CATALOG_CACHE_FILE_NAME), log,
|
||||
(): string | null => this.credentials.readToken(store.id))
|
||||
const launchers = new LauncherWriter(configuration, layouts, files, log)
|
||||
|
||||
return {
|
||||
configuration,
|
||||
layout,
|
||||
layouts,
|
||||
catalog,
|
||||
launchers,
|
||||
log,
|
||||
service: new ServiceDescriptorClient(catalog.httpClient(), configuration.store.baseUrl, log),
|
||||
surveyor: new CatalogSurveyor(configuration, log),
|
||||
state: new StoreStateRepository(files, path.join(store.home, STATE_FILE_NAME), log),
|
||||
installer: new GameInstaller(
|
||||
configuration, layouts,
|
||||
new PayloadInstaller(catalog, files, log),
|
||||
launchers, catalog, files, log)
|
||||
}
|
||||
}
|
||||
|
||||
private toGame (
|
||||
engine: StoreEngineContext,
|
||||
game: SelectedGame,
|
||||
installed: ReadonlyMap<string, InstalledRecord>
|
||||
): Game {
|
||||
const record = installed.get(gameKey(game)) ?? null
|
||||
return {
|
||||
name: game.name,
|
||||
title: game.title,
|
||||
platform: game.platform,
|
||||
version: game.version,
|
||||
mode: toMode(game.mode),
|
||||
kind: game.kind,
|
||||
description: game.description,
|
||||
author: game.author,
|
||||
imagePath: game.imageUrl,
|
||||
installed: record !== null,
|
||||
updateAvailable: record !== null && record.asset !== game.asset,
|
||||
installedVersion: record?.version ?? null,
|
||||
menuEntryPath: record?.menuEntry ?? null,
|
||||
executablePath: record?.executable ?? null,
|
||||
// The catalog's own play address wins where it gives one: a store that gates its
|
||||
// web builds serves them from a page that knows how to ask somebody to sign in,
|
||||
// and the raw /file/ directory under it does not.
|
||||
hostedUrl: game.mode === WEB_MODE
|
||||
? game.access?.webUrl ?? engine.launchers.webUrl(game)
|
||||
: null,
|
||||
installable: true,
|
||||
unavailableReason: null,
|
||||
unavailableDetail: null,
|
||||
access: game.access
|
||||
}
|
||||
}
|
||||
|
||||
private toPaths (engine: StoreEngineContext): StorePaths {
|
||||
const host = this.hosts.findHost()
|
||||
return {
|
||||
operatingSystem: engine.layout.operatingSystem,
|
||||
architecture: host.architecture,
|
||||
installRoot: engine.layout.installRoot,
|
||||
menuDirectory: engine.layout.menuDirectory,
|
||||
storeFolder: engine.layouts.ownedRoot(engine.layout),
|
||||
menuGroup: engine.layouts.menuGroup(engine.layout),
|
||||
catalogBaseUrl: engine.configuration.store.baseUrl,
|
||||
storeName: engine.configuration.store.name,
|
||||
storeId: engine.configuration.store.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything one store's operations need, assembled from its home. */
|
||||
interface StoreEngineContext {
|
||||
readonly configuration: StoreConfiguration
|
||||
readonly layout: DesktopLayout
|
||||
readonly layouts: DesktopLayoutResolver
|
||||
readonly catalog: CatalogClient
|
||||
readonly service: ServiceDescriptorClient
|
||||
readonly launchers: LauncherWriter
|
||||
readonly surveyor: CatalogSurveyor
|
||||
readonly state: StoreStateRepository
|
||||
readonly installer: GameInstaller
|
||||
readonly log: (line: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A title this machine cannot install, in the same shape as an installable one.
|
||||
*
|
||||
* The mode is `app` for want of a truer answer: a title with no build has no mode,
|
||||
* and nothing reads this one because `installable` is false.
|
||||
*/
|
||||
function toUnavailableGame (entry: UnavailableEntry): Game {
|
||||
return {
|
||||
name: entry.name,
|
||||
title: entry.title,
|
||||
platform: entry.platform,
|
||||
version: entry.version,
|
||||
mode: APP_MODE,
|
||||
kind: '',
|
||||
description: entry.description,
|
||||
author: entry.author,
|
||||
imagePath: entry.imageUrl,
|
||||
installed: false,
|
||||
updateAvailable: false,
|
||||
installedVersion: null,
|
||||
menuEntryPath: null,
|
||||
executablePath: null,
|
||||
hostedUrl: null,
|
||||
installable: false,
|
||||
unavailableReason: entry.reason,
|
||||
unavailableDetail: entry.detail,
|
||||
access: entry.access
|
||||
}
|
||||
}
|
||||
|
||||
interface SignInContext {
|
||||
readonly client: DeviceSignInClient
|
||||
readonly descriptor: ServiceDescriptor
|
||||
readonly log: (line: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Holding a token for a store that has no sign-in is not being signed in.
|
||||
*
|
||||
* It happens: a store can lose its identity configuration, or a client can keep a token
|
||||
* from before. Reporting it as signed in would offer a "sign out" for a door that is no
|
||||
* longer there.
|
||||
*/
|
||||
function toAccount (descriptor: ServiceDescriptor, token: string | null): StoreAccount {
|
||||
const available = descriptor.auth !== null
|
||||
return { signInAvailable: available, signedIn: available && token !== null }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,159 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { SelectedGame } from '../../domain/models/SelectedGame'
|
||||
import { ZipArchive } from '../archive/ZipArchive'
|
||||
import type { StoreFileSystem } from '../files/StoreFileSystem'
|
||||
import type { CatalogClient } from './CatalogClient'
|
||||
|
||||
/** Files that are never the program: data, libraries and documentation. */
|
||||
const NEVER_A_PROGRAM: readonly string[] =
|
||||
['.txt', '.md', '.json', '.so', '.dll', '.dylib', '.pck', '.dat']
|
||||
|
||||
export type ExecutableKind = 'bundle' | 'exe'
|
||||
|
||||
export interface FoundProgram {
|
||||
readonly kind: ExecutableKind
|
||||
readonly executablePath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Getting a native build onto the disk and finding what to launch in it.
|
||||
*
|
||||
* The archive is downloaded to a part file beside the payload and unpacked only once
|
||||
* it is complete, and the payload directory is replaced rather than merged: a build
|
||||
* that dropped a file between releases would otherwise keep the old one around and
|
||||
* the game would load it.
|
||||
*/
|
||||
export class PayloadInstaller {
|
||||
public constructor (
|
||||
private readonly catalog: CatalogClient,
|
||||
private readonly files: StoreFileSystem,
|
||||
private readonly log: (line: string) => void
|
||||
) {}
|
||||
|
||||
/** Download and unpack into `destination`. Returns bytes downloaded. */
|
||||
public async unpack (game: SelectedGame, destination: string): Promise<number> {
|
||||
const parent = path.dirname(destination)
|
||||
fs.mkdirSync(parent, { recursive: true })
|
||||
const archivePath = this.files.temporaryPath(parent, game.name, '.zip')
|
||||
|
||||
try {
|
||||
const size = await this.catalog.downloadAsset(game.asset, archivePath)
|
||||
fs.rmSync(destination, { recursive: true, force: true })
|
||||
fs.mkdirSync(destination, { recursive: true })
|
||||
ZipArchive.open(archivePath).extractAll(destination)
|
||||
return size
|
||||
} finally {
|
||||
fs.rmSync(archivePath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The thing to launch inside an unpacked payload.
|
||||
*
|
||||
* A `bundle` is a macOS `.app` the archive already contained — a LÖVE or Godot
|
||||
* build ships one — and then it *is* the launcher rather than something to wrap.
|
||||
* Otherwise the answer is a single executable, and "single" is the whole
|
||||
* difficulty: an archive holding one candidate is unambiguous, and where there are
|
||||
* several the one named after the game wins. Anything else is reported as not
|
||||
* found, because launching the wrong binary is worse than failing the install.
|
||||
*/
|
||||
public findProgram (root: string, name: string, operatingSystem: string): FoundProgram | null {
|
||||
if (operatingSystem === 'darwin') {
|
||||
const bundle = this.findBundle(root)
|
||||
if (bundle !== null) return { kind: 'bundle', executablePath: bundle }
|
||||
}
|
||||
|
||||
const executables: string[] = []
|
||||
const namedAfterTheGame: string[] = []
|
||||
|
||||
this.walk(root, (filePath: string): void => {
|
||||
const fileName = path.basename(filePath)
|
||||
const lowered = fileName.toLowerCase()
|
||||
if (NEVER_A_PROGRAM.some((extension: string): boolean => lowered.endsWith(extension))) return
|
||||
|
||||
if (operatingSystem === 'windows') {
|
||||
if (lowered.endsWith('.exe')) executables.push(filePath)
|
||||
} else if (isExecutable(filePath)) {
|
||||
executables.push(filePath)
|
||||
}
|
||||
if (stem(fileName) === name) namedAfterTheGame.push(filePath)
|
||||
})
|
||||
|
||||
for (const candidates of [executables, namedAfterTheGame]) {
|
||||
if (candidates.length === 1 && candidates[0] !== undefined) {
|
||||
return { kind: 'exe', executablePath: candidates[0] }
|
||||
}
|
||||
const exact = candidates.filter((candidate: string): boolean =>
|
||||
stem(path.basename(candidate)) === name)
|
||||
if (exact.length === 1 && exact[0] !== undefined) {
|
||||
return { kind: 'exe', executablePath: exact[0] }
|
||||
}
|
||||
}
|
||||
this.log(`found no single program to launch inside ${root}`)
|
||||
return null
|
||||
}
|
||||
|
||||
/** The shallowest `.app`, without descending into one we have already found. */
|
||||
private findBundle (root: string): string | null {
|
||||
let level = [root]
|
||||
while (level.length > 0) {
|
||||
const next: string[] = []
|
||||
for (const directory of level) {
|
||||
const bundles = readDirectories(directory)
|
||||
.filter((entry: string): boolean => entry.endsWith('.app')).sort()
|
||||
const first = bundles[0]
|
||||
if (first !== undefined) return path.join(directory, first)
|
||||
for (const entry of readDirectories(directory)) next.push(path.join(directory, entry))
|
||||
}
|
||||
level = next
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Every file under `root`, never entering a `.app` bundle. */
|
||||
private walk (root: string, visit: (filePath: string) => void): void {
|
||||
const pending = [root]
|
||||
while (pending.length > 0) {
|
||||
const directory = pending.pop()
|
||||
if (directory === undefined) continue
|
||||
let entries: fs.Dirent[]
|
||||
try {
|
||||
entries = fs.readdirSync(directory, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (!entry.name.endsWith('.app')) pending.push(full)
|
||||
} else if (entry.isFile()) {
|
||||
visit(full)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readDirectories (directory: string): readonly string[] {
|
||||
try {
|
||||
return fs.readdirSync(directory, { withFileTypes: true })
|
||||
.filter((entry: fs.Dirent): boolean => entry.isDirectory())
|
||||
.map((entry: fs.Dirent): string => entry.name)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function isExecutable (filePath: string): boolean {
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function stem (fileName: string): string {
|
||||
return path.basename(fileName, path.extname(fileName))
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import path from 'node:path'
|
||||
import type { CatalogRelease } from './dialects/CatalogDialect'
|
||||
|
||||
export interface PickedRelease {
|
||||
readonly version: string
|
||||
readonly createdAt: string | null
|
||||
readonly assetName: string
|
||||
readonly kind: string
|
||||
readonly assetPath: string
|
||||
}
|
||||
|
||||
/** `/file/blessingofra-2.0.0.prg` -> `blessingofra-2.0.0.prg`. */
|
||||
export function assetBasename (assetPath: string): string {
|
||||
return path.posix.basename(assetPath.replace(/\/+$/, ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest non-dev release that carries an asset kind we can use.
|
||||
*
|
||||
* The candidates arrive newest-first from the dialect, which is where knowing that the
|
||||
* API sorts them lives. Two rules decide the rest:
|
||||
*
|
||||
* **Release order wins over kind order.** The newest release that has *any* acceptable
|
||||
* kind is taken, and within it the most preferred kind. That is what a desktop host
|
||||
* wants: on Apple Silicon `mac_universal` beats `mac_x64`, which would need Rosetta,
|
||||
* but not at the price of installing an older release.
|
||||
*
|
||||
* **A `dev-` build is never taken.** It is a moving target, and installing one would
|
||||
* leave a menu entry pointing at an archive that is replaced without a version change.
|
||||
*/
|
||||
export function pickRelease (
|
||||
candidates: readonly CatalogRelease[],
|
||||
kinds: readonly string[],
|
||||
extension: string
|
||||
): PickedRelease | null {
|
||||
for (const release of candidates) {
|
||||
if (release.version.startsWith('dev-')) continue
|
||||
for (const kind of kinds) {
|
||||
for (const asset of release.assets) {
|
||||
if (asset.kind !== kind) continue
|
||||
const assetName = assetBasename(asset.path)
|
||||
if (assetName.length === 0) continue
|
||||
if (extension.length > 0 && !assetName.toLowerCase().endsWith(extension.toLowerCase())) continue
|
||||
return {
|
||||
version: release.version,
|
||||
createdAt: release.createdAt,
|
||||
assetName,
|
||||
kind,
|
||||
assetPath: asset.path
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
DEFAULT_SERVICE_DESCRIPTOR, type AuthDescriptor, type DeviceAuthDescriptor,
|
||||
type ServiceDescriptor
|
||||
} from '../../domain/models/ServiceDescriptor'
|
||||
import { HttpStatusError } from '../http/HttpTextClient'
|
||||
import type { StoreHttpClient } from '../http/StoreHttpClient'
|
||||
import { asRecord, readBoolean, readNumber, readOptionalString, readRecord } from '../json/JsonRecord'
|
||||
|
||||
const SERVICE_PATH = '/api/service'
|
||||
|
||||
/**
|
||||
* `GET /api/service`: what this catalog's server is, asked before anything else.
|
||||
*
|
||||
* A missing descriptor is an answer, not a failure. Every WarpEngine before 0.5 has no
|
||||
* such endpoint, so a 404 means "an older engine" — a plain catalog with nothing gated
|
||||
* and nobody to sign in as, which is exactly what this client assumed for its whole
|
||||
* life before now. Same for a network that is simply down: the store still works
|
||||
* offline from its cached catalog, and refusing to open because we could not ask the
|
||||
* server about itself would be a worse client than the one we had.
|
||||
*/
|
||||
export class ServiceDescriptorClient {
|
||||
public constructor (
|
||||
private readonly http: StoreHttpClient,
|
||||
private readonly baseUrl: string,
|
||||
private readonly log: (line: string) => void
|
||||
) {}
|
||||
|
||||
public async fetchDescriptor (): Promise<ServiceDescriptor> {
|
||||
const url = `${this.baseUrl}${SERVICE_PATH}`
|
||||
try {
|
||||
const { json } = await this.http.requestJson(url)
|
||||
const record = asRecord(json)
|
||||
if (record === null) return DEFAULT_SERVICE_DESCRIPTOR
|
||||
|
||||
const descriptor: ServiceDescriptor = {
|
||||
engineVersion: readOptionalString(record, 'version'),
|
||||
catalogGated: readBoolean(readRecord(record, 'catalog') ?? {}, 'gated', false),
|
||||
auth: readAuth(record, this.baseUrl)
|
||||
}
|
||||
this.log(describe(descriptor))
|
||||
return descriptor
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof HttpStatusError && error.statusCode === 404) {
|
||||
this.log('the catalog has no service descriptor — an engine older than 0.5')
|
||||
} else {
|
||||
this.log(`warning: could not read ${url} — carrying on as a plain catalog`)
|
||||
}
|
||||
return DEFAULT_SERVICE_DESCRIPTOR
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readAuth (record: Readonly<Record<string, unknown>>, baseUrl: string): AuthDescriptor | null {
|
||||
const auth = readRecord(record, 'auth')
|
||||
if (auth === null) return null
|
||||
|
||||
const device = readRecord(auth, 'device')
|
||||
if (device === null) return null
|
||||
|
||||
const authorizeUrl = absolute(readOptionalString(device, 'authorizeUrl'), baseUrl)
|
||||
const tokenUrl = absolute(readOptionalString(device, 'tokenUrl'), baseUrl)
|
||||
const verificationUrl = absolute(readOptionalString(device, 'verificationUrl'), baseUrl)
|
||||
// Two of the three are the flow itself and the third is where a person goes. Without
|
||||
// all three there is no sign-in to offer, and half a flow is worse than none.
|
||||
if (authorizeUrl === null || tokenUrl === null || verificationUrl === null) return null
|
||||
|
||||
const descriptor: DeviceAuthDescriptor = {
|
||||
authorizeUrl,
|
||||
tokenUrl,
|
||||
revokeUrl: absolute(readOptionalString(device, 'revokeUrl'), baseUrl),
|
||||
verificationUrl,
|
||||
interval: Math.max(1, readNumber(device, 'interval', 5))
|
||||
}
|
||||
return { device: descriptor }
|
||||
}
|
||||
|
||||
/** A server may answer with a path; it knows its own address better than we do. */
|
||||
function absolute (value: string | null, baseUrl: string): string | null {
|
||||
if (value === null || value.length === 0) return null
|
||||
if (value.startsWith('http://') || value.startsWith('https://')) return value
|
||||
return `${baseUrl.replace(/\/+$/, '')}/${value.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
function describe (descriptor: ServiceDescriptor): string {
|
||||
const version = descriptor.engineVersion ?? 'an unnamed version'
|
||||
const gated = descriptor.catalogGated ? 'some titles need an entitlement' : 'nothing is gated'
|
||||
const auth = descriptor.auth === null ? 'no sign-in' : 'sign-in available'
|
||||
return `catalog served by WarpEngine ${version} — ${gated}, ${auth}`
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { HostSpecific } from '../../domain/models/HostMachine'
|
||||
import {
|
||||
APP_MODE, DEFAULT_STORE_CONFIGURATION, WEB_MODE,
|
||||
type AssetSpecification, type BehaviorConfiguration, type CatalogConfiguration,
|
||||
type InstallConfiguration, type PathsConfiguration, type PlatformConfiguration,
|
||||
type StoreConfiguration, type StoreDescriptor
|
||||
} from '../../domain/models/StoreConfiguration'
|
||||
import {
|
||||
asRecord, readBoolean, readNumber, readOptionalString, readRecord, readString,
|
||||
readStringArray, type JsonRecord
|
||||
} from '../json/JsonRecord'
|
||||
import type { StoreFileSystem } from '../files/StoreFileSystem'
|
||||
|
||||
/**
|
||||
* A store's `config.json`, read onto the defaults.
|
||||
*
|
||||
* The file is **snake_case** and this is the only place that knows it: that is the
|
||||
* format the store repositories publish, and it stays the format on disk so a store
|
||||
* config written for the shell engine is still a valid config here. Rename a field
|
||||
* there and this reader is the single file that follows.
|
||||
*
|
||||
* Reading replaces the deep merge the shell engine did: every field states its own
|
||||
* default, so a config that omits a section gets the whole section rather than a
|
||||
* half-populated one.
|
||||
*/
|
||||
export class StoreConfigurationReader {
|
||||
public constructor (private readonly files: StoreFileSystem) {}
|
||||
|
||||
public readConfiguration (configPath: string): StoreConfiguration {
|
||||
const record = asRecord(this.files.readJson(configPath)) ?? {}
|
||||
return {
|
||||
store: readStore(readRecord(record, 'store') ?? {}),
|
||||
paths: readPaths(readRecord(record, 'paths') ?? {}),
|
||||
install: readInstall(readRecord(record, 'install') ?? {}),
|
||||
catalog: readCatalog(readRecord(record, 'catalog') ?? {}),
|
||||
platforms: readPlatforms(readRecord(record, 'platforms')),
|
||||
behavior: readBehavior(readRecord(record, 'behavior') ?? {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readStore (record: JsonRecord): StoreDescriptor {
|
||||
const defaults = DEFAULT_STORE_CONFIGURATION.store
|
||||
const api = readRecord(record, 'api') ?? {}
|
||||
return {
|
||||
id: readString(record, 'id', defaults.id),
|
||||
name: readString(record, 'name', defaults.name),
|
||||
// Trailing slashes are stripped once, here, so every URL built from it joins
|
||||
// with exactly one separator.
|
||||
baseUrl: readString(record, 'base_url', defaults.baseUrl).replace(/\/+$/, ''),
|
||||
api: {
|
||||
catalog: readString(api, 'catalog', defaults.api.catalog),
|
||||
download: readString(api, 'download', defaults.api.download)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readPaths (record: JsonRecord): PathsConfiguration {
|
||||
const defaults = DEFAULT_STORE_CONFIGURATION.paths
|
||||
return {
|
||||
installRoot: readOptionalString(record, 'install_root'),
|
||||
menuDirectory: readOptionalString(record, 'menu_dir'),
|
||||
iconDirectory: readOptionalString(record, 'icon_dir'),
|
||||
subfolder: readString(record, 'subfolder', defaults.subfolder)
|
||||
}
|
||||
}
|
||||
|
||||
function readInstall (record: JsonRecord): InstallConfiguration {
|
||||
const defaults = DEFAULT_STORE_CONFIGURATION.install
|
||||
const modes = readStringArray(record, 'modes')
|
||||
const specifications: Record<string, AssetSpecification> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (key === 'modes') continue
|
||||
const specification = asRecord(value)
|
||||
if (specification === null) continue
|
||||
specifications[key] = {
|
||||
kind: readAssetKind(specification['kind']),
|
||||
extension: readHostSpecificString(specification['ext'])
|
||||
}
|
||||
}
|
||||
for (const mode of [APP_MODE, WEB_MODE]) {
|
||||
specifications[mode] ??= defaults.specifications[mode] ?? { kind: null, extension: null }
|
||||
}
|
||||
|
||||
return { modes: modes.length > 0 ? modes : defaults.modes, specifications }
|
||||
}
|
||||
|
||||
function readCatalog (record: JsonRecord): CatalogConfiguration {
|
||||
const defaults = DEFAULT_STORE_CONFIGURATION.catalog
|
||||
const statuses = readStringArray(record, 'statuses')
|
||||
const ownerId = record['owner_id']
|
||||
return {
|
||||
// An explicit empty list means "every status", so only a missing key falls back.
|
||||
statuses: record['statuses'] === undefined ? defaults.statuses : statuses,
|
||||
ownerId: typeof ownerId === 'number' && Number.isFinite(ownerId) ? ownerId : null,
|
||||
only: readStringArray(record, 'only'),
|
||||
exclude: readStringArray(record, 'exclude')
|
||||
}
|
||||
}
|
||||
|
||||
function readPlatforms (record: JsonRecord | null): Readonly<Record<string, PlatformConfiguration>> {
|
||||
if (record === null) return DEFAULT_STORE_CONFIGURATION.platforms
|
||||
const platforms: Record<string, PlatformConfiguration> = {}
|
||||
for (const [name, value] of Object.entries(record)) {
|
||||
const entry = asRecord(value)
|
||||
platforms[name] = { enabled: entry === null ? true : readBoolean(entry, 'enabled', true) }
|
||||
}
|
||||
return platforms
|
||||
}
|
||||
|
||||
function readBehavior (record: JsonRecord): BehaviorConfiguration {
|
||||
const defaults = DEFAULT_STORE_CONFIGURATION.behavior
|
||||
return {
|
||||
prune: readBoolean(record, 'prune', defaults.prune),
|
||||
timeout: readNumber(record, 'timeout', defaults.timeout),
|
||||
insecure: readBoolean(record, 'insecure', defaults.insecure)
|
||||
}
|
||||
}
|
||||
|
||||
/** `"linux_x64"`, `["win_x64", "win_x86"]`, or either of those keyed by host. */
|
||||
function readAssetKind (value: unknown): HostSpecific<string | readonly string[]> | null {
|
||||
if (typeof value === 'string') return value
|
||||
if (Array.isArray(value)) return readStrings(value)
|
||||
const record = asRecord(value)
|
||||
if (record === null) return null
|
||||
const map: Record<string, string | readonly string[]> = {}
|
||||
for (const [key, entry] of Object.entries(record)) {
|
||||
if (typeof entry === 'string') map[key] = entry
|
||||
else if (Array.isArray(entry)) map[key] = readStrings(entry)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function readHostSpecificString (value: unknown): HostSpecific<string> | null {
|
||||
if (typeof value === 'string') return value
|
||||
const record = asRecord(value)
|
||||
if (record === null) return null
|
||||
const map: Record<string, string> = {}
|
||||
for (const [key, entry] of Object.entries(record)) {
|
||||
if (typeof entry === 'string') map[key] = entry
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function readStrings (values: readonly unknown[]): readonly string[] {
|
||||
return values.filter((item: unknown): item is string => typeof item === 'string')
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
gameKey, recordScope, type InstalledRecord
|
||||
} from '../../domain/models/InstalledRecord'
|
||||
import {
|
||||
asRecord, readOptionalString, readString, type JsonRecord
|
||||
} from '../json/JsonRecord'
|
||||
import type { StoreFileSystem } from '../files/StoreFileSystem'
|
||||
|
||||
/** Bumped when the on-disk shape changes. v1 keyed `installed` by bare software name. */
|
||||
const STATE_VERSION = 2
|
||||
|
||||
/**
|
||||
* `state.json`: what this store put on this machine, and where.
|
||||
*
|
||||
* The file is **snake_case**, and deliberately so: it is the same file the shell
|
||||
* store engine wrote, so a machine that installed games through the CLI keeps its
|
||||
* library when the client takes over. This class is the only place that knows the
|
||||
* on-disk field names — everything above it sees `InstalledRecord`.
|
||||
*
|
||||
* A `version: 1` file, keyed by bare software name, is re-keyed on first read.
|
||||
*/
|
||||
export class StoreStateRepository {
|
||||
public constructor (
|
||||
private readonly files: StoreFileSystem,
|
||||
private readonly statePath: string,
|
||||
private readonly log: (line: string) => void
|
||||
) {}
|
||||
|
||||
public readState (): Map<string, InstalledRecord> {
|
||||
const state = asRecord(this.files.readJson(this.statePath))
|
||||
if (state === null) return new Map<string, InstalledRecord>()
|
||||
const installed = asRecord(state['installed'])
|
||||
if (installed === null) return new Map<string, InstalledRecord>()
|
||||
|
||||
const version = state['version']
|
||||
if (version === 1) return this.migrateFromVersionOne(installed)
|
||||
if (version !== STATE_VERSION) return new Map<string, InstalledRecord>()
|
||||
|
||||
const records = new Map<string, InstalledRecord>()
|
||||
for (const [key, value] of Object.entries(installed)) {
|
||||
const record = asRecord(value)
|
||||
if (record !== null) records.set(key, toRecord(record))
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
public writeState (installed: ReadonlyMap<string, InstalledRecord>): void {
|
||||
const serialised: Record<string, JsonRecord> = {}
|
||||
for (const [key, record] of installed) serialised[key] = fromRecord(record)
|
||||
this.files.writeJson(this.statePath, { version: STATE_VERSION, installed: serialised })
|
||||
}
|
||||
|
||||
private migrateFromVersionOne (installed: JsonRecord): Map<string, InstalledRecord> {
|
||||
const records = new Map<string, InstalledRecord>()
|
||||
for (const value of Object.values(installed)) {
|
||||
const raw = asRecord(value)
|
||||
if (raw === null) continue
|
||||
const record = toRecord(raw)
|
||||
if (record.name.length > 0 && record.scope.length > 0) records.set(gameKey(record), record)
|
||||
}
|
||||
this.log(`migrated ${String(records.size)} state entries to the per-scope key format`)
|
||||
return records
|
||||
}
|
||||
}
|
||||
|
||||
function toRecord (raw: JsonRecord): InstalledRecord {
|
||||
return {
|
||||
name: readString(raw, 'name'),
|
||||
// `system` is what the Batocera store wrote before the shared core existed, and
|
||||
// there the two were the same string — so an installed machine needs no migration.
|
||||
scope: recordScope(readOptionalString(raw, 'scope'), readOptionalString(raw, 'system')),
|
||||
platform: readString(raw, 'platform'),
|
||||
kind: readString(raw, 'kind'),
|
||||
version: readString(raw, 'version'),
|
||||
asset: readString(raw, 'asset'),
|
||||
assetPath: readString(raw, 'asset_path'),
|
||||
title: readString(raw, 'title'),
|
||||
description: readString(raw, 'desc'),
|
||||
author: readString(raw, 'author'),
|
||||
imageUrl: readOptionalString(raw, 'image_url'),
|
||||
createdAt: readOptionalString(raw, 'created_at'),
|
||||
mode: readString(raw, 'mode'),
|
||||
payload: readOptionalString(raw, 'payload'),
|
||||
executable: readOptionalString(raw, 'exe'),
|
||||
executableKind: readOptionalString(raw, 'exe_kind'),
|
||||
icon: readOptionalString(raw, 'icon'),
|
||||
menuEntry: readOptionalString(raw, 'menu'),
|
||||
url: readOptionalString(raw, 'url')
|
||||
}
|
||||
}
|
||||
|
||||
function fromRecord (record: InstalledRecord): JsonRecord {
|
||||
return {
|
||||
name: record.name,
|
||||
scope: record.scope,
|
||||
platform: record.platform,
|
||||
kind: record.kind,
|
||||
version: record.version,
|
||||
asset: record.asset,
|
||||
asset_path: record.assetPath,
|
||||
title: record.title,
|
||||
desc: record.description,
|
||||
author: record.author,
|
||||
image_url: record.imageUrl,
|
||||
created_at: record.createdAt,
|
||||
mode: record.mode,
|
||||
payload: record.payload,
|
||||
exe: record.executable,
|
||||
exe_kind: record.executableKind,
|
||||
icon: record.icon,
|
||||
menu: record.menuEntry,
|
||||
url: record.url
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
readBoolean, readNumber, readOptionalString, readRecord, readString, type JsonRecord
|
||||
} from '../../json/JsonRecord'
|
||||
import type { CatalogAccess, CatalogPrice } from '../../../domain/models/CatalogAccess'
|
||||
import { SoftwareListCatalogDialect } from './SoftwareListCatalogDialect'
|
||||
|
||||
/**
|
||||
* The catalog as WarpEngine 0.5 serves it: the same entries, plus what they cost.
|
||||
*
|
||||
* 0.5 is the first engine that can say a title is not yours. Every entry carries an
|
||||
* `access` block — even in a catalog that gates nothing, so that "this store is open"
|
||||
* and "this store did not say" stay tellable apart. Everything else about the shape is
|
||||
* unchanged, which is why this is the older dialect with one field added rather than a
|
||||
* parser of its own.
|
||||
*
|
||||
* The words are the engine's, not any store's. A client reads more than one catalog,
|
||||
* and a field named after what one shop calls its wares is a field that only works
|
||||
* there.
|
||||
*/
|
||||
export class AccessAwareCatalogDialect extends SoftwareListCatalogDialect {
|
||||
protected override readAccess (entry: JsonRecord): CatalogAccess | null {
|
||||
const access = readRecord(entry, 'access')
|
||||
// An entry with no block at all: possible from a 0.5 engine whose policy failed to
|
||||
// answer. Reading it as "open" would be inventing the friendlier of two answers.
|
||||
if (access === null) return null
|
||||
|
||||
return {
|
||||
gated: readBoolean(access, 'gated', false),
|
||||
entitled: readNullableBoolean(access, 'entitled'),
|
||||
price: readPrice(access),
|
||||
purchaseUrl: readOptionalString(access, 'purchaseUrl'),
|
||||
webUrl: readOptionalString(access, 'webUrl')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Three states, not two: yes, no, and nobody asked.
|
||||
*
|
||||
* A client that is not signed in gets null, and that is the case worth keeping
|
||||
* separate — it is the difference between "you do not own this" and "there is no you",
|
||||
* and only the second is a reason to offer signing in.
|
||||
*/
|
||||
function readNullableBoolean (record: JsonRecord, key: string): boolean | null {
|
||||
const value = record[key]
|
||||
return typeof value === 'boolean' ? value : null
|
||||
}
|
||||
|
||||
/** A price with no currency is not a price anybody can be shown. */
|
||||
function readPrice (access: JsonRecord): CatalogPrice | null {
|
||||
const price = readRecord(access, 'price')
|
||||
if (price === null) return null
|
||||
|
||||
const currency = readString(price, 'currency')
|
||||
if (currency.length === 0) return null
|
||||
return { amountCents: readNumber(price, 'amountCents'), currency }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { CatalogAccess } from '../../../domain/models/CatalogAccess'
|
||||
import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngineVersion'
|
||||
|
||||
/**
|
||||
* How one WarpEngine version's catalog is shaped.
|
||||
*
|
||||
* The catalog is the one thing this client reads that it does not own the shape of, so
|
||||
* it is the one thing an engine version can change under it. A dialect turns that
|
||||
* foreign JSON into the typed records below, and everything downstream — the survey,
|
||||
* the release choice — sees only those. When a future engine renames a field or nests a
|
||||
* release differently, a new dialect is the whole change.
|
||||
*/
|
||||
export interface CatalogDialect {
|
||||
readonly version: SupportedWarpEngineVersion
|
||||
/** One entry per title in the catalog. */
|
||||
listEntries: (catalog: unknown) => readonly CatalogEntry[]
|
||||
}
|
||||
|
||||
export interface CatalogEntry {
|
||||
readonly software: CatalogSoftware
|
||||
/**
|
||||
* What the catalog says about getting this title, or null where it says nothing.
|
||||
*
|
||||
* Null is not "free": it is an engine too old to have an opinion, and a store that
|
||||
* never gated anything reads the same as one that could not say. Both mean the same
|
||||
* thing in practice — try the download — but only one of them is worth offering a
|
||||
* sign-in for.
|
||||
*/
|
||||
readonly access: CatalogAccess | null
|
||||
/**
|
||||
* The release the catalog itself calls newest-and-stable, or null when it names none.
|
||||
*
|
||||
* Kept separate from the candidates because it is also what an unavailable title's
|
||||
* card shows a version from — and a catalog with releases but no `latestRelease` has
|
||||
* nothing to show there.
|
||||
*/
|
||||
readonly latestRelease: CatalogRelease | null
|
||||
/**
|
||||
* Every release worth trying, newest first and deduplicated.
|
||||
*
|
||||
* `latestRelease` comes first where there is one, then the rest, so that a game whose
|
||||
* newest build is missing an asset still installs from an older one.
|
||||
*/
|
||||
readonly releaseCandidates: readonly CatalogRelease[]
|
||||
}
|
||||
|
||||
export interface CatalogSoftware {
|
||||
readonly name: string
|
||||
readonly title: string
|
||||
readonly platform: string
|
||||
readonly status: string
|
||||
readonly description: string
|
||||
readonly author: string
|
||||
readonly imageUrl: string | null
|
||||
}
|
||||
|
||||
export interface CatalogRelease {
|
||||
readonly version: string
|
||||
readonly createdAt: string | null
|
||||
readonly assets: readonly CatalogAsset[]
|
||||
}
|
||||
|
||||
export interface CatalogAsset {
|
||||
readonly kind: string
|
||||
readonly path: string
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngineVersion'
|
||||
import { AccessAwareCatalogDialect } from './AccessAwareCatalogDialect'
|
||||
import type { CatalogDialect } from './CatalogDialect'
|
||||
import { SoftwareListCatalogDialect } from './SoftwareListCatalogDialect'
|
||||
|
||||
/**
|
||||
* Which dialect reads a catalog served by which engine version.
|
||||
*
|
||||
* The switch is exhaustive over `SUPPORTED_WARP_ENGINE_VERSIONS`, which is the whole
|
||||
* mechanism: adding a version to that list stops compiling here until somebody decides
|
||||
* what it reads like. Three versions share one dialect because the catalog's shape did
|
||||
* not change across them — and one class serving three versions is the honest way to
|
||||
* say that, rather than three identical ones pretending otherwise.
|
||||
*
|
||||
* 0.5 gets its own, because that is the engine that started saying what a title costs.
|
||||
*/
|
||||
export function selectCatalogDialect (version: SupportedWarpEngineVersion): CatalogDialect {
|
||||
switch (version) {
|
||||
case '0.2':
|
||||
case '0.3':
|
||||
case '0.4':
|
||||
return new SoftwareListCatalogDialect(version)
|
||||
case '0.5':
|
||||
return new AccessAwareCatalogDialect(version)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngineVersion'
|
||||
import {
|
||||
asRecord, readOptionalString, readRecord, readString, type JsonRecord
|
||||
} from '../../json/JsonRecord'
|
||||
import type { CatalogAccess } from '../../../domain/models/CatalogAccess'
|
||||
import type {
|
||||
CatalogAsset, CatalogDialect, CatalogEntry, CatalogRelease, CatalogSoftware
|
||||
} from './CatalogDialect'
|
||||
|
||||
/**
|
||||
* The catalog as every WarpEngine from 0.2 to 0.4 serves it.
|
||||
*
|
||||
* `{ softwares: [ { software: {…}, latestRelease: {…}, releases: [ { assets: [] } ] } ] }`,
|
||||
* camelCase throughout. The three versions differ in what they *contain* — 0.3 added
|
||||
* the `linux_arm64` asset kinds, so an older engine simply has fewer kinds to offer,
|
||||
* and that needs no code: an absent kind is a title reported as unavailable on this
|
||||
* machine, which is already the honest answer.
|
||||
*
|
||||
* The version is carried rather than assumed, so a log line can name which dialect read
|
||||
* a catalog even while one class serves several.
|
||||
*/
|
||||
export class SoftwareListCatalogDialect implements CatalogDialect {
|
||||
public constructor (public readonly version: SupportedWarpEngineVersion) {}
|
||||
|
||||
public listEntries (catalog: unknown): readonly CatalogEntry[] {
|
||||
const record = asRecord(catalog)
|
||||
if (record === null) return []
|
||||
const entries = record['softwares']
|
||||
if (!Array.isArray(entries)) return []
|
||||
|
||||
const found: CatalogEntry[] = []
|
||||
for (const item of entries) {
|
||||
const entry = asRecord(item)
|
||||
if (entry === null) continue
|
||||
const software = this.readSoftware(entry)
|
||||
if (software === null) continue
|
||||
found.push({
|
||||
software,
|
||||
access: this.readAccess(entry),
|
||||
latestRelease: this.readLatestRelease(entry),
|
||||
releaseCandidates: this.readCandidates(entry)
|
||||
})
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* What the catalog says about getting this title. Nothing, at these versions.
|
||||
*
|
||||
* An engine older than 0.5 has no opinion to report, and inventing one here would be
|
||||
* worse than admitting it: "not gated" and "could not say" are different answers, and
|
||||
* only the first is safe to act on. The subclass that can read it overrides this.
|
||||
*/
|
||||
protected readAccess (entry: JsonRecord): CatalogAccess | null {
|
||||
void entry
|
||||
return null
|
||||
}
|
||||
|
||||
/** A title with no name is not a title: nothing could be keyed by it. */
|
||||
protected readSoftware (entry: JsonRecord): CatalogSoftware | null {
|
||||
const software = readRecord(entry, 'software')
|
||||
if (software === null) return null
|
||||
const name = readOptionalString(software, 'name')
|
||||
if (name === null) return null
|
||||
return {
|
||||
name,
|
||||
title: readOptionalString(software, 'title') ?? name,
|
||||
platform: readString(software, 'platform'),
|
||||
status: readString(software, 'status'),
|
||||
description: readString(software, 'desc').trim(),
|
||||
author: readString(software, 'author').trim(),
|
||||
imageUrl: readOptionalString(software, 'imageUrl')
|
||||
}
|
||||
}
|
||||
|
||||
protected readLatestRelease (entry: JsonRecord): CatalogRelease | null {
|
||||
const latest = readRecord(entry, 'latestRelease')
|
||||
return latest === null ? null : this.readRelease(latest)
|
||||
}
|
||||
|
||||
/**
|
||||
* The releases to try, newest first and without repeats.
|
||||
*
|
||||
* `releases` arrives newest-first from the API and `latestRelease` is usually its
|
||||
* first element, so identity is settled on the release's own id where it has one.
|
||||
*/
|
||||
protected readCandidates (entry: JsonRecord): readonly CatalogRelease[] {
|
||||
const records: JsonRecord[] = []
|
||||
const latest = readRecord(entry, 'latestRelease')
|
||||
if (latest !== null) records.push(latest)
|
||||
const releases = entry['releases']
|
||||
if (Array.isArray(releases)) {
|
||||
for (const item of releases) {
|
||||
const record = asRecord(item)
|
||||
if (record !== null) records.push(record)
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const candidates: CatalogRelease[] = []
|
||||
for (const record of records) {
|
||||
const identity = JSON.stringify(record['id'] ?? null)
|
||||
if (seen.has(identity)) continue
|
||||
seen.add(identity)
|
||||
candidates.push(this.readRelease(record))
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
protected readRelease (release: JsonRecord): CatalogRelease {
|
||||
const assets: CatalogAsset[] = []
|
||||
const listed = release['assets']
|
||||
if (Array.isArray(listed)) {
|
||||
for (const item of listed) {
|
||||
const asset = asRecord(item)
|
||||
if (asset === null) continue
|
||||
assets.push({ kind: readString(asset, 'kind'), path: readString(asset, 'path') })
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: readString(release, 'version'),
|
||||
createdAt: readOptionalString(release, 'createdAt'),
|
||||
assets
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { InstalledRecord } from '../../../domain/models/InstalledRecord'
|
||||
import { WEB_MODE } from '../../../domain/models/StoreConfiguration'
|
||||
import type { StoreFileSystem } from '../../files/StoreFileSystem'
|
||||
import { quoteForShell } from './ShellQuoting'
|
||||
|
||||
const LAUNCHER_MODE = 0o755
|
||||
const COMMENT_LIMIT = 120
|
||||
|
||||
/**
|
||||
* Linux: an XDG desktop entry.
|
||||
*
|
||||
* `Path=` is what gives the game its working directory — a Godot or LÖVE build
|
||||
* looks for its `.pck` next to the binary, and started from anywhere else it exits
|
||||
* without a window and without a message.
|
||||
*/
|
||||
export class DesktopEntryWriter {
|
||||
public constructor (
|
||||
private readonly storeId: string,
|
||||
private readonly files: StoreFileSystem
|
||||
) {}
|
||||
|
||||
public write (record: InstalledRecord, entryPath: string, webUrl: string): string {
|
||||
const lines: string[] = ['[Desktop Entry]', 'Type=Application', 'Version=1.0', `Name=${record.title}`]
|
||||
|
||||
if (record.description.length > 0) {
|
||||
// One line only, and short: the menu shows it as a tooltip.
|
||||
const firstLine = record.description.split('\n')[0] ?? ''
|
||||
lines.push(`Comment=${firstLine.slice(0, COMMENT_LIMIT)}`)
|
||||
}
|
||||
if (record.mode === WEB_MODE) {
|
||||
lines.push(`Exec=xdg-open ${quoteForShell(webUrl)}`)
|
||||
} else if (record.executable !== null) {
|
||||
lines.push(`Exec=${quoteForShell(record.executable)}`)
|
||||
lines.push(`Path=${quoteForShell(path.dirname(record.executable))}`)
|
||||
}
|
||||
if (record.icon !== null) lines.push(`Icon=${record.icon}`)
|
||||
lines.push('Terminal=false', 'Categories=Game;', `X-WarpStore=${this.storeId}`, '')
|
||||
|
||||
fs.mkdirSync(path.dirname(entryPath), { recursive: true })
|
||||
this.files.writeAtomic(entryPath, Buffer.from(lines.join('\n'), 'utf8'), LAUNCHER_MODE)
|
||||
return entryPath
|
||||
}
|
||||
}
|
||||