Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8511ccbef8 | ||
|
|
a364a5ce5f | ||
|
|
7026e0cc6a | ||
|
|
2f725fdd14 | ||
|
|
526c67b069 | ||
|
|
3d63c8a0b0 | ||
|
|
a25acf6e35 |
@@ -1,2 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# 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 is worth running here: it exercises the
|
||||||
|
# registry and the message bundles.
|
||||||
|
- |
|
||||||
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
|
npm run smoke
|
||||||
|
else
|
||||||
|
echo "no python3 in the image — the smoke test needs it, skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# WarpEngine Store GUI — the front door to the npm scripts.
|
# WarpEngine Client — the front door to the npm scripts.
|
||||||
#
|
#
|
||||||
# Everything here is a thin wrapper: the app is an Electron project, so npm still
|
# Everything here is a thin wrapper: the app is an Electron project, so npm still
|
||||||
# does the work. The Makefile exists so the useful sequences have names, and so
|
# does the work. The Makefile exists so the useful sequences have names, and so
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
#
|
#
|
||||||
# make list the targets
|
# make list the targets
|
||||||
# make setup install the dependencies
|
# make setup install the dependencies
|
||||||
|
# make check typecheck, lint and both test suites
|
||||||
# make dist package for this machine
|
# make dist package for this machine
|
||||||
# make release package and publish to Gitea
|
# make release package and publish to Gitea
|
||||||
#
|
#
|
||||||
@@ -21,13 +22,23 @@ NODE_MIN := 22
|
|||||||
VERSION := $(shell python3 -c 'import json; print(json.load(open("package.json"))["version"])')
|
VERSION := $(shell python3 -c 'import json; print(json.load(open("package.json"))["version"])')
|
||||||
TAG ?= v$(VERSION)
|
TAG ?= v$(VERSION)
|
||||||
|
|
||||||
|
# Which site's store registry a packaged build reads. Empty means the default in
|
||||||
|
# package.json (ours); set it to build a client for somebody else's catalog:
|
||||||
|
#
|
||||||
|
# make dist STORES_API=https://games.example.org/api/stores
|
||||||
|
#
|
||||||
|
# It is baked into the package's own package.json, so the built app carries it. A runtime
|
||||||
|
# STORES_API still overrides it, which is for trying something out rather than shipping.
|
||||||
|
STORES_API ?=
|
||||||
|
BUILDER_ARGS := $(if $(STORES_API),-- --config.extraMetadata.warpEngine.registryUrl=$(STORES_API),)
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
.PHONY: help setup node-check start smoke uitest test dist dist-mac dist-win dist-linux \
|
.PHONY: help setup node-check build typecheck lint lint-fix check start smoke uitest test \
|
||||||
release publish clean distclean version
|
dist dist-mac dist-win dist-linux release publish clean distclean version
|
||||||
|
|
||||||
help: ## List available targets
|
help: ## List available targets
|
||||||
@echo "WarpEngine Store GUI $(VERSION) — usage: make <target>"
|
@echo "WarpEngine Client $(VERSION) — usage: make <target>"
|
||||||
@echo
|
@echo
|
||||||
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \
|
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \
|
||||||
awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
|
awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
|
||||||
@@ -45,6 +56,22 @@ node-check: ## Check the Node version Electron's installer needs
|
|||||||
setup: node-check ## Install the dependencies
|
setup: node-check ## Install the dependencies
|
||||||
npm install
|
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 both test suites
|
||||||
|
|
||||||
start: ## Run the app against whatever store is installed
|
start: ## Run the app against whatever store is installed
|
||||||
npm start
|
npm start
|
||||||
|
|
||||||
@@ -57,16 +84,16 @@ uitest: ## Load the window once and report what rendered
|
|||||||
test: smoke uitest ## Both checks
|
test: smoke uitest ## Both checks
|
||||||
|
|
||||||
dist: node-check ## Package for this machine
|
dist: node-check ## Package for this machine
|
||||||
npm run dist
|
npm run dist $(BUILDER_ARGS)
|
||||||
|
|
||||||
dist-mac: node-check ## Package for macOS (ad-hoc signed, see the README)
|
dist-mac: node-check ## Package for macOS (ad-hoc signed, see the README)
|
||||||
npm run dist:mac
|
npm run dist:mac $(BUILDER_ARGS)
|
||||||
|
|
||||||
dist-win: node-check ## Package for Windows
|
dist-win: node-check ## Package for Windows
|
||||||
npm run dist:win
|
npm run dist:win $(BUILDER_ARGS)
|
||||||
|
|
||||||
dist-linux: node-check ## Package for Linux
|
dist-linux: node-check ## Package for Linux
|
||||||
npm run dist:linux
|
npm run dist:linux $(BUILDER_ARGS)
|
||||||
|
|
||||||
publish: ## Upload the packages already in dist/ to the Gitea release
|
publish: ## Upload the packages already in dist/ to the Gitea release
|
||||||
@TAG=$(TAG) $(SCRIPTS)/release.sh
|
@TAG=$(TAG) $(SCRIPTS)/release.sh
|
||||||
@@ -75,8 +102,8 @@ publish: ## Upload the packages already in dist/ to the Gitea release
|
|||||||
# what this version produced.
|
# what this version produced.
|
||||||
release: clean dist publish ## Package for this machine and publish it
|
release: clean dist publish ## Package for this machine and publish it
|
||||||
|
|
||||||
clean: ## Remove the built packages
|
clean: ## Remove the compiled output and the built packages
|
||||||
rm -rf dist
|
rm -rf build dist
|
||||||
|
|
||||||
distclean: clean ## Remove the packages and the dependencies
|
distclean: clean ## Remove the packages and the dependencies
|
||||||
rm -rf node_modules
|
rm -rf node_modules
|
||||||
@@ -86,5 +113,9 @@ version: ## Show the versions involved
|
|||||||
@printf "node "; node --version 2>/dev/null || echo "missing"
|
@printf "node "; node --version 2>/dev/null || echo "missing"
|
||||||
@printf "npm "; npm --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 "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 "tea "; tea --version 2>/dev/null | head -1 || echo "missing — devarea: make tea"
|
||||||
@printf "python3 "; python3 --version 2>/dev/null || echo "missing"
|
@printf "python3 "; python3 --version 2>/dev/null || echo "missing"
|
||||||
|
@printf "registry "; python3 -c 'import json; print(json.load(open("package.json"))["warpEngine"]["registryUrl"])'
|
||||||
|
@if [ -n "$(STORES_API)" ]; then printf " build override: %s\n" "$(STORES_API)"; fi
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# warp-engine-desktop-gui — a window for the desktop store
|
# warp-engine-client — the WarpEngine Client app
|
||||||
|
|
||||||
A graphical client for
|
The graphical client for a WarpEngine store — the app is called **WarpEngine
|
||||||
[`warp-engine-desktop-store`](https://git.teletypegames.org/stores/warp-engine-desktop-store):
|
Store** — driving
|
||||||
the catalog as a grid of cards, one click to install a title into your own
|
[`warp-engine-desktop-store`](https://git.teletypegames.org/stores/warp-engine-desktop-store)
|
||||||
application menu, one to play it, one to remove it. Linux, macOS and Windows.
|
underneath: the catalog as a grid of cards, one click to install a title into your
|
||||||
|
own application menu, one to play it, one to remove it. Linux, macOS and Windows.
|
||||||
|
|
||||||
The CLI stays the product; this is its front door. Every action here runs
|
The 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
|
`desktop_store.py`, so there is one catalog logic, one state file and one delete
|
||||||
@@ -24,10 +25,13 @@ catalog it serves and where its configuration lives.
|
|||||||
- Nothing else at runtime. No Node, no package manager, no admin rights: the
|
- Nothing else at runtime. No Node, no package manager, no admin rights: the
|
||||||
store installs under your own user account.
|
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
|
## Install
|
||||||
|
|
||||||
Grab the package for your machine from the
|
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
|
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.
|
offers to download one — that is the whole setup.
|
||||||
|
|
||||||
@@ -37,7 +41,7 @@ The build is ad-hoc signed but **not notarised**, so macOS asks before running a
|
|||||||
copy that came from a browser. The reliable way through:
|
copy that came from a browser. The reliable way through:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
xattr -dr com.apple.quarantine "/Applications/WarpEngine Store.app"
|
xattr -dr com.apple.quarantine "/Applications/WarpEngine Client.app"
|
||||||
```
|
```
|
||||||
|
|
||||||
If macOS offers *Open Anyway* under **System Settings ▸ Privacy & Security** after
|
If macOS offers *Open Anyway* under **System Settings ▸ Privacy & Security** after
|
||||||
@@ -60,36 +64,53 @@ and there is nothing to decide; several and the setup screen shows a picker.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
|
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org", "storeRepositoryUrl": null },
|
||||||
{
|
{
|
||||||
"name": "Teletype Games",
|
"name": "Some Other Store",
|
||||||
"catalogUrl": "https://teletypegames.org",
|
"catalogUrl": "https://games.example.org",
|
||||||
"storeRepositoryUrl": "https://git.teletypegames.org/stores/ttg-desktop-store"
|
"storeRepositoryUrl": "https://git.example.org/stores/other-desktop-store"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**A store needs no repository of its own.** A name and a catalog are enough: the
|
||||||
|
store engine's built-in defaults already cover the host-to-asset mapping, the
|
||||||
|
install modes, the platforms and the behaviour, so what is actually missing from
|
||||||
|
them is identity — a slug, a name and a catalog URL — and that is exactly what a
|
||||||
|
registry record carries. With `storeRepositoryUrl` null the client writes a
|
||||||
|
three-section config and the store installs.
|
||||||
|
|
||||||
From a record the client works out the rest:
|
From a record the client works out the rest:
|
||||||
|
|
||||||
- **`storeRepositoryUrl`** → the store's `config.json`, read from
|
- **the store id** — which names the store home and the folder games land in —
|
||||||
`…/raw/branch/master/config.json`. That file is the authority on how the store
|
comes from the repository name when there is one (`ttg-desktop-store` becomes
|
||||||
behaves: which platforms, which statuses, where things land.
|
`ttg`), otherwise from the catalog host (`teletypegames.org` becomes
|
||||||
|
`teletypegames`), otherwise from the display name. A `config.json` that sets its
|
||||||
|
own id keeps it.
|
||||||
- **`catalogUrl` and `name`** override the config's own `store.base_url` and
|
- **`catalogUrl` and `name`** override the config's own `store.base_url` and
|
||||||
`store.name`. The registry says which catalog this store is *for*, so it wins.
|
`store.name`. The registry says which catalog this store is *for*, so it wins.
|
||||||
- **the store id** — which names the store home and the folder games land in —
|
- **`storeRepositoryUrl`**, when given → the store's `config.json`, read from
|
||||||
comes from the repository name: `ttg-desktop-store` becomes `ttg`. A
|
`…/raw/branch/master/config.json`. That file stays the authority on how the store
|
||||||
`config.json` that sets its own id keeps it.
|
behaves: which platforms, which statuses, where things land. A repository
|
||||||
|
**without** a `config.json` is treated as no repository at all.
|
||||||
|
|
||||||
A repository **without** a `config.json` still works. The engine merges whatever
|
What the defaults produce, for a record with no repository: the games land in a
|
||||||
it is handed onto its own defaults, so the client writes a three-field config and
|
folder named after the store id, and released, archived **and demo** titles are
|
||||||
the store behaves like the default one pointed at that catalog.
|
listed — a catalog that publishes a demo means it to be played.
|
||||||
|
|
||||||
The registry address is the single thing about a particular site left in the
|
The registry address is the single thing about a particular site left in the client,
|
||||||
client, and `STORES_API` overrides it:
|
and it is decided in three places, most specific first:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
STORES_API=http://127.0.0.1:8731/stores npm start
|
STORES_API=http://127.0.0.1:8731/stores npm start # runtime: for trying something out
|
||||||
|
make dist STORES_API=https://games.example.org/api/stores # build: for shipping it
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The build variant is baked into the packaged app's own `package.json`
|
||||||
|
(`warpEngine.registryUrl`, written by `electron-builder --config.extraMetadata`), so a
|
||||||
|
client built for somebody else's catalog needs no source change and no environment on the
|
||||||
|
user's machine. With neither set, the address is ours.
|
||||||
|
|
||||||
Adding a store is therefore a database row on the site — see its ActiveAdmin
|
Adding a store is therefore a database row on the site — see its ActiveAdmin
|
||||||
panel — and not a release of this app.
|
panel — and not a release of this app.
|
||||||
|
|
||||||
@@ -104,8 +125,8 @@ Everything that is not a title lives in the **side menu** on the left, and the
|
|||||||
catalog into different folders show their folder instead of their id, because
|
catalog into different folders show their folder instead of their id, because
|
||||||
the id would not tell them apart. **Add a store…** brings up the registry
|
the id would not tell them apart. **Add a store…** brings up the registry
|
||||||
picker, the same one the first run offers.
|
picker, the same one the first run offers.
|
||||||
- **Actions** holds **Install all**, which fetches everything the catalog offers
|
- **Actions** holds **Refresh**, which re-reads the catalog. Titles are installed
|
||||||
for this machine, and **Refresh**, which re-reads the catalog.
|
one at a time from their own cards; there is no install-everything button.
|
||||||
- **Categories** narrows the grid, one category at a time, with the count next to
|
- **Categories** narrows the grid, one category at a time, with the count next to
|
||||||
each: *Everything*, *Installed*, *Updates*, *Not installed*, then a row per
|
each: *Everything*, *Installed*, *Updates*, *Not installed*, then a row per
|
||||||
**platform** (`godot`, `tic80`, `love`, …) and per **kind** (native or hosted).
|
**platform** (`godot`, `tic80`, `love`, …) and per **kind** (native or hosted).
|
||||||
@@ -113,6 +134,9 @@ Everything that is not a title lives in the **side menu** on the left, and the
|
|||||||
titles is not listed, and a category that disappears under you falls back to
|
titles is not listed, and a category that disappears under you falls back to
|
||||||
*Everything* rather than leaving an empty grid. There is no genre in a
|
*Everything* rather than leaving an empty grid. There is no genre in a
|
||||||
WarpEngine catalog, so these are the categories there are.
|
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**.
|
- **Language** follows the system and can be switched; **English and Hungarian**.
|
||||||
|
|
||||||
In the grid, a card's button is **Install**, **Update**, or **Play** / **Open**
|
In the grid, a card's button is **Install**, **Update**, or **Play** / **Open**
|
||||||
@@ -121,8 +145,13 @@ once it is there. **Remove** takes a title back out. Each card says whether it i
|
|||||||
build the catalog serves rather than packages, so its entry opens a page and needs
|
build the catalog serves rather than packages, so its entry opens a page and needs
|
||||||
the network.
|
the network.
|
||||||
|
|
||||||
The **Log** drawer at the bottom carries the store's own output verbatim, and next
|
**Everything in the catalog is listed, including what this machine cannot install.**
|
||||||
to it are buttons that open the two folders everything lands in.
|
Those cards are dimmed, carry an *unsupported platform* or *no build for this machine*
|
||||||
|
badge with the engine's own explanation under it, and have nothing to press. A store
|
||||||
|
that hides them leaves you wondering whether the catalog is small or your machine is
|
||||||
|
unusual; this way it says which. They have a category of their own — *Not for this
|
||||||
|
machine* — and they are left out of the native/hosted counts, because a title with no
|
||||||
|
build has no mode to be counted under.
|
||||||
|
|
||||||
Every card carries a band of box art the same height — the first letter of the
|
Every card carries a band of box art the same height — the first letter of the
|
||||||
title when the catalog has no image — so titles and buttons line up across a row.
|
title when the catalog has no image — so titles and buttons line up across a row.
|
||||||
@@ -145,19 +174,83 @@ names. `make` on its own lists everything.
|
|||||||
| Target | What it does |
|
| Target | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `make setup` | install the dependencies (checks the Node version first) |
|
| `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 both test suites** — the gate |
|
||||||
| `make start` | run the app against whatever store is installed |
|
| `make start` | run the app against whatever store is installed |
|
||||||
| `make smoke` | drive the store bridge with no window at all |
|
| `make smoke` | drive the store with no window and no Electron at all |
|
||||||
| `make uitest` | load the window once and report what rendered |
|
| `make uitest` | load the window once and report what rendered |
|
||||||
| `SELFTEST_SHOT=shot.png npm run uitest` | the same, and the window photographs itself into that file |
|
| `SELFTEST_SHOT=shot.png npm run uitest` | the same, and the window photographs itself into that file |
|
||||||
| `make test` | both checks |
|
| `make test` | both test suites |
|
||||||
| `make dist` | package for this machine (`dist-mac`, `dist-win`, `dist-linux` to pick) |
|
| `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 publish` | upload the packages already in `dist/` to the Gitea release |
|
||||||
| `make release` | **package and publish in one go** |
|
| `make release` | **package and publish in one go** |
|
||||||
| `make clean` | remove the built packages (`distclean` also drops `node_modules`) |
|
| `make clean` | remove `build/` and the packages (`distclean` also drops `node_modules`) |
|
||||||
| `make version` | the versions involved, including whether `tea` is there |
|
| `make version` | the versions involved, including whether `tea` is there |
|
||||||
|
|
||||||
The npm scripts still work directly (`npm start`, `npm run dist:mac`) — the
|
The npm scripts still work directly (`npm start`, `npm run dist:mac`) — the
|
||||||
Makefile adds no logic of its own beyond the release step.
|
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.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
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
|
### Publishing a release
|
||||||
|
|
||||||
@@ -165,15 +258,27 @@ Makefile adds no logic of its own beyond the release step.
|
|||||||
make release
|
make release
|
||||||
```
|
```
|
||||||
|
|
||||||
The tag comes from `package.json`, so `npm version patch` is the only place a
|
This is the **macOS half** of a release; the Linux and Windows packages come from the
|
||||||
version is set. The release is created if it is not there yet, and an attachment
|
pipeline when the tag is pushed (see above). The tag comes from `package.json`, so
|
||||||
whose name is already on it is **replaced** rather than refused — so a rebuild and
|
`npm version patch` is the only place a version is set. The release is created if it is
|
||||||
a second `make publish` lands rather than erroring.
|
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 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
|
release gets a one-line note. The repository is read from `origin`, so a fork
|
||||||
publishes to the 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 contain a space — `WarpEngine Client-1.5.0-arm64.dmg` — so the list of
|
||||||
|
files is passed one path per line rather than as one string; splitting it on
|
||||||
|
whitespace is what broke the first attempt at publishing 1.1.0.
|
||||||
|
|
||||||
It needs `tea` installed and logged in — the devarea repo has `make tea` for that.
|
It needs `tea` installed and logged in — the devarea repo has `make tea` for that.
|
||||||
Overridable: `TAG`, `REPO`, `TEA_LOGIN`, `NOTES`, `DIST`.
|
Overridable: `TAG`, `REPO`, `TEA_LOGIN`, `NOTES`, `DIST`.
|
||||||
|
|
||||||
@@ -200,33 +305,67 @@ SMOKE_HOME=/tmp/sandbox-root/ttg-desktop npm run smoke
|
|||||||
|
|
||||||
### How it is put together
|
### 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 |
|
| `src/shared/` | the IPC channel table, the bridge contract, the DTOs, the two message bundles |
|
||||||
| `preload.js` | the entire surface the renderer gets — no Node reaches it |
|
| `src/domain/` | models, ports and errors — no Electron, no Node, no Python |
|
||||||
| `lib/store.js` | finds the store and Python, runs the CLI, parses its JSON |
|
| `src/application/` | services and the domain → DTO mappers |
|
||||||
| `lib/bootstrap.js` | reads the registry, then downloads the engine, the core and a config |
|
| `src/infrastructure/` | the adapters: the Python CLI, HTTP, the filesystem, Electron itself |
|
||||||
| `lib/i18n.js` | the two string tables |
|
| `src/main/` | the window, the IPC controllers, the composition root, the self-test |
|
||||||
| `renderer/` | plain HTML, CSS and JS — no framework, no build step |
|
| `src/preload/` | the bridge, bundled into one file — a sandboxed preload cannot require modules |
|
||||||
| `Makefile` | the named sequences; no logic of its own beyond the release |
|
| `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/release.sh` | creates the Gitea release and replaces its attachments |
|
||||||
| `scripts/after-pack.js` | ad-hoc signs the macOS bundle during packaging |
|
| `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
|
Two properties are worth stating because they are what the layers buy:
|
||||||
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.
|
|
||||||
|
|
||||||
`lib/store.js` talks to the CLI through `--json`, which puts data on stdout and
|
- **The catalog can be driven without a window.** `make smoke` assembles the same
|
||||||
the human-readable log on stderr. That flag arrived with engine **1.1.0**, and the
|
services against the same ports with no Electron in the process at all.
|
||||||
client checks: an older store is met with an offer to refresh it rather than a
|
- **The window never receives a filesystem path.** A `GameDto` carries no paths; the
|
||||||
failed call.
|
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
|
`contextIsolation` is on, `nodeIntegration` off, `sandbox` on, and the page carries a
|
||||||
the same command shape, so a second entry is the whole change needed to drive it
|
CSP that allows only its own script and stylesheet plus images over HTTPS. Links open
|
||||||
too — that is why the indirection is there.
|
in the real browser; the window itself never navigates.
|
||||||
|
|
||||||
|
`PythonStoreCatalogGateway` is the only class that knows the store is a Python
|
||||||
|
program. It talks to the CLI through `--json`, which puts data on stdout and the
|
||||||
|
human-readable log on stderr. That flag arrived with engine **1.1.0**, and the client
|
||||||
|
checks: an older store is met with an offer to refresh it rather than a failed call.
|
||||||
|
|
||||||
|
`STORE_ENGINES` has one entry today. The RetroArch store has the same command shape,
|
||||||
|
so a second entry is the whole change needed to drive it too — that is why the table is
|
||||||
|
there.
|
||||||
|
|
||||||
## Verified, and not
|
## Verified, and not
|
||||||
|
|
||||||
|
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
|
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
|
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
|
renders the installed state, and `npm run uitest` passes with the grid rendered
|
||||||
|
|||||||
+36
-39
@@ -1,58 +1,55 @@
|
|||||||
# WarpEngine Store 1.2.0
|
# WarpEngine Client 1.5.0
|
||||||
|
|
||||||
**A side menu.** Everything that is not a title moved out of the bar into a menu on
|
**The app is called WarpEngine Client.** It was WarpEngine Store, which named the thing
|
||||||
the left that folds away with `☰`: the stores on this machine, the two actions, the
|
it opens rather than the thing you run — and the store is a catalog on a site, not a
|
||||||
categories and the language. Open or closed is remembered between runs.
|
window on your machine. The window title, the packages, the bundle and the menu entry all
|
||||||
|
say Client now; the repository already did. The store the window is driving is named in
|
||||||
|
the side menu, so the bar no longer repeats it as a badge.
|
||||||
|
|
||||||
**Categories.** The grid narrows to *Installed*, *Updates* or *Not installed*, to a
|
**Every title in the catalog is listed, including the ones this machine cannot install.**
|
||||||
platform (`godot`, `tic80`, `love`, …), or to native/hosted titles — one at a time,
|
A C64 cartridge on a desktop, or a game with no build for your operating system, used to
|
||||||
each with its count. The axes are built from what the catalog actually contains, so
|
be silently absent — leaving you to wonder whether the catalog is small or your machine
|
||||||
nothing empty is listed, and a category that disappears under you falls back to
|
is unusual. Those cards are now there, dimmed, with an *unsupported platform* or *no
|
||||||
*Everything* rather than leaving a blank grid.
|
build for this machine* badge and the engine's own sentence underneath, and with nothing
|
||||||
|
to press. They have a category of their own, *Not for this machine*, and they stay out of
|
||||||
|
the native/hosted counts: a title with no build has no mode to be counted under.
|
||||||
|
|
||||||
**Switching stores.** With more than one store installed, clicking another in the
|
This needs store engine **1.2.0** (desktop) on **warpstore 1.4.0**, which report what
|
||||||
menu opens it: the grid, the categories and the folders follow, and the client
|
they had to leave out and why. An older engine still works — its listing is simply all
|
||||||
reopens on the store last used. Two stores installed from the same catalog into
|
installable, as it was before.
|
||||||
different folders are told apart by their folder, since their id is identical.
|
|
||||||
|
|
||||||
While the store is working, the menu, the log drawer and the filters keep working —
|
**A build can be pointed at another site's registry.**
|
||||||
only what would start a second call is disabled.
|
|
||||||
|
|
||||||
**The grid was broken, and now is not.** Its rows split the window's height evenly
|
```sh
|
||||||
rather than following their content, so every card came out 94px tall: the box art
|
make dist STORES_API=https://games.example.org/api/stores
|
||||||
collapsed to nothing and the action buttons were clipped away below the fold. The
|
```
|
||||||
DOM was intact the whole time — ten cards, twenty buttons — which is why every
|
|
||||||
automated count passed. Cards now carry a band of art of one height, with the
|
|
||||||
title's first letter where the catalog has no image.
|
|
||||||
|
|
||||||
Unchanged from 1.1.0: which stores exist is the site's answer (`GET /api/stores`),
|
The address is written into the packaged app's own `package.json`, so a client built for
|
||||||
not something baked into this app, and `STORES_API` overrides that address.
|
somebody else's catalog needs no source change and nothing set on the user's machine. A
|
||||||
|
runtime `STORES_API` still wins, which is for trying something out rather than shipping.
|
||||||
|
|
||||||
|
### Also
|
||||||
|
|
||||||
|
The scrollbars are the window's own now: the platform's light track down the side menu of
|
||||||
|
a dark window looked like a mistake.
|
||||||
|
|
||||||
### Opening it on macOS
|
### Opening it on macOS
|
||||||
|
|
||||||
Ad-hoc signed, **not notarised**, so macOS asks first:
|
Ad-hoc signed, **not notarised**, so macOS asks first:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
xattr -dr com.apple.quarantine "/Applications/WarpEngine Store.app"
|
xattr -dr com.apple.quarantine "/Applications/WarpEngine Client.app"
|
||||||
```
|
```
|
||||||
|
|
||||||
*Open Anyway* under **System Settings ▸ Privacy & Security** works as well.
|
|
||||||
Nothing the store itself downloads is affected — Python fetches those, and Python
|
|
||||||
does not set the quarantine flag.
|
|
||||||
|
|
||||||
### What is attached
|
### What is attached
|
||||||
|
|
||||||
**macOS arm64 only**, the machine this was built and verified on. Windows and
|
The macOS package, built and verified on a Mac, plus the Linux (AppImage, deb) and
|
||||||
Linux packages need a build on those platforms (`make dist-win` / `dist-linux`).
|
Windows (installer, portable) packages the pipeline builds when the tag is pushed.
|
||||||
|
|
||||||
### Verified
|
### Verified
|
||||||
|
|
||||||
`SELFTEST_SHOT=shot.png npm run uitest` has the window photograph itself, which is
|
`make check` is clean. The store on this machine lists 13 titles — ten installable, three
|
||||||
how the collapsed rows were found and how the fix was confirmed — in English and in
|
C64 cartridges this store does not carry — and the window was photographed with all
|
||||||
Hungarian, with the menu open and closed.
|
thirteen on screen, the three dimmed and labelled. The engine change was measured through
|
||||||
|
the CLI as well, in both its human and its `--json` listing, and the other two store
|
||||||
`npm run uitest` loads the window and reports what rendered; with two stores in one
|
engines still get the two-value answer they ask for.
|
||||||
root — a sandbox copy beside the real install — it now also clicks the store that
|
|
||||||
is not open and checks that the bar, the grid and the categories follow. Both runs
|
|
||||||
pass, and `npm run smoke` drives the same bridge with no window at all: ten titles,
|
|
||||||
five native and five hosted, every installed one with something to launch.
|
|
||||||
|
|||||||
+303
@@ -0,0 +1,303 @@
|
|||||||
|
# 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, Node or Python
|
||||||
|
application ← services and DTO mappers. Orchestrates the domain through its ports
|
||||||
|
infrastructure ← adapters: the Python CLI, 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/
|
||||||
|
process/ Python: locating it, running it, reading its streams
|
||||||
|
repositories/ the port implementations
|
||||||
|
mappers/ engine JSON → domain
|
||||||
|
http/ HttpTextClient, HttpStatusError
|
||||||
|
json/ JsonRecord: reading data that came from elsewhere
|
||||||
|
config/ BuildConfiguration: what was decided when this was packaged
|
||||||
|
electron/ ApplicationEnvironment and GameLauncher adapters
|
||||||
|
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 Python CLI, the registry HTTP call and Electron's `shell` replaceable — by a stub
|
||||||
|
in a test, by a local endpoint in development, by a second engine later.
|
||||||
|
|
||||||
|
### 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** — another program or service with its own protocol:
|
||||||
|
`StoreCatalogGateway` (the engine).
|
||||||
|
|
||||||
|
### 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/mappers/Engine*Mapper` — engine JSON (snake_case) → domain model.
|
||||||
|
These are the only files that know the engine's field names.
|
||||||
|
- `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
|
||||||
|
(`PythonMissingError`, `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 — engine stdout, 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>` | `PythonStoreCatalogGateway`, `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` | `PythonMissingError` |
|
||||||
|
| 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; the CLI has
|
||||||
|
the same command shape, so `PythonStoreCatalogGateway` is unchanged.
|
||||||
|
|
||||||
|
**A new field from the engine.** `EngineGameMapper` reads it into the model, `GameDto`
|
||||||
|
and `GameDtoMapper` carry it across if the window needs it, and a view renders it.
|
||||||
|
|
||||||
|
**A new 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']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
Vendored
-159
@@ -1,159 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
// Setting up a 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.
|
|
||||||
//
|
|
||||||
// Which store, though, 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. A different site, or a
|
|
||||||
// second store on ours, needs no change here. The registry address is the one
|
|
||||||
// address the client does know, and even that is overridable.
|
|
||||||
|
|
||||||
const fs = require('node:fs')
|
|
||||||
const https = require('node:https')
|
|
||||||
const http = require('node:http')
|
|
||||||
const path = require('node:path')
|
|
||||||
|
|
||||||
// Where the engine itself comes from. Not part of the registry: this is the
|
|
||||||
// client's own machinery, the same for every store it can drive.
|
|
||||||
const FORGE = process.env.FORGE_BASE || 'https://git.teletypegames.org'
|
|
||||||
const ENGINE_SOURCES = {
|
|
||||||
'desktop_store.py': `${FORGE}/stores/warp-engine-desktop-store/raw/branch/master/desktop_store.py`,
|
|
||||||
'warpstore.py': `${FORGE}/engines/warpstore/raw/branch/master/warpstore.py`
|
|
||||||
}
|
|
||||||
|
|
||||||
// The registry. One address, and the only thing about a particular site left in
|
|
||||||
// the client.
|
|
||||||
const REGISTRY_URL = process.env.STORES_API || 'https://teletypegames.org/api/stores'
|
|
||||||
|
|
||||||
/** GET a URL as a string, following redirects — a moved repo answers 301. */
|
|
||||||
function fetchText (url, redirects = 5) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const client = url.startsWith('http://') ? http : https
|
|
||||||
const request = client.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}`))
|
|
||||||
return fetchText(new URL(res.headers.location, url).toString(), redirects - 1).then(resolve, reject)
|
|
||||||
}
|
|
||||||
if (res.statusCode !== 200) {
|
|
||||||
res.resume()
|
|
||||||
const error = new Error(`${url} answered ${res.statusCode}`)
|
|
||||||
error.statusCode = res.statusCode
|
|
||||||
return reject(error)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The stores this client can install, from the site's registry.
|
|
||||||
*
|
|
||||||
* Each record carries a name, the catalog it serves and the repository holding
|
|
||||||
* its config. Anything without those three is dropped rather than half-used.
|
|
||||||
*/
|
|
||||||
async function registry (url = REGISTRY_URL) {
|
|
||||||
const body = await fetchText(url)
|
|
||||||
const rows = JSON.parse(body)
|
|
||||||
if (!Array.isArray(rows)) throw new Error(`${url} did not answer with a list of stores`)
|
|
||||||
return rows
|
|
||||||
.map((row) => ({
|
|
||||||
name: String(row.name || '').trim(),
|
|
||||||
catalogUrl: String(row.catalogUrl || row.catalog_url || '').trim(),
|
|
||||||
storeRepositoryUrl: String(row.storeRepositoryUrl || row.store_repository_url || '').trim()
|
|
||||||
}))
|
|
||||||
.filter((row) => row.name && row.catalogUrl && row.storeRepositoryUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `…/stores/ttg-desktop-store` -> the raw config.json on its default branch. */
|
|
||||||
function configUrl (repositoryUrl, branch = 'master') {
|
|
||||||
return `${repositoryUrl.replace(/\/+$/, '')}/raw/branch/${branch}/config.json`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A store id from its repository name: `ttg-desktop-store` -> `ttg`.
|
|
||||||
*
|
|
||||||
* The id names the store home and the folder the games land in, so it has to be
|
|
||||||
* short and filesystem-safe. The repository name is the best source we have; the
|
|
||||||
* store's own config.json overrides it whenever it exists.
|
|
||||||
*/
|
|
||||||
function storeId (store) {
|
|
||||||
const last = store.storeRepositoryUrl.replace(/\/+$/, '').split('/').pop() || ''
|
|
||||||
const base = last.replace(/-(desktop-)?store$/, '') || store.name
|
|
||||||
return base.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'store'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The store's configuration.
|
|
||||||
*
|
|
||||||
* Its repository is the authority on how the store behaves — which platforms,
|
|
||||||
* which statuses, where things land. A repository without a config.json still
|
|
||||||
* works: the engine merges whatever it is given onto its own defaults, so a
|
|
||||||
* three-field config is a complete one.
|
|
||||||
*/
|
|
||||||
async function storeConfig (store, { onLog = () => {} } = {}) {
|
|
||||||
const id = storeId(store)
|
|
||||||
let config = null
|
|
||||||
try {
|
|
||||||
onLog(`reading the store config from ${store.storeRepositoryUrl}`)
|
|
||||||
config = JSON.parse(await fetchText(configUrl(store.storeRepositoryUrl)))
|
|
||||||
} catch (err) {
|
|
||||||
if (err.statusCode !== 404) throw err
|
|
||||||
onLog('no config.json in the store repository — using the engine defaults')
|
|
||||||
config = { paths: { subfolder: id }, catalog: { statuses: ['released', 'archived', 'demo'] } }
|
|
||||||
}
|
|
||||||
// The registry is the authority on identity and on which catalog to read, so
|
|
||||||
// those two win over whatever the file says.
|
|
||||||
config.store = { ...(config.store || {}) }
|
|
||||||
config.store.id = config.store.id || id
|
|
||||||
config.store.name = store.name
|
|
||||||
config.store.base_url = store.catalogUrl
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Where this store's home goes. */
|
|
||||||
function homeFor (store, root) {
|
|
||||||
return path.join(root, `${storeId(store)}-desktop`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install 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.
|
|
||||||
*/
|
|
||||||
async function install (home, store, { onLog = () => {} } = {}) {
|
|
||||||
if (!store) throw new Error('no store was chosen')
|
|
||||||
fs.mkdirSync(home, { recursive: true })
|
|
||||||
|
|
||||||
for (const [file, url] of Object.entries(ENGINE_SOURCES)) {
|
|
||||||
onLog(`downloading ${file}`)
|
|
||||||
const body = await fetchText(url)
|
|
||||||
if (!body.startsWith('#!/usr/bin/env python3')) {
|
|
||||||
throw new Error(`${file} does not look like the store engine — refusing to install it`)
|
|
||||||
}
|
|
||||||
fs.writeFileSync(path.join(home, file), body, { mode: 0o755 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const configPath = path.join(home, 'config.json')
|
|
||||||
const config = await storeConfig(store, { onLog })
|
|
||||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
|
||||||
|
|
||||||
onLog(`${store.name} is set up in ${home}`)
|
|
||||||
return { home, config: configPath, script: path.join(home, 'desktop_store.py'), id: config.store.id }
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
ENGINE_SOURCES, FORGE, REGISTRY_URL,
|
|
||||||
configUrl, fetchText, homeFor, install, registry, storeConfig, storeId
|
|
||||||
}
|
|
||||||
-130
@@ -1,130 +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',
|
|
||||||
menu: 'Menu',
|
|
||||||
stores: 'Stores',
|
|
||||||
addStore: 'Add a store…',
|
|
||||||
switchFailed: 'That store could not be opened',
|
|
||||||
actions: 'Actions',
|
|
||||||
categories: 'Categories',
|
|
||||||
catAll: 'Everything',
|
|
||||||
catInstalled: 'Installed',
|
|
||||||
catUpdates: 'Updates',
|
|
||||||
catAvailable: 'Not installed',
|
|
||||||
catPlatform: 'Platform',
|
|
||||||
catMode: 'Kind',
|
|
||||||
language: 'Language',
|
|
||||||
noGames: 'No installable titles in the catalog.',
|
|
||||||
noMatch: 'Nothing in this category.',
|
|
||||||
setupTitle: 'Set up a store',
|
|
||||||
setupBody: 'No store on this machine yet. Pick one and it will be downloaded — the same files the shell installer would place, in the same folder.',
|
|
||||||
setupAction: 'Download the store',
|
|
||||||
setupWorking: 'Setting up…',
|
|
||||||
setupChoose: 'Store',
|
|
||||||
registryFailed: 'The list of stores could not be fetched',
|
|
||||||
registryEmpty: 'The list of stores came back empty. Nothing to install from yet.',
|
|
||||||
registryRetry: 'Try again',
|
|
||||||
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ó',
|
|
||||||
menu: 'Menü',
|
|
||||||
stores: 'Store-ok',
|
|
||||||
addStore: 'Store hozzáadása…',
|
|
||||||
switchFailed: 'Ez a store nem nyitható meg',
|
|
||||||
actions: 'Műveletek',
|
|
||||||
categories: 'Kategóriák',
|
|
||||||
catAll: 'Minden',
|
|
||||||
catInstalled: 'Telepítve',
|
|
||||||
catUpdates: 'Frissítés',
|
|
||||||
catAvailable: 'Nincs telepítve',
|
|
||||||
catPlatform: 'Platform',
|
|
||||||
catMode: 'Fajta',
|
|
||||||
language: 'Nyelv',
|
|
||||||
noGames: 'Nincs telepíthető cím a katalógusban.',
|
|
||||||
noMatch: 'Ebben a kategóriában nincs semmi.',
|
|
||||||
setupTitle: 'Store beállítása',
|
|
||||||
setupBody: 'Ezen a gépen még nincs store. Válassz egyet, és letöltöm — ugyanazokat a fájlokat, ugyanabba a könyvtárba, ahová a shell-telepítő tenné.',
|
|
||||||
setupAction: 'Store letöltése',
|
|
||||||
setupWorking: 'Beállítás…',
|
|
||||||
setupChoose: 'Store',
|
|
||||||
registryFailed: 'A store-ok listája nem érhető el',
|
|
||||||
registryEmpty: 'A store-ok listája üresen jött vissza. Egyelőre nincs miből telepíteni.',
|
|
||||||
registryRetry: 'Újra',
|
|
||||||
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) }
|
|
||||||
-258
@@ -1,258 +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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The store to drive: the one asked for by home if it is still there, otherwise
|
|
||||||
* the first one found. The client remembers the choice, so a machine with two
|
|
||||||
* stores reopens on the one last used rather than on whichever sorts first.
|
|
||||||
*/
|
|
||||||
function findStore (preferredHome) {
|
|
||||||
const stores = findStores()
|
|
||||||
if (preferredHome) {
|
|
||||||
const wanted = stores.find((store) => store.home === preferredHome)
|
|
||||||
if (wanted) return wanted
|
|
||||||
}
|
|
||||||
return stores[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The store's own name, from the config the installer wrote. Worth reading here
|
|
||||||
* rather than waiting for `paths`: the switcher lists every store on the machine,
|
|
||||||
* and starting a Python process per entry to learn its name would be absurd.
|
|
||||||
*/
|
|
||||||
function storeName (store) {
|
|
||||||
try {
|
|
||||||
const config = JSON.parse(fs.readFileSync(store.config, 'utf8'))
|
|
||||||
return (config.store && config.store.name) || store.id
|
|
||||||
} catch {
|
|
||||||
return store.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An installed store as the window needs it. */
|
|
||||||
function describe (store) {
|
|
||||||
return store && { id: store.id, home: store.home, engine: store.engine, name: storeName(store) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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, describe, engineVersion,
|
|
||||||
findPython, findStore, findStores, list, parseVersion, paths, purge, remove, run,
|
|
||||||
storeName, storeRoots, sync
|
|
||||||
}
|
|
||||||
@@ -1,357 +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')
|
|
||||||
|
|
||||||
// A test run must never be swallowed by a copy the user already has open: it gets
|
|
||||||
// its own user-data directory and skips the single-instance lock. Without this the
|
|
||||||
// second process exits silently with status 0, which reads as a passing test.
|
|
||||||
if (SELFTEST) {
|
|
||||||
app.setPath('userData', path.join(app.getPath('temp'), 'warpstore-gui-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,
|
|
||||||
gateChoices: [...document.getElementById('gate-select').options].map((o) => o.text),
|
|
||||||
gateAction: document.getElementById('gate-action').textContent,
|
|
||||||
appName: document.getElementById('app-name').textContent,
|
|
||||||
storeId: document.getElementById('store-id').textContent,
|
|
||||||
navOpen: !document.body.classList.contains('nav-closed'),
|
|
||||||
stores: [...document.querySelectorAll('#store-list .store-row')].map((n) => n.textContent),
|
|
||||||
categories: [...document.querySelectorAll('#cats .cat')].map((n) => n.textContent),
|
|
||||||
activeCategory: (document.querySelector('#cats .cat.is-active') || {}).textContent || null,
|
|
||||||
paths: document.getElementById('log-paths').textContent.slice(0, 120),
|
|
||||||
logLines: document.querySelectorAll('.log-line').length,
|
|
||||||
locales: [...document.getElementById('locale').options].map((o) => o.value)
|
|
||||||
}))()`)
|
|
||||||
console.log(JSON.stringify(result, null, 2))
|
|
||||||
|
|
||||||
// With two stores on the machine the switcher is the thing most likely to be
|
|
||||||
// broken without anyone noticing, so the test uses it: click the store that is
|
|
||||||
// not open and see whether the window follows. Skipped when there is only one,
|
|
||||||
// which is the normal case — a single store cannot be switched away from.
|
|
||||||
let switched = null
|
|
||||||
if (result.stores.length > 1) {
|
|
||||||
switched = await win.webContents.executeJavaScript(`(async () => {
|
|
||||||
const other = [...document.querySelectorAll('#store-list .store-row')]
|
|
||||||
.find((row) => !row.classList.contains('is-active'))
|
|
||||||
other.click()
|
|
||||||
await new Promise((done) => setTimeout(done, 8000))
|
|
||||||
return {
|
|
||||||
storeId: document.getElementById('store-id').textContent,
|
|
||||||
active: (document.querySelector('#store-list .store-row.is-active') || {}).textContent || null,
|
|
||||||
cards: document.querySelectorAll('.card').length,
|
|
||||||
categories: document.querySelectorAll('#cats .cat').length
|
|
||||||
}
|
|
||||||
})()`)
|
|
||||||
console.log(`switched: ${JSON.stringify(switched)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A layout mistake does not show up in the DOM counts above, so on request the
|
|
||||||
// window photographs itself — the terminal cannot screenshot it from outside.
|
|
||||||
if (process.env.SELFTEST_SHOT) {
|
|
||||||
// capturePage hands back the last painted frame, so a window that is behind
|
|
||||||
// others — or still loading box art — photographs as a half-drawn page. Focus
|
|
||||||
// it, wait for the images, then let one frame go by.
|
|
||||||
win.show()
|
|
||||||
win.focus()
|
|
||||||
await win.webContents.executeJavaScript(`(async () => {
|
|
||||||
await Promise.all([...document.images].map((img) => img.complete
|
|
||||||
? null
|
|
||||||
: new Promise((done) => { img.onload = done; img.onerror = done })))
|
|
||||||
await new Promise((done) => requestAnimationFrame(() => setTimeout(done, 400)))
|
|
||||||
return document.images.length
|
|
||||||
})()`)
|
|
||||||
const image = await win.webContents.capturePage()
|
|
||||||
fs.writeFileSync(process.env.SELFTEST_SHOT, image.toPNG())
|
|
||||||
console.log(`shot: ${process.env.SELFTEST_SHOT}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Either outcome is a pass: a grid when a store is installed — with the side
|
|
||||||
// menu populated, which is the part a blank render would silently lose — or the
|
|
||||||
// setup gate with something to choose from when there is none.
|
|
||||||
const good = result.locales.length > 1 && (
|
|
||||||
(result.cards > 0 && !result.gateVisible &&
|
|
||||||
result.stores.length > 0 && result.categories.length > 0 && result.activeCategory) ||
|
|
||||||
(result.gateVisible && result.gateChoices.length > 0 && result.gateAction))
|
|
||||||
const switchGood = switched === null || (
|
|
||||||
switched.storeId && switched.storeId !== result.storeId &&
|
|
||||||
switched.cards > 0 && switched.categories > 0)
|
|
||||||
console.log(good && switchGood ? 'SELFTEST OK' : 'SELFTEST FAILED')
|
|
||||||
app.exit(good && switchGood ? 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()
|
|
||||||
// Every store on the machine, because the window offers a switch between them —
|
|
||||||
// and the remembered one wins, so reopening lands where the user left off.
|
|
||||||
const stores = store.findStores()
|
|
||||||
current = store.findStore(prefs.store)
|
|
||||||
// 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()),
|
|
||||||
nav: prefs.nav !== false,
|
|
||||||
python: python ? python.version : null,
|
|
||||||
store: store.describe(current),
|
|
||||||
stores: stores.map(store.describe),
|
|
||||||
engine: engine ? { text: engine.text, ok: engine.ok } : null,
|
|
||||||
minEngine: store.MIN_ENGINE.join('.'),
|
|
||||||
registryUrl: bootstrap.REGISTRY_URL,
|
|
||||||
storeRoot: store.storeRoots()[0],
|
|
||||||
version: app.getVersion()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// The side menu's open/closed state is worth keeping between runs; it is the one
|
|
||||||
// preference the window sets that is not a language.
|
|
||||||
ipcMain.handle('app:setNav', (_event, open) => {
|
|
||||||
const prefs = loadPrefs()
|
|
||||||
prefs.nav = Boolean(open)
|
|
||||||
savePrefs(prefs)
|
|
||||||
return prefs.nav
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Switch to another installed store.
|
|
||||||
*
|
|
||||||
* The choice is remembered, and the engine is checked here rather than in the
|
|
||||||
* window: two stores on one machine can be at different versions, and the one
|
|
||||||
* being switched to may be the older one.
|
|
||||||
*/
|
|
||||||
ipcMain.handle('store:use', (_event, home) => {
|
|
||||||
const wanted = store.findStores().find((candidate) => candidate.home === home)
|
|
||||||
if (!wanted) throw new Error('that store is no longer on this machine')
|
|
||||||
current = wanted
|
|
||||||
const prefs = loadPrefs()
|
|
||||||
prefs.store = wanted.home
|
|
||||||
savePrefs(prefs)
|
|
||||||
const engine = store.engineVersion(current)
|
|
||||||
return {
|
|
||||||
store: store.describe(current),
|
|
||||||
engine: engine ? { text: engine.text, ok: engine.ok } : null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
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())))
|
|
||||||
|
|
||||||
// The stores this client can install, from the site's registry rather than from
|
|
||||||
// anything baked in here. A separate call because it needs the network: the first
|
|
||||||
// window paints without waiting for it.
|
|
||||||
ipcMain.handle('store:registry', async () => {
|
|
||||||
try {
|
|
||||||
return { stores: await bootstrap.registry() }
|
|
||||||
} catch (err) {
|
|
||||||
return { stores: [], error: err.message, url: bootstrap.REGISTRY_URL }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('store:bootstrap', (_event, chosen) => guarded(async () => {
|
|
||||||
if (!chosen) throw new Error('no store was chosen')
|
|
||||||
const home = bootstrap.homeFor(chosen, store.storeRoots()[0])
|
|
||||||
const result = await bootstrap.install(home, chosen, { onLog: (line) => send('store:log', line) })
|
|
||||||
current = { engine: 'desktop', ...result }
|
|
||||||
const prefs = loadPrefs()
|
|
||||||
prefs.store = current.home
|
|
||||||
savePrefs(prefs)
|
|
||||||
return { ...store.describe(current), name: chosen.name }
|
|
||||||
}))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 (!SELFTEST && !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))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Generated
+1464
-13
File diff suppressed because it is too large
Load Diff
+31
-20
@@ -1,36 +1,43 @@
|
|||||||
{
|
{
|
||||||
"name": "warp-engine-desktop-gui",
|
"name": "warp-engine-client",
|
||||||
"productName": "WarpEngine Store",
|
"productName": "WarpEngine Client",
|
||||||
"version": "1.2.0",
|
"version": "1.5.0",
|
||||||
"description": "Graphical client for a WarpEngine desktop store: install the catalog into your own application menu.",
|
"description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Teletype Games <games@teletype.hu>",
|
"author": "Teletype Games <games@teletype.hu>",
|
||||||
"homepage": "https://git.teletypegames.org/stores/warp-engine-desktop-gui",
|
"homepage": "https://git.teletypegames.org/stores/warp-engine-client",
|
||||||
"main": "main.js",
|
"main": "build/main/main.js",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"build": "tsc -p tsconfig.build.json && node scripts/build-assets.mjs",
|
||||||
"smoke": "node scripts/smoke.js",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"dist": "electron-builder",
|
"lint": "eslint .",
|
||||||
"dist:mac": "electron-builder --mac",
|
"lint:fix": "eslint . --fix",
|
||||||
"dist:win": "electron-builder --win",
|
"start": "npm run build && electron .",
|
||||||
"dist:linux": "electron-builder --linux",
|
"smoke": "npm run build && node build/scripts/SmokeTest.js",
|
||||||
"uitest": "electron . --selftest"
|
"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"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.2.0",
|
||||||
"electron": "^43.4.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": {
|
"build": {
|
||||||
"appId": "org.teletypegames.warpstore.gui",
|
"appId": "org.teletypegames.warpstore.gui",
|
||||||
"productName": "WarpEngine Store",
|
"productName": "WarpEngine Client",
|
||||||
"files": [
|
"files": [
|
||||||
"main.js",
|
"build/**/*",
|
||||||
"preload.js",
|
"package.json"
|
||||||
"lib/**/*",
|
|
||||||
"renderer/**/*"
|
|
||||||
],
|
],
|
||||||
"mac": {
|
"mac": {
|
||||||
"category": "public.app-category.games",
|
"category": "public.app-category.games",
|
||||||
@@ -55,6 +62,10 @@
|
|||||||
"afterPack": "scripts/after-pack.js"
|
"afterPack": "scripts/after-pack.js"
|
||||||
},
|
},
|
||||||
"allowScripts": {
|
"allowScripts": {
|
||||||
"electron@43.4.0": true
|
"electron@43.4.0": true,
|
||||||
|
"esbuild@0.28.2": true
|
||||||
|
},
|
||||||
|
"warpEngine": {
|
||||||
|
"registryUrl": "https://teletypegames.org/api/stores"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-29
@@ -1,29 +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),
|
|
||||||
setNav: (open) => ipcRenderer.invoke('app:setNav', open),
|
|
||||||
|
|
||||||
list: () => ipcRenderer.invoke('store:list'),
|
|
||||||
paths: () => ipcRenderer.invoke('store:paths'),
|
|
||||||
sync: (names) => ipcRenderer.invoke('store:sync', names),
|
|
||||||
remove: (name) => ipcRenderer.invoke('store:remove', name),
|
|
||||||
use: (home) => ipcRenderer.invoke('store:use', home),
|
|
||||||
registry: () => ipcRenderer.invoke('store:registry'),
|
|
||||||
bootstrap: (store) => ipcRenderer.invoke('store:bootstrap', store),
|
|
||||||
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))
|
|
||||||
})
|
|
||||||
-555
@@ -1,555 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
// The whole renderer. No framework and no build step: a side menu on the left
|
|
||||||
// decides what is shown, a grid of cards on the right shows it, 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 state = null // what the main process knows: stores, python, engine
|
|
||||||
let games = []
|
|
||||||
let paths = null
|
|
||||||
let busy = false
|
|
||||||
let plan = null // { total, done } while a sync is running
|
|
||||||
|
|
||||||
// What the grid is narrowed down to. One category at a time on purpose: a matrix
|
|
||||||
// of filters would need explaining, and a catalog of this size does not earn it.
|
|
||||||
let filter = { kind: 'group', value: 'all' }
|
|
||||||
|
|
||||||
// --- 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// While the CLI runs, anything that would start a second call is disabled. The
|
|
||||||
// menu toggle, the log drawer and the category filters are not among them: they
|
|
||||||
// only change what is on screen.
|
|
||||||
function setBusy (value) {
|
|
||||||
busy = value
|
|
||||||
for (const node of document.querySelectorAll('button')) {
|
|
||||||
if (node.id === 'log-toggle' || node.id === 'nav-toggle') continue
|
|
||||||
if (node.classList.contains('cat')) 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 side menu --------------------------------------------------------
|
|
||||||
|
|
||||||
function setNav (open) {
|
|
||||||
document.body.classList.toggle('nav-closed', !open)
|
|
||||||
el('nav-toggle').setAttribute('aria-expanded', String(open))
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStores () {
|
|
||||||
const box = el('store-list')
|
|
||||||
const stores = (state && state.stores) || []
|
|
||||||
const active = state && state.store ? state.store.home : null
|
|
||||||
// Two stores can carry the same id in different roots — the same catalog
|
|
||||||
// installed twice. Then the id says nothing and the folder is what tells them
|
|
||||||
// apart, so that is what the row shows.
|
|
||||||
const ambiguous = new Set(stores
|
|
||||||
.filter((store, index) => stores.findIndex((other) => other.id === store.id) !== index)
|
|
||||||
.map((store) => store.id))
|
|
||||||
box.replaceChildren(...stores.map((store) => {
|
|
||||||
const row = document.createElement('button')
|
|
||||||
row.className = 'store-row'
|
|
||||||
if (store.home === active) row.classList.add('is-active')
|
|
||||||
const name = document.createElement('span')
|
|
||||||
name.className = 'store-row-name'
|
|
||||||
text(name, store.name)
|
|
||||||
row.appendChild(name)
|
|
||||||
const id = document.createElement('span')
|
|
||||||
id.className = 'store-row-id'
|
|
||||||
text(id, ambiguous.has(store.id) ? store.home : store.id)
|
|
||||||
row.appendChild(id)
|
|
||||||
row.title = store.home
|
|
||||||
row.addEventListener('click', () => {
|
|
||||||
if (store.home !== active) switchStore(store.home)
|
|
||||||
})
|
|
||||||
return row
|
|
||||||
}))
|
|
||||||
el('add-store').hidden = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The categories, built from what the catalog actually contains.
|
|
||||||
*
|
|
||||||
* There is no genre in a WarpEngine catalog, so the useful axes are the state of
|
|
||||||
* a title on this machine, the platform it was built with, and whether it runs
|
|
||||||
* here or in a browser. Empty axes are left out rather than shown as zeroes.
|
|
||||||
*/
|
|
||||||
function categories () {
|
|
||||||
const count = (fn) => games.filter(fn).length
|
|
||||||
const sections = [{
|
|
||||||
group: null,
|
|
||||||
items: [
|
|
||||||
{ kind: 'group', value: 'all', label: T.catAll, count: games.length },
|
|
||||||
{ kind: 'group', value: 'installed', label: T.catInstalled, count: count((g) => g.installed) },
|
|
||||||
{ kind: 'group', value: 'updates', label: T.catUpdates, count: count((g) => g.update_available) },
|
|
||||||
{ kind: 'group', value: 'available', label: T.catAvailable, count: count((g) => !g.installed) }
|
|
||||||
].filter((item) => item.value === 'all' || item.count > 0)
|
|
||||||
}]
|
|
||||||
|
|
||||||
const platforms = [...new Set(games.map((g) => g.platform).filter(Boolean))].sort()
|
|
||||||
if (platforms.length > 1) {
|
|
||||||
sections.push({
|
|
||||||
group: T.catPlatform,
|
|
||||||
items: platforms.map((platform) => ({
|
|
||||||
kind: 'platform', value: platform, label: platform, count: count((g) => g.platform === platform)
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const modes = [...new Set(games.map((g) => g.mode).filter(Boolean))]
|
|
||||||
if (modes.length > 1) {
|
|
||||||
sections.push({
|
|
||||||
group: T.catMode,
|
|
||||||
items: modes.map((mode) => ({
|
|
||||||
kind: 'mode', value: mode, label: mode === 'web' ? T.hosted : T.native, count: count((g) => g.mode === mode)
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return sections
|
|
||||||
}
|
|
||||||
|
|
||||||
function matches (game) {
|
|
||||||
if (filter.kind === 'platform') return game.platform === filter.value
|
|
||||||
if (filter.kind === 'mode') return game.mode === filter.value
|
|
||||||
if (filter.value === 'installed') return Boolean(game.installed)
|
|
||||||
if (filter.value === 'updates') return Boolean(game.update_available)
|
|
||||||
if (filter.value === 'available') return !game.installed
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCats () {
|
|
||||||
const box = el('cats')
|
|
||||||
const sections = categories()
|
|
||||||
|
|
||||||
// A category can vanish under us — the last title of a platform is removed, or
|
|
||||||
// an update is applied — and a filter matching nothing would look like an empty
|
|
||||||
// catalog. Falling back to everything is the honest answer.
|
|
||||||
const known = sections.flatMap((section) => section.items)
|
|
||||||
.some((item) => item.kind === filter.kind && item.value === filter.value)
|
|
||||||
if (!known) filter = { kind: 'group', value: 'all' }
|
|
||||||
|
|
||||||
const nodes = []
|
|
||||||
for (const section of sections) {
|
|
||||||
if (section.group) {
|
|
||||||
const head = document.createElement('div')
|
|
||||||
head.className = 'cat-group'
|
|
||||||
text(head, section.group)
|
|
||||||
nodes.push(head)
|
|
||||||
}
|
|
||||||
for (const item of section.items) {
|
|
||||||
const button = document.createElement('button')
|
|
||||||
button.className = 'cat'
|
|
||||||
if (item.kind === filter.kind && item.value === filter.value) button.classList.add('is-active')
|
|
||||||
const label = document.createElement('span')
|
|
||||||
label.className = 'cat-label'
|
|
||||||
text(label, item.label)
|
|
||||||
button.appendChild(label)
|
|
||||||
const count = document.createElement('span')
|
|
||||||
count.className = 'cat-count'
|
|
||||||
text(count, item.count)
|
|
||||||
button.appendChild(count)
|
|
||||||
button.addEventListener('click', () => {
|
|
||||||
filter = { kind: item.kind, value: item.value }
|
|
||||||
renderCats()
|
|
||||||
renderGrid()
|
|
||||||
})
|
|
||||||
nodes.push(button)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
box.replaceChildren(...nodes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 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 {
|
|
||||||
// No box art in the catalog: the first letter, on the same band an image
|
|
||||||
// would fill, so a row of cards stays aligned either way.
|
|
||||||
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 shown = games.filter(matches)
|
|
||||||
const grid = el('grid')
|
|
||||||
grid.replaceChildren(...shown.map(card))
|
|
||||||
grid.hidden = shown.length === 0
|
|
||||||
grid.scrollTop = 0
|
|
||||||
const empty = el('empty')
|
|
||||||
empty.hidden = shown.length !== 0
|
|
||||||
text(empty, games.length === 0 ? T.noGames : T.noMatch)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
renderCats()
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open another store that is already on this machine. */
|
|
||||||
async function switchStore (home) {
|
|
||||||
try {
|
|
||||||
const next = await api.use(home)
|
|
||||||
state.store = next.store
|
|
||||||
state.engine = next.engine
|
|
||||||
text(el('store-id'), next.store.id)
|
|
||||||
renderStores()
|
|
||||||
games = []
|
|
||||||
paths = null
|
|
||||||
filter = { kind: 'group', value: 'all' }
|
|
||||||
if (next.engine && !next.engine.ok) {
|
|
||||||
renderCats()
|
|
||||||
showOldEngineGate()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hideGate()
|
|
||||||
await refresh()
|
|
||||||
} catch (err) {
|
|
||||||
logLine(`${T.switchFailed}: ${err && err.message ? err.message : err}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pick up a store that appeared since the window opened. */
|
|
||||||
async function reloadState () {
|
|
||||||
state = await api.state()
|
|
||||||
text(el('store-id'), state.store ? state.store.id : '')
|
|
||||||
renderStores()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- gate: no python, no store yet, or an engine too old ------------------
|
|
||||||
|
|
||||||
function showGate (title, body, action, link, choices) {
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Only shown when the registry offers more than one store; with a single one
|
|
||||||
// there is nothing to decide.
|
|
||||||
const choice = el('gate-choice')
|
|
||||||
const select = el('gate-select')
|
|
||||||
choice.hidden = !choices || choices.length < 2
|
|
||||||
if (!choice.hidden) {
|
|
||||||
text(el('gate-choice-label'), T.setupChoose)
|
|
||||||
select.replaceChildren(...choices.map((store, index) => {
|
|
||||||
const option = document.createElement('option')
|
|
||||||
option.value = String(index)
|
|
||||||
option.textContent = store.name
|
|
||||||
return option
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const button = el('gate-action')
|
|
||||||
button.hidden = !action
|
|
||||||
if (action) {
|
|
||||||
text(button, action.label)
|
|
||||||
button.onclick = () => action.onClick(choices ? choices[Number(select.value) || 0] : undefined)
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
function showOldEngineGate () {
|
|
||||||
showGate(T.oldEngineTitle,
|
|
||||||
`${T.oldEngineBody}\n\n${state.engine.text} → ${state.minEngine}`,
|
|
||||||
{ label: T.oldEngineAction, onClick: offerStores })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setUpStore (chosen) {
|
|
||||||
showProgress(T.setupWorking)
|
|
||||||
try {
|
|
||||||
await api.bootstrap(chosen)
|
|
||||||
await reloadState()
|
|
||||||
hideGate()
|
|
||||||
await refresh()
|
|
||||||
} catch (err) {
|
|
||||||
logLine(String(err && err.message ? err.message : err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Which stores exist is the site's answer, not this client's: the registry is
|
|
||||||
// asked for it, and its records carry the catalog and the config repository.
|
|
||||||
async function offerStores () {
|
|
||||||
const result = await api.registry()
|
|
||||||
if (result.error) {
|
|
||||||
showGate(`${T.registryFailed}`, `${result.url}\n\n${result.error}`,
|
|
||||||
{ label: T.registryRetry, onClick: offerStores })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!result.stores.length) {
|
|
||||||
showGate(T.setupTitle, `${T.registryEmpty}\n\n${result.url}`, null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
showGate(T.setupTitle,
|
|
||||||
`${T.setupBody}\n\n${state.storeRoot}`,
|
|
||||||
{ label: T.setupAction, onClick: async (chosen) => { await setUpStore(chosen); await runSync([]) } },
|
|
||||||
null,
|
|
||||||
result.stores)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 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)
|
|
||||||
text(el('head-stores'), T.stores)
|
|
||||||
text(el('head-actions'), T.actions)
|
|
||||||
text(el('head-cats'), T.categories)
|
|
||||||
text(el('head-lang'), T.language)
|
|
||||||
text(el('add-store'), T.addStore)
|
|
||||||
el('nav-toggle').title = T.menu
|
|
||||||
el('nav-toggle').setAttribute('aria-label', T.menu)
|
|
||||||
renderPaths()
|
|
||||||
if (state) renderStores()
|
|
||||||
if (games.length) {
|
|
||||||
renderCats()
|
|
||||||
renderGrid()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function boot () {
|
|
||||||
state = await api.state()
|
|
||||||
applyStrings(state.strings)
|
|
||||||
setNav(state.nav !== false)
|
|
||||||
renderStores()
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.store) {
|
|
||||||
await offerStores()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.engine && !state.engine.ok) {
|
|
||||||
showOldEngineGate()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
text(el('store-id'), state.store.id)
|
|
||||||
hideGate()
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
el('sync-all').addEventListener('click', () => runSync([]))
|
|
||||||
el('refresh').addEventListener('click', () => refresh())
|
|
||||||
el('add-store').addEventListener('click', () => offerStores())
|
|
||||||
el('nav-toggle').addEventListener('click', () => {
|
|
||||||
const open = document.body.classList.contains('nav-closed')
|
|
||||||
setNav(open)
|
|
||||||
api.setNav(open)
|
|
||||||
})
|
|
||||||
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()
|
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
Executable
+114
@@ -0,0 +1,114 @@
|
|||||||
|
#!/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" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Package names contain spaces — "WarpEngine Client Setup 1.5.0.exe" does — so the list
|
||||||
|
# lives one path per line in a file and is read with `while IFS= read -r`. A single
|
||||||
|
# variable looped over with $list splits on the space and uploads nothing.
|
||||||
|
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"'
|
||||||
Executable
+35
@@ -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
|
||||||
+30
-13
@@ -52,7 +52,7 @@ REPO="${REPO:-$(git remote get-url origin 2>/dev/null |
|
|||||||
#
|
#
|
||||||
# - the version filter, because dist/ keeps whatever earlier builds left there
|
# - the version filter, because dist/ keeps whatever earlier builds left there
|
||||||
# and a release would quietly get the previous version's files attached;
|
# and a release would quietly get the previous version's files attached;
|
||||||
# - the spaces. "WarpEngine Store-1.1.0-arm64.dmg" has one, so the list lives one
|
# - the spaces. "WarpEngine Client-1.5.0-arm64.dmg" has one, so the list lives one
|
||||||
# path per line in a file and is read with `while IFS= read -r`. Holding it in
|
# path per line in a file and is read with `while IFS= read -r`. Holding it in
|
||||||
# a single variable and looping over $list splits it on the space.
|
# a single variable and looping over $list splits it on the space.
|
||||||
LIST="$(mktemp)"
|
LIST="$(mktemp)"
|
||||||
@@ -99,27 +99,44 @@ RELEASE_ID="$(release_id)"
|
|||||||
[ -n "$RELEASE_ID" ] || die "the release $TAG could not be created or found"
|
[ -n "$RELEASE_ID" ] || die "the release $TAG could not be created or found"
|
||||||
|
|
||||||
# --- the attachments -------------------------------------------------------
|
# --- the attachments -------------------------------------------------------
|
||||||
while IFS= read -r asset; do
|
# Anything already attached under this name, dropped: replacing rather than
|
||||||
[ -n "$asset" ] || continue
|
# refusing is what makes a rebuild-and-upload repeatable. Called before every
|
||||||
[ -f "$asset" ] || die "no such file: $asset"
|
# attempt, so a retry cannot leave two copies behind.
|
||||||
name="$(basename "$asset")"
|
drop_existing() {
|
||||||
|
ids="$(tea api "/repos/$REPO/releases/$RELEASE_ID/assets" | python3 -c "
|
||||||
# Replacing rather than refusing: a second run after a rebuild should land.
|
|
||||||
old="$(tea api "/repos/$REPO/releases/$RELEASE_ID/assets" | python3 -c "
|
|
||||||
import json, sys
|
import json, sys
|
||||||
name = sys.argv[1]
|
name = sys.argv[1]
|
||||||
for a in json.load(sys.stdin):
|
for a in json.load(sys.stdin):
|
||||||
if a['name'] == name:
|
if a['name'] == name:
|
||||||
print(a['id'])
|
print(a['id'])
|
||||||
" "$name")"
|
" "$1")"
|
||||||
for id in $old; do
|
for id in $ids; do
|
||||||
say "replacing $name"
|
say "replacing $1"
|
||||||
tea api -X DELETE "/repos/$REPO/releases/$RELEASE_ID/assets/$id" >/dev/null
|
tea api -X DELETE "/repos/$REPO/releases/$RELEASE_ID/assets/$id" >/dev/null
|
||||||
done
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
while IFS= read -r asset; do
|
||||||
|
[ -n "$asset" ] || continue
|
||||||
|
[ -f "$asset" ] || die "no such file: $asset"
|
||||||
|
name="$(basename "$asset")"
|
||||||
size="$(python3 -c "import os,sys; print(f'{os.path.getsize(sys.argv[1])/1e6:.0f} MB')" "$asset")"
|
size="$(python3 -c "import os,sys; print(f'{os.path.getsize(sys.argv[1])/1e6:.0f} MB')" "$asset")"
|
||||||
say "uploading $name ($size) — large packages take a few minutes"
|
|
||||||
tea releases assets create --login "$LOGIN" --repo "$REPO" "$TAG" "$asset" >/dev/null
|
# 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"
|
done < "$LIST"
|
||||||
|
|
||||||
say "done:"
|
say "done:"
|
||||||
|
|||||||
@@ -1,128 +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 bootstrap = require('../lib/bootstrap')
|
|
||||||
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`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The registry is what decides which stores exist, so it is checked before
|
|
||||||
// anything that depends on one being installed.
|
|
||||||
try {
|
|
||||||
const stores = await bootstrap.registry()
|
|
||||||
if (!stores.length) bad('registry', `${bootstrap.REGISTRY_URL} returned no stores`)
|
|
||||||
else {
|
|
||||||
ok('registry', `${stores.length} store(s) from ${bootstrap.REGISTRY_URL}`)
|
|
||||||
for (const store of stores) {
|
|
||||||
ok(` ${store.name}`, `${store.catalogUrl} · ${bootstrap.storeId(store)}`)
|
|
||||||
const url = bootstrap.configUrl(store.storeRepositoryUrl)
|
|
||||||
try {
|
|
||||||
const config = JSON.parse(await bootstrap.fetchText(url))
|
|
||||||
ok(' config.json', `${Object.keys(config).length} sections`)
|
|
||||||
} catch (err) {
|
|
||||||
// Not fatal: the engine merges onto its defaults, so a store without a
|
|
||||||
// config file still installs.
|
|
||||||
ok(' config.json', `absent (${err.statusCode || err.message}) — defaults would be used`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
bad('registry', `${bootstrap.REGISTRY_URL}: ${err.message}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
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,8 @@
|
|||||||
|
import type { EngineVersion } from '../../domain/models/EngineVersion'
|
||||||
|
import type { EngineVersionDto } from '../../shared/contracts/dto/EngineVersionDto'
|
||||||
|
|
||||||
|
export class EngineVersionDtoMapper {
|
||||||
|
public toDto (version: EngineVersion): EngineVersionDto {
|
||||||
|
return { text: version.text, supported: version.supported }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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.
|
||||||
|
*
|
||||||
|
* Two decisions live here rather than in the renderer: the box art is resolved
|
||||||
|
* against the catalog's base URL, and whether a title can be launched is answered
|
||||||
|
* here — so the window never receives a filesystem path it could be talked into
|
||||||
|
* opening.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,27 @@
|
|||||||
|
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,
|
||||||
|
storeRepositoryUrl: store.storeRepositoryUrl,
|
||||||
|
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,
|
||||||
|
storeRepositoryUrl: dto.storeRepositoryUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,54 @@
|
|||||||
|
import { MINIMUM_ENGINE_VERSION, formatVersion } from '../../domain/models/EngineVersion'
|
||||||
|
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
||||||
|
import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment'
|
||||||
|
import type { PythonRuntimeLocator } from '../../domain/ports/PythonRuntimeLocator'
|
||||||
|
import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto'
|
||||||
|
import { TranslationCatalog } from '../../shared/i18n/TranslationCatalog'
|
||||||
|
import { EngineVersionDtoMapper } from '../mappers/EngineVersionDtoMapper'
|
||||||
|
import { InstalledStoreDtoMapper } from '../mappers/InstalledStoreDtoMapper'
|
||||||
|
import type { PreferencesService } from './PreferencesService'
|
||||||
|
import type { StoreProvisioningService } from './StoreProvisioningService'
|
||||||
|
import type { StoreSelectionService } from './StoreSelectionService'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the window needs before it can paint anything, in one answer.
|
||||||
|
*
|
||||||
|
* One call rather than six, because the first frame should not be a sequence of
|
||||||
|
* round trips — and because the decisions the window makes from it (no Python, no
|
||||||
|
* store, an engine too old) all depend on each other.
|
||||||
|
*/
|
||||||
|
export class ApplicationStateService {
|
||||||
|
public constructor (
|
||||||
|
private readonly preferences: PreferencesService,
|
||||||
|
private readonly selection: StoreSelectionService,
|
||||||
|
private readonly provisioning: StoreProvisioningService,
|
||||||
|
private readonly pythonLocator: PythonRuntimeLocator,
|
||||||
|
private readonly environment: ApplicationEnvironment,
|
||||||
|
private readonly translations: TranslationCatalog = new TranslationCatalog(),
|
||||||
|
private readonly storeMapper: InstalledStoreDtoMapper = new InstalledStoreDtoMapper(),
|
||||||
|
private readonly engineMapper: EngineVersionDtoMapper = new EngineVersionDtoMapper()
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public readState (): AppStateDto {
|
||||||
|
const locale = this.preferences.readLocale()
|
||||||
|
const stores = this.selection.listStores()
|
||||||
|
const current: InstalledStore | null = this.selection.findCurrentStore()
|
||||||
|
const engine = current === null ? null : this.selection.findEngineVersion(current)
|
||||||
|
const runtime = this.pythonLocator.findRuntime()
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
locales: this.translations.locales,
|
||||||
|
messages: this.translations.readBundle(locale),
|
||||||
|
navigationOpen: this.preferences.readNavigationOpen(),
|
||||||
|
pythonVersion: runtime === null ? null : runtime.version,
|
||||||
|
currentStore: current === null ? null : this.storeMapper.toDto(current),
|
||||||
|
stores: this.storeMapper.toDtoList(stores),
|
||||||
|
engine: engine === null ? null : this.engineMapper.toDto(engine),
|
||||||
|
minimumEngineVersion: formatVersion(MINIMUM_ENGINE_VERSION),
|
||||||
|
registryUrl: this.provisioning.registryUrl,
|
||||||
|
defaultStoreRoot: this.selection.readDefaultStoreRoot(),
|
||||||
|
appVersion: this.environment.readVersion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,51 @@
|
|||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
private merge (changes: Preferences): void {
|
||||||
|
this.repository.write({ ...this.repository.read(), ...changes })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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 { 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
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public get registryUrl (): string {
|
||||||
|
return this.registry.sourceUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listAvailableStores (): Promise<readonly RegistryStore[]> {
|
||||||
|
return this.registry.listStores()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { StoreMissingError } from '../../domain/errors/StoreMissingError'
|
||||||
|
import type { EngineVersion } from '../../domain/models/EngineVersion'
|
||||||
|
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
||||||
|
import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository'
|
||||||
|
import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
|
||||||
|
import type { PreferencesService } from './PreferencesService'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 catalogGateway: StoreCatalogGateway,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The engine version of a store, checked per store rather than once: two stores
|
||||||
|
* on one machine can be at different versions, and the one being switched to may
|
||||||
|
* be the older one.
|
||||||
|
*/
|
||||||
|
public findEngineVersion (store: InstalledStore): EngineVersion | null {
|
||||||
|
return this.catalogGateway.readEngineVersion(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
public readDefaultStoreRoot (): string {
|
||||||
|
return this.stores.readRoots()[0] ?? ''
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 PythonMissingError extends DomainError {
|
||||||
|
public override readonly code: string = 'PYTHON_MISSING'
|
||||||
|
|
||||||
|
public constructor () {
|
||||||
|
super('python3 was not found on this machine')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,9 @@
|
|||||||
|
import type { Game } from './Game'
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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,34 @@
|
|||||||
|
/**
|
||||||
|
* The engine version this client found, and whether it can drive it.
|
||||||
|
*
|
||||||
|
* `--json` arrived with engine 1.1.0. An older engine is not broken, it simply
|
||||||
|
* cannot be driven from a window — and it will be met in the wild, because the CLI
|
||||||
|
* shipped before this client did.
|
||||||
|
*/
|
||||||
|
export interface EngineVersion {
|
||||||
|
readonly text: string
|
||||||
|
readonly numbers: readonly number[] | null
|
||||||
|
readonly supported: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MINIMUM_ENGINE_VERSION: readonly number[] = [1, 1, 0]
|
||||||
|
|
||||||
|
export function parseVersionNumbers (text: string): readonly number[] | null {
|
||||||
|
const match = /(\d+)\.(\d+)\.(\d+)/.exec(text)
|
||||||
|
return match ? match.slice(1, 4).map((part: string): number => Number(part)) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAtLeast (version: readonly number[] | null, minimum: readonly number[]): boolean {
|
||||||
|
if (version === null) return false
|
||||||
|
for (let index = 0; index < minimum.length; index += 1) {
|
||||||
|
const found = version[index] ?? 0
|
||||||
|
const needed = minimum[index] ?? 0
|
||||||
|
if (found > needed) return true
|
||||||
|
if (found < needed) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatVersion (numbers: readonly number[]): string {
|
||||||
|
return numbers.join('.')
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/** 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** A store engine installed on this machine, with everything needed to run it. */
|
||||||
|
export interface InstalledStore {
|
||||||
|
readonly id: string
|
||||||
|
readonly name: string
|
||||||
|
readonly home: string
|
||||||
|
readonly scriptPath: 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,6 @@
|
|||||||
|
/** The Python 3 this machine has, and how to invoke it. */
|
||||||
|
export interface PythonRuntime {
|
||||||
|
readonly command: string
|
||||||
|
readonly arguments: readonly string[]
|
||||||
|
readonly version: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* A store the site's registry offers.
|
||||||
|
*
|
||||||
|
* A name and a catalog are what make a store; the repository is optional. When
|
||||||
|
* there is one it stays the authority on how that store behaves — which platforms
|
||||||
|
* it offers, where things land — and when there is not, the engine's own defaults
|
||||||
|
* cover all of it and this record covers the identity. That is the whole reason a
|
||||||
|
* store needs no repository of its own.
|
||||||
|
*/
|
||||||
|
export interface RegistryStore {
|
||||||
|
readonly name: string
|
||||||
|
readonly catalogUrl: string
|
||||||
|
readonly storeRepositoryUrl: string | null
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* A store engine this client knows how to drive.
|
||||||
|
*
|
||||||
|
* There is one today. The table exists because the RetroArch store has the same
|
||||||
|
* command shape, so a second entry — not a second code path — is what adding it
|
||||||
|
* would take.
|
||||||
|
*/
|
||||||
|
export interface StoreEngine {
|
||||||
|
readonly id: string
|
||||||
|
readonly scriptFileName: string
|
||||||
|
/** The installer names a store home `<store id><homeSuffix>`. */
|
||||||
|
readonly homeSuffix: string
|
||||||
|
readonly launcherSuffix: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DESKTOP_STORE_ENGINE: StoreEngine = {
|
||||||
|
id: 'desktop',
|
||||||
|
scriptFileName: 'desktop_store.py',
|
||||||
|
homeSuffix: '-desktop',
|
||||||
|
launcherSuffix: '-desktop-store'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STORE_ENGINES: readonly StoreEngine[] = [DESKTOP_STORE_ENGINE]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { RegistryStore } from './RegistryStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A store id, from whatever the registry gave us.
|
||||||
|
*
|
||||||
|
* The id names the store home, the folder games land in and the launcher files, so
|
||||||
|
* it has to be short and filesystem-safe. Three sources, in order of how much they
|
||||||
|
* were meant to be a name:
|
||||||
|
*
|
||||||
|
* 1. the repository name — `ttg-desktop-store` becomes `ttg`;
|
||||||
|
* 2. the catalog host — `https://teletypegames.org` becomes `teletypegames`;
|
||||||
|
* 3. the display name, slugged, as a last resort.
|
||||||
|
*
|
||||||
|
* The store's own config.json overrides all of it whenever one exists.
|
||||||
|
*/
|
||||||
|
export function deriveStoreId (store: RegistryStore): string {
|
||||||
|
const fromRepository = store.storeRepositoryUrl === null
|
||||||
|
? ''
|
||||||
|
: (lastSegment(store.storeRepositoryUrl).replace(/-(desktop-)?store$/, ''))
|
||||||
|
return toSlug(fromRepository) || toSlug(readHostLabel(store.catalogUrl)) || toSlug(store.name) || 'store'
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastSegment (url: string): string {
|
||||||
|
return url.replace(/\/+$/, '').split('/').pop() ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `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,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,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,10 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { Preferences } from '../models/Preferences'
|
||||||
|
|
||||||
|
export interface PreferencesRepository {
|
||||||
|
read: () => Preferences
|
||||||
|
write: (preferences: Preferences) => void
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { PythonRuntime } from '../models/PythonRuntime'
|
||||||
|
|
||||||
|
export interface PythonRuntimeLocator {
|
||||||
|
findRuntime: () => PythonRuntime | null
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { CatalogListing } from '../models/CatalogListing'
|
||||||
|
import type { EngineProgressListener } from '../models/EngineProgress'
|
||||||
|
import type { EngineVersion } from '../models/EngineVersion'
|
||||||
|
import type { InstalledStore } from '../models/InstalledStore'
|
||||||
|
import type { StorePaths } from '../models/StorePaths'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The store engine, as an interface.
|
||||||
|
*
|
||||||
|
* Every catalog operation this client performs is one call on this port; the CLI
|
||||||
|
* behind it stays the product, and nothing above this line knows it is Python.
|
||||||
|
*/
|
||||||
|
export interface StoreCatalogGateway {
|
||||||
|
listGames: (store: InstalledStore, progress?: EngineProgressListener) => Promise<CatalogListing>
|
||||||
|
readPaths: (store: InstalledStore, progress?: EngineProgressListener) => Promise<StorePaths>
|
||||||
|
syncGames: (store: InstalledStore, names: readonly string[], progress?: EngineProgressListener) => Promise<void>
|
||||||
|
removeGame: (store: InstalledStore, name: string, progress?: EngineProgressListener) => Promise<void>
|
||||||
|
readEngineVersion: (store: InstalledStore) => EngineVersion | null
|
||||||
|
}
|
||||||
@@ -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,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,60 @@
|
|||||||
|
import http from 'node:http'
|
||||||
|
import https from 'node:https'
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT_MS = 60_000
|
||||||
|
const MAX_REDIRECTS = 5
|
||||||
|
const USER_AGENT = 'warp-engine-client'
|
||||||
|
|
||||||
|
/** A response that arrived but said no. The status matters: 404 is not a failure everywhere. */
|
||||||
|
export class HttpStatusError extends Error {
|
||||||
|
public constructor (public readonly url: string, public readonly statusCode: number) {
|
||||||
|
super(`${url} answered ${String(statusCode)}`)
|
||||||
|
this.name = 'HttpStatusError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET a URL as text, following redirects.
|
||||||
|
*
|
||||||
|
* Node's own client rather than `fetch`, because this runs in the main process
|
||||||
|
* where the proxy and certificate settings are the system's, and because a moved
|
||||||
|
* repository answers 301.
|
||||||
|
*/
|
||||||
|
export class HttpTextClient {
|
||||||
|
public async readText (url: string, redirectsLeft: number = MAX_REDIRECTS): Promise<string> {
|
||||||
|
return new Promise<string>((resolve: (body: string) => void, reject: (error: Error) => void): void => {
|
||||||
|
const client = url.startsWith('http://') ? http : https
|
||||||
|
const request = client.get(url, { headers: { 'User-Agent': USER_AGENT } }, (response): void => {
|
||||||
|
const status = response.statusCode ?? 0
|
||||||
|
const location = response.headers.location
|
||||||
|
|
||||||
|
if (status >= 300 && status < 400 && location !== undefined) {
|
||||||
|
response.resume()
|
||||||
|
if (redirectsLeft <= 0) {
|
||||||
|
reject(new Error(`too many redirects for ${url}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const next = new URL(location, url).toString()
|
||||||
|
this.readText(next, redirectsLeft - 1).then(resolve, reject)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
response.resume()
|
||||||
|
reject(new HttpStatusError(url, status))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = ''
|
||||||
|
response.setEncoding('utf8')
|
||||||
|
response.on('data', (chunk: string): void => { body += chunk })
|
||||||
|
response.on('end', (): void => { resolve(body) })
|
||||||
|
})
|
||||||
|
|
||||||
|
request.setTimeout(REQUEST_TIMEOUT_MS, (): void => {
|
||||||
|
request.destroy(new Error(`${url} timed out`))
|
||||||
|
})
|
||||||
|
request.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Reading JSON that came from somewhere else.
|
||||||
|
*
|
||||||
|
* The engine's stdout and the site's registry are both outside this program, so
|
||||||
|
* their shape is a claim, not a fact. These readers turn `unknown` into typed
|
||||||
|
* values with a stated fallback, which keeps every parser honest and every mapper
|
||||||
|
* free of casts.
|
||||||
|
*/
|
||||||
|
export type JsonRecord = Readonly<Record<string, unknown>>
|
||||||
|
|
||||||
|
export function asRecord (value: unknown): JsonRecord | null {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
? (value as JsonRecord)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readString (record: JsonRecord, key: string, fallback: string = ''): string {
|
||||||
|
const value = record[key]
|
||||||
|
return typeof value === 'string' ? value : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readOptionalString (record: JsonRecord, key: string): string | null {
|
||||||
|
const value = record[key]
|
||||||
|
return typeof value === 'string' && value.length > 0 ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readBoolean (record: JsonRecord, key: string, fallback: boolean = false): boolean {
|
||||||
|
const value = record[key]
|
||||||
|
return typeof value === 'boolean' ? value : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readNumber (record: JsonRecord, key: string, fallback: number = 0): number {
|
||||||
|
const value = record[key]
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStringArray (record: JsonRecord, key: string): readonly string[] {
|
||||||
|
const value = record[key]
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return value.filter((item: unknown): item is string => typeof item === 'string')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readRecordArray (record: JsonRecord, key: string): readonly JsonRecord[] {
|
||||||
|
const value = record[key]
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return value
|
||||||
|
.map((item: unknown): JsonRecord | null => asRecord(item))
|
||||||
|
.filter((item: JsonRecord | null): item is JsonRecord => item !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readRecord (record: JsonRecord, key: string): JsonRecord | null {
|
||||||
|
return asRecord(record[key])
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Game, GameMode, UnavailableReason } from '../../domain/models/Game'
|
||||||
|
import {
|
||||||
|
readBoolean, readOptionalString, readString, type JsonRecord
|
||||||
|
} from '../json/JsonRecord'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One engine JSON entry to one domain model.
|
||||||
|
*
|
||||||
|
* The engine speaks snake_case and this is the only place that knows it: rename a
|
||||||
|
* field there and this mapper is the single file that follows.
|
||||||
|
*/
|
||||||
|
export class EngineGameMapper {
|
||||||
|
public toModel (record: JsonRecord): Game {
|
||||||
|
return {
|
||||||
|
name: readString(record, 'name'),
|
||||||
|
title: readString(record, 'title'),
|
||||||
|
platform: readString(record, 'platform'),
|
||||||
|
version: readString(record, 'version'),
|
||||||
|
mode: this.toMode(readString(record, 'mode')),
|
||||||
|
kind: readString(record, 'kind'),
|
||||||
|
description: readString(record, 'desc'),
|
||||||
|
author: readString(record, 'author'),
|
||||||
|
imagePath: readOptionalString(record, 'image_url'),
|
||||||
|
installed: readBoolean(record, 'installed'),
|
||||||
|
updateAvailable: readBoolean(record, 'update_available'),
|
||||||
|
installedVersion: readOptionalString(record, 'installed_version'),
|
||||||
|
menuEntryPath: readOptionalString(record, 'menu_entry'),
|
||||||
|
executablePath: readOptionalString(record, 'exe'),
|
||||||
|
hostedUrl: readOptionalString(record, 'url'),
|
||||||
|
// Absent means installable: engines older than 1.2.0 list only what they can
|
||||||
|
// install, and treating their silence as "unavailable" would empty the window.
|
||||||
|
installable: readBoolean(record, 'installable', true),
|
||||||
|
unavailableReason: this.toReason(readOptionalString(record, 'unavailable_reason')),
|
||||||
|
unavailableDetail: readOptionalString(record, 'unavailable_detail')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toMode (value: string): GameMode {
|
||||||
|
return value === 'web' ? 'web' : 'app'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The engine's snake_case codes, which are its wire format and not ours. */
|
||||||
|
private toReason (value: string | null): UnavailableReason | null {
|
||||||
|
const codes: Readonly<Record<string, UnavailableReason>> = {
|
||||||
|
platform_off: 'platformOff',
|
||||||
|
host_asset: 'hostAsset',
|
||||||
|
no_asset: 'noAsset',
|
||||||
|
vetoed: 'vetoed'
|
||||||
|
}
|
||||||
|
return value === null ? null : codes[value] ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { StorePaths } from '../../domain/models/StorePaths'
|
||||||
|
import { readRecord, readString, type JsonRecord } from '../json/JsonRecord'
|
||||||
|
|
||||||
|
/** The engine's `paths` answer to one domain model. */
|
||||||
|
export class EngineStorePathsMapper {
|
||||||
|
public toModel (record: JsonRecord): StorePaths {
|
||||||
|
const store = readRecord(record, 'store')
|
||||||
|
return {
|
||||||
|
operatingSystem: readString(record, 'os'),
|
||||||
|
architecture: readString(record, 'arch'),
|
||||||
|
installRoot: readString(record, 'install_root'),
|
||||||
|
menuDirectory: readString(record, 'menu_dir'),
|
||||||
|
storeFolder: readString(record, 'store_folder'),
|
||||||
|
menuGroup: readString(record, 'menu_group'),
|
||||||
|
catalogBaseUrl: store === null ? '' : readString(store, 'base_url'),
|
||||||
|
storeName: store === null ? '' : readString(store, 'name'),
|
||||||
|
storeId: store === null ? '' : readString(store, 'id')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { spawn, spawnSync } from 'node:child_process'
|
||||||
|
import { EngineInvocationError } from '../../domain/errors/EngineInvocationError'
|
||||||
|
import { PythonMissingError } from '../../domain/errors/PythonMissingError'
|
||||||
|
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
|
||||||
|
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
||||||
|
import type { PythonRuntimeLocator } from '../../domain/ports/PythonRuntimeLocator'
|
||||||
|
import type { SyncEventDto } from '../../shared/contracts/dto/SyncEventDto'
|
||||||
|
import { asRecord, type JsonRecord } from '../json/JsonRecord'
|
||||||
|
|
||||||
|
const VERSION_PROBE_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs one engine command and reads its two streams.
|
||||||
|
*
|
||||||
|
* The engine's contract with any client is `--json`: data on stdout, one JSON
|
||||||
|
* object per line, and the human-readable log on stderr. So nothing here parses a
|
||||||
|
* sentence meant for a person, and a line that is not JSON is handed to the log
|
||||||
|
* rather than crashing the call.
|
||||||
|
*/
|
||||||
|
export class PythonEngineProcessRunner {
|
||||||
|
public constructor (private readonly runtimeLocator: PythonRuntimeLocator) {}
|
||||||
|
|
||||||
|
public async runCommand (
|
||||||
|
store: InstalledStore,
|
||||||
|
commandArguments: readonly string[],
|
||||||
|
progress: EngineProgressListener = {}
|
||||||
|
): Promise<readonly JsonRecord[]> {
|
||||||
|
const runtime = this.runtimeLocator.findRuntime()
|
||||||
|
if (runtime === null) throw new PythonMissingError()
|
||||||
|
|
||||||
|
const argv = [
|
||||||
|
...runtime.arguments,
|
||||||
|
store.scriptPath,
|
||||||
|
'--config', store.configPath,
|
||||||
|
'--json',
|
||||||
|
...commandArguments
|
||||||
|
]
|
||||||
|
|
||||||
|
return new Promise<readonly JsonRecord[]>((
|
||||||
|
resolve: (records: readonly JsonRecord[]) => void,
|
||||||
|
reject: (error: Error) => void
|
||||||
|
): void => {
|
||||||
|
const child = spawn(runtime.command, argv, {
|
||||||
|
env: { ...process.env, DESKTOP_STORE_HOME: store.home }
|
||||||
|
})
|
||||||
|
const records: JsonRecord[] = []
|
||||||
|
let stdoutRest = ''
|
||||||
|
let stderrRest = ''
|
||||||
|
|
||||||
|
const takeStdout = (chunk: string): void => {
|
||||||
|
stdoutRest += chunk
|
||||||
|
const parts = stdoutRest.split('\n')
|
||||||
|
stdoutRest = parts.pop() ?? ''
|
||||||
|
for (const part of parts) this.consumeStdoutLine(part, records, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
const takeStderr = (chunk: string): void => {
|
||||||
|
stderrRest += chunk
|
||||||
|
const parts = stderrRest.split('\n')
|
||||||
|
stderrRest = parts.pop() ?? ''
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.trim().length > 0) progress.onLog?.(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout.setEncoding('utf8')
|
||||||
|
child.stderr.setEncoding('utf8')
|
||||||
|
child.stdout.on('data', takeStdout)
|
||||||
|
child.stderr.on('data', takeStderr)
|
||||||
|
child.on('error', (error: Error): void => {
|
||||||
|
reject(new EngineInvocationError(error.message))
|
||||||
|
})
|
||||||
|
child.on('close', (code: number | null): void => {
|
||||||
|
takeStdout('\n')
|
||||||
|
takeStderr('\n')
|
||||||
|
if (code === 0) resolve(records)
|
||||||
|
else reject(new EngineInvocationError(`the store exited with code ${String(code)}`, code))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The engine's `--version`, read synchronously because it gates the first paint. */
|
||||||
|
public readVersionText (store: InstalledStore): string | null {
|
||||||
|
const runtime = this.runtimeLocator.findRuntime()
|
||||||
|
if (runtime === null) return null
|
||||||
|
try {
|
||||||
|
const probe = spawnSync(runtime.command, [...runtime.arguments, store.scriptPath, '--version'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: VERSION_PROBE_TIMEOUT_MS
|
||||||
|
})
|
||||||
|
const text = `${probe.stdout}${probe.stderr}`.trim()
|
||||||
|
return probe.status === 0 && text.length > 0 ? text : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private consumeStdoutLine (
|
||||||
|
line: string,
|
||||||
|
records: JsonRecord[],
|
||||||
|
progress: EngineProgressListener
|
||||||
|
): void {
|
||||||
|
if (line.trim().length === 0) return
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line)
|
||||||
|
} catch {
|
||||||
|
// Not ours to interpret — hand it on as a log line rather than fail the call.
|
||||||
|
progress.onLog?.(line)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const record = asRecord(parsed)
|
||||||
|
if (record === null) return
|
||||||
|
records.push(record)
|
||||||
|
const event = this.asSyncEvent(record)
|
||||||
|
if (event !== null) progress.onEvent?.(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A progress line, or null for the final result object.
|
||||||
|
*
|
||||||
|
* The engine tags its stream events with `event`; the listing and paths answers
|
||||||
|
* carry no such field, which is exactly the difference.
|
||||||
|
*/
|
||||||
|
private asSyncEvent (record: JsonRecord): SyncEventDto | null {
|
||||||
|
return typeof record['event'] === 'string' ? (record as unknown as SyncEventDto) : null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import type { PythonRuntime } from '../../domain/models/PythonRuntime'
|
||||||
|
import type { PythonRuntimeLocator } from '../../domain/ports/PythonRuntimeLocator'
|
||||||
|
|
||||||
|
interface RuntimeCandidate {
|
||||||
|
readonly command: string
|
||||||
|
readonly arguments: readonly string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `py -3` is the Windows launcher, and often the only Python on PATH there. */
|
||||||
|
const WINDOWS_CANDIDATES: readonly RuntimeCandidate[] = [
|
||||||
|
{ command: 'py', arguments: ['-3'] },
|
||||||
|
{ command: 'python', arguments: [] },
|
||||||
|
{ command: 'python3', arguments: [] }
|
||||||
|
]
|
||||||
|
|
||||||
|
const POSIX_CANDIDATES: readonly RuntimeCandidate[] = [
|
||||||
|
{ command: 'python3', arguments: [] },
|
||||||
|
{ command: 'python', arguments: [] }
|
||||||
|
]
|
||||||
|
|
||||||
|
const PROBE_TIMEOUT_MS = 10_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the Python 3 the store needs.
|
||||||
|
*
|
||||||
|
* The answer is cached: the probe spawns processes, and the window asks for it on
|
||||||
|
* every state read.
|
||||||
|
*/
|
||||||
|
export class SystemPythonRuntimeLocator implements PythonRuntimeLocator {
|
||||||
|
private cached: PythonRuntime | null = null
|
||||||
|
private probed = false
|
||||||
|
|
||||||
|
public findRuntime (): PythonRuntime | null {
|
||||||
|
if (this.probed) return this.cached
|
||||||
|
this.probed = true
|
||||||
|
const candidates = process.platform === 'win32' ? WINDOWS_CANDIDATES : POSIX_CANDIDATES
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const runtime = this.probeCandidate(candidate)
|
||||||
|
if (runtime !== null) {
|
||||||
|
this.cached = runtime
|
||||||
|
return runtime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private probeCandidate (candidate: RuntimeCandidate): PythonRuntime | null {
|
||||||
|
try {
|
||||||
|
const probe = spawnSync(candidate.command, [...candidate.arguments, '--version'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: PROBE_TIMEOUT_MS
|
||||||
|
})
|
||||||
|
const output = `${probe.stdout}${probe.stderr}`
|
||||||
|
if (probe.status === 0 && output.includes('Python 3.')) {
|
||||||
|
return { command: candidate.command, arguments: candidate.arguments, version: output.trim() }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// An absent interpreter is the normal case, not an error worth reporting.
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
||||||
|
import { DESKTOP_STORE_ENGINE, STORE_ENGINES } from '../../domain/models/StoreEngine'
|
||||||
|
import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository'
|
||||||
|
import { asRecord, readString } from '../json/JsonRecord'
|
||||||
|
|
||||||
|
const STORE_DIRECTORY_NAME = 'warp-engine-store'
|
||||||
|
const CONFIG_FILE_NAME = 'config.json'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds stores where the shell installers put them.
|
||||||
|
*
|
||||||
|
* The roots are searched in the installers' own order, and `STORE_ROOT` comes
|
||||||
|
* first so a sandbox can be driven without touching a working installation — which
|
||||||
|
* is how this repository is tested.
|
||||||
|
*/
|
||||||
|
export class FileSystemInstalledStoreRepository implements InstalledStoreRepository {
|
||||||
|
public findAll (): readonly InstalledStore[] {
|
||||||
|
const found: InstalledStore[] = []
|
||||||
|
for (const root of this.readRoots()) {
|
||||||
|
for (const entry of this.readDirectories(root)) {
|
||||||
|
const home = path.join(root, entry)
|
||||||
|
const store = this.readStoreAt(home, entry)
|
||||||
|
if (store !== null) found.push(store)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
public findByHome (home: string): InstalledStore | null {
|
||||||
|
return this.findAll().find((store: InstalledStore): boolean => store.home === home) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
public readRoots (): readonly string[] {
|
||||||
|
const home = os.homedir()
|
||||||
|
const roots: string[] = []
|
||||||
|
const override = process.env['STORE_ROOT']
|
||||||
|
if (override !== undefined && override.length > 0) roots.push(override)
|
||||||
|
const xdgDataHome = process.env['XDG_DATA_HOME']
|
||||||
|
if (xdgDataHome !== undefined && xdgDataHome.length > 0) {
|
||||||
|
roots.push(path.join(xdgDataHome, STORE_DIRECTORY_NAME))
|
||||||
|
}
|
||||||
|
roots.push(path.join(home, '.local', 'share', STORE_DIRECTORY_NAME))
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
roots.push(path.join(home, 'Library', 'Application Support', STORE_DIRECTORY_NAME))
|
||||||
|
}
|
||||||
|
const localAppData = process.env['LOCALAPPDATA']
|
||||||
|
if (process.platform === 'win32' && localAppData !== undefined && localAppData.length > 0) {
|
||||||
|
roots.push(path.join(localAppData, STORE_DIRECTORY_NAME))
|
||||||
|
}
|
||||||
|
return [...new Set(roots)]
|
||||||
|
}
|
||||||
|
|
||||||
|
public resolveDefaultHome (storeId: string): string {
|
||||||
|
const root = this.readRoots()[0] ?? path.join(os.homedir(), '.local', 'share', STORE_DIRECTORY_NAME)
|
||||||
|
return path.join(root, `${storeId}${DESKTOP_STORE_ENGINE.homeSuffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private readDirectories (root: string): readonly string[] {
|
||||||
|
try {
|
||||||
|
return fs.readdirSync(root, { withFileTypes: true })
|
||||||
|
.filter((entry: fs.Dirent): boolean => entry.isDirectory())
|
||||||
|
.map((entry: fs.Dirent): string => entry.name)
|
||||||
|
} catch {
|
||||||
|
// A root that does not exist is the normal case on a fresh machine.
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readStoreAt (home: string, directoryName: string): InstalledStore | null {
|
||||||
|
for (const engine of STORE_ENGINES) {
|
||||||
|
const scriptPath = path.join(home, engine.scriptFileName)
|
||||||
|
const configPath = path.join(home, CONFIG_FILE_NAME)
|
||||||
|
if (!fs.existsSync(scriptPath) || !fs.existsSync(configPath)) continue
|
||||||
|
const id = directoryName.replace(engine.homeSuffix, '')
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: this.readStoreName(configPath, id),
|
||||||
|
home,
|
||||||
|
scriptPath,
|
||||||
|
configPath,
|
||||||
|
engine: engine.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The store's own name, from the config the installer wrote.
|
||||||
|
*
|
||||||
|
* Read here rather than asked of the engine: the switcher lists every store on
|
||||||
|
* the machine, and starting a Python process per entry to learn its name would
|
||||||
|
* be absurd.
|
||||||
|
*/
|
||||||
|
private readStoreName (configPath: string, fallback: string): string {
|
||||||
|
try {
|
||||||
|
const config = asRecord(JSON.parse(fs.readFileSync(configPath, 'utf8')))
|
||||||
|
const store = config === null ? null : asRecord(config['store'])
|
||||||
|
const name = store === null ? '' : readString(store, 'name')
|
||||||
|
return name.length > 0 ? name : fallback
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
|
||||||
|
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
||||||
|
import type { RegistryStore } from '../../domain/models/RegistryStore'
|
||||||
|
import { DESKTOP_STORE_ENGINE } from '../../domain/models/StoreEngine'
|
||||||
|
import { deriveStoreId } from '../../domain/models/StoreIdentity'
|
||||||
|
import type { StoreEngineInstaller } from '../../domain/ports/StoreEngineInstaller'
|
||||||
|
import { asRecord, readString } from '../json/JsonRecord'
|
||||||
|
import { HttpStatusError, type HttpTextClient } from '../http/HttpTextClient'
|
||||||
|
|
||||||
|
const CONFIG_FILE_NAME = 'config.json'
|
||||||
|
const SCRIPT_MODE = 0o755
|
||||||
|
const PYTHON_SHEBANG = '#!/usr/bin/env python3'
|
||||||
|
const DEFAULT_FORGE_BASE = 'https://git.teletypegames.org'
|
||||||
|
const DEFAULT_BRANCH = 'master'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads the engine, the shared core and a store config into a store home.
|
||||||
|
*
|
||||||
|
* The same three files, in the same folder, the shell installer would place — so
|
||||||
|
* the CLI and this client stay one installation, and running install.sh afterwards
|
||||||
|
* only adds the launcher script. No launcher is written here: the window is it.
|
||||||
|
*/
|
||||||
|
export class HttpStoreEngineInstaller implements StoreEngineInstaller {
|
||||||
|
private readonly forgeBase: string
|
||||||
|
|
||||||
|
public constructor (private readonly httpClient: HttpTextClient, forgeBase?: string) {
|
||||||
|
const configured = process.env['FORGE_BASE']
|
||||||
|
this.forgeBase = forgeBase ?? (configured !== undefined && configured.length > 0
|
||||||
|
? configured
|
||||||
|
: DEFAULT_FORGE_BASE)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async installEngine (
|
||||||
|
home: string,
|
||||||
|
store: RegistryStore,
|
||||||
|
progress: EngineProgressListener = {}
|
||||||
|
): Promise<InstalledStore> {
|
||||||
|
fs.mkdirSync(home, { recursive: true })
|
||||||
|
|
||||||
|
for (const [fileName, url] of Object.entries(this.engineSources())) {
|
||||||
|
progress.onLog?.(`downloading ${fileName}`)
|
||||||
|
const body = await this.httpClient.readText(url)
|
||||||
|
if (!body.startsWith(PYTHON_SHEBANG)) {
|
||||||
|
throw new Error(`${fileName} does not look like the store engine — refusing to install it`)
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join(home, fileName), body, { mode: SCRIPT_MODE })
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await this.readStoreConfig(store, progress)
|
||||||
|
const configPath = path.join(home, CONFIG_FILE_NAME)
|
||||||
|
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||||
|
|
||||||
|
progress.onLog?.(`${store.name} is set up in ${home}`)
|
||||||
|
const configStore = asRecord(config['store'])
|
||||||
|
return {
|
||||||
|
id: configStore === null ? deriveStoreId(store) : readString(configStore, 'id', deriveStoreId(store)),
|
||||||
|
name: store.name,
|
||||||
|
home,
|
||||||
|
scriptPath: path.join(home, DESKTOP_STORE_ENGINE.scriptFileName),
|
||||||
|
configPath,
|
||||||
|
engine: DESKTOP_STORE_ENGINE.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the engine itself comes from: this client's own machinery, not the registry's. */
|
||||||
|
private engineSources (): Readonly<Record<string, string>> {
|
||||||
|
return {
|
||||||
|
[DESKTOP_STORE_ENGINE.scriptFileName]:
|
||||||
|
`${this.forgeBase}/stores/warp-engine-desktop-store/raw/branch/${DEFAULT_BRANCH}/${DESKTOP_STORE_ENGINE.scriptFileName}`,
|
||||||
|
'warpstore.py': `${this.forgeBase}/engines/warpstore/raw/branch/${DEFAULT_BRANCH}/warpstore.py`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The store's configuration.
|
||||||
|
*
|
||||||
|
* Three cases, and all of them install:
|
||||||
|
*
|
||||||
|
* - **a repository with a config.json** — that file is the authority on how the
|
||||||
|
* store behaves: which platforms it offers, which statuses it shows, where
|
||||||
|
* things land;
|
||||||
|
* - **a repository without one** (404) — the engine's defaults, as below;
|
||||||
|
* - **no repository at all** — the same defaults, without the round trip.
|
||||||
|
*
|
||||||
|
* The engine's built-in defaults already cover the host-to-asset mapping, the
|
||||||
|
* modes, the platforms and the behaviour, so what a store actually has to supply
|
||||||
|
* is identity: a slug, a name and a catalog. That is exactly what a registry
|
||||||
|
* record carries, which is why a store needs no repository of its own. The
|
||||||
|
* registry always wins on those three, whatever a config file says.
|
||||||
|
*/
|
||||||
|
private async readStoreConfig (
|
||||||
|
store: RegistryStore,
|
||||||
|
progress: EngineProgressListener
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const storeId = deriveStoreId(store)
|
||||||
|
const config = await this.readPublishedConfig(store, storeId, progress)
|
||||||
|
|
||||||
|
const existing = asRecord(config['store']) ?? {}
|
||||||
|
config['store'] = {
|
||||||
|
...existing,
|
||||||
|
id: readString(existing, 'id', storeId),
|
||||||
|
name: store.name,
|
||||||
|
base_url: store.catalogUrl
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readPublishedConfig (
|
||||||
|
store: RegistryStore,
|
||||||
|
storeId: string,
|
||||||
|
progress: EngineProgressListener
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const repositoryUrl = store.storeRepositoryUrl
|
||||||
|
if (repositoryUrl === null) {
|
||||||
|
progress.onLog?.(`${store.name} has no store repository — using the engine defaults`)
|
||||||
|
return this.defaultConfig(storeId)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
progress.onLog?.(`reading the store config from ${repositoryUrl}`)
|
||||||
|
const body = await this.httpClient.readText(this.configUrl(repositoryUrl))
|
||||||
|
return { ...(asRecord(JSON.parse(body)) ?? {}) }
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!(error instanceof HttpStatusError) || error.statusCode !== 404) throw error
|
||||||
|
progress.onLog?.('no config.json in the store repository — using the engine defaults')
|
||||||
|
return this.defaultConfig(storeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a store gets when nothing else says otherwise.
|
||||||
|
*
|
||||||
|
* Two fields, on top of the identity added by the caller. The subfolder keeps two
|
||||||
|
* stores on one machine out of each other's files, and it is the prune boundary,
|
||||||
|
* so it must be the store's own. Demo titles are listed because a catalog that
|
||||||
|
* publishes them means them to be played — the engine defaults to released and
|
||||||
|
* archived only, which is the safer default for a store nobody configured.
|
||||||
|
*/
|
||||||
|
private defaultConfig (storeId: string): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
paths: { subfolder: storeId },
|
||||||
|
catalog: { statuses: ['released', 'archived', 'demo'] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private configUrl (repositoryUrl: string, branch: string = DEFAULT_BRANCH): string {
|
||||||
|
return `${repositoryUrl.replace(/\/+$/, '')}/raw/branch/${branch}/${CONFIG_FILE_NAME}`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { RegistryUnavailableError } from '../../domain/errors/RegistryUnavailableError'
|
||||||
|
import type { RegistryStore } from '../../domain/models/RegistryStore'
|
||||||
|
import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository'
|
||||||
|
import { asRecord, readString, type JsonRecord } from '../json/JsonRecord'
|
||||||
|
import { BuildConfiguration } from '../config/BuildConfiguration'
|
||||||
|
import type { HttpTextClient } from '../http/HttpTextClient'
|
||||||
|
|
||||||
|
const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The registry: `GET /api/stores` on the site.
|
||||||
|
*
|
||||||
|
* The one address this client knows, and it is decided in three places, most specific
|
||||||
|
* first: a runtime `STORES_API` (for trying something out), the `warpEngine.registryUrl`
|
||||||
|
* field a build was packaged with (for shipping a client for another site), and finally
|
||||||
|
* the address of ours.
|
||||||
|
*
|
||||||
|
* A record needs a name and a catalog URL; those two make a store. The repository
|
||||||
|
* is optional and arrives as null when absent — a store configured by nothing but
|
||||||
|
* this record installs on the engine's defaults. Records missing either of the two
|
||||||
|
* required fields are dropped rather than half-used.
|
||||||
|
*/
|
||||||
|
export class HttpStoreRegistryRepository implements StoreRegistryRepository {
|
||||||
|
public readonly sourceUrl: string
|
||||||
|
|
||||||
|
public constructor (
|
||||||
|
private readonly httpClient: HttpTextClient,
|
||||||
|
sourceUrl?: string,
|
||||||
|
buildConfiguration: BuildConfiguration = new BuildConfiguration()
|
||||||
|
) {
|
||||||
|
const fromEnvironment = process.env['STORES_API']
|
||||||
|
this.sourceUrl = sourceUrl
|
||||||
|
?? (fromEnvironment !== undefined && fromEnvironment.length > 0 ? fromEnvironment : null)
|
||||||
|
?? buildConfiguration.readRegistryUrl()
|
||||||
|
?? DEFAULT_REGISTRY_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listStores (): Promise<readonly RegistryStore[]> {
|
||||||
|
const body = await this.httpClient.readText(this.sourceUrl)
|
||||||
|
const parsed: unknown = JSON.parse(body)
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new RegistryUnavailableError(this.sourceUrl, 'the answer was not a list of stores')
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
.map((row: unknown): JsonRecord | null => asRecord(row))
|
||||||
|
.filter((row: JsonRecord | null): row is JsonRecord => row !== null)
|
||||||
|
.map((row: JsonRecord): RegistryStore => {
|
||||||
|
// Both spellings, because a registry is someone else's API: ours answers
|
||||||
|
// camelCase, and a hand-rolled one may not.
|
||||||
|
const repository = (
|
||||||
|
readString(row, 'storeRepositoryUrl') || readString(row, 'store_repository_url')
|
||||||
|
).trim()
|
||||||
|
return {
|
||||||
|
name: readString(row, 'name').trim(),
|
||||||
|
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim(),
|
||||||
|
storeRepositoryUrl: repository.length > 0 ? repository : null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((store: RegistryStore): boolean =>
|
||||||
|
store.name.length > 0 && store.catalogUrl.length > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import type { Preferences } from '../../domain/models/Preferences'
|
||||||
|
import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment'
|
||||||
|
import type { PreferencesRepository } from '../../domain/ports/PreferencesRepository'
|
||||||
|
import { LOCALES, type Locale } from '../../shared/i18n/MessageBundle'
|
||||||
|
import { asRecord, readBoolean, readOptionalString } from '../json/JsonRecord'
|
||||||
|
|
||||||
|
const PREFERENCES_FILE_NAME = 'prefs.json'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preferences in one small JSON file next to the application's own data.
|
||||||
|
*
|
||||||
|
* A lost preference is not worth an error dialog, so both directions swallow their
|
||||||
|
* failures — the defaults are always usable.
|
||||||
|
*/
|
||||||
|
export class JsonFilePreferencesRepository implements PreferencesRepository {
|
||||||
|
public constructor (private readonly environment: ApplicationEnvironment) {}
|
||||||
|
|
||||||
|
public read (): Preferences {
|
||||||
|
try {
|
||||||
|
const parsed = asRecord(JSON.parse(fs.readFileSync(this.filePath(), 'utf8')))
|
||||||
|
if (parsed === null) return {}
|
||||||
|
const locale = readOptionalString(parsed, 'locale')
|
||||||
|
const storeHome = readOptionalString(parsed, 'storeHome')
|
||||||
|
const preferences: {
|
||||||
|
locale?: Locale
|
||||||
|
navigationOpen?: boolean
|
||||||
|
storeHome?: string
|
||||||
|
} = {}
|
||||||
|
const known = LOCALES.find((candidate: Locale): boolean => candidate === locale)
|
||||||
|
if (known !== undefined) preferences.locale = known
|
||||||
|
if (storeHome !== null) preferences.storeHome = storeHome
|
||||||
|
if (typeof parsed['navigationOpen'] === 'boolean') {
|
||||||
|
preferences.navigationOpen = readBoolean(parsed, 'navigationOpen', true)
|
||||||
|
}
|
||||||
|
return preferences
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public write (preferences: Preferences): void {
|
||||||
|
try {
|
||||||
|
const target = this.filePath()
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||||
|
fs.writeFileSync(target, `${JSON.stringify(preferences, null, 2)}\n`)
|
||||||
|
} catch {
|
||||||
|
// Not worth interrupting the session over.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private filePath (): string {
|
||||||
|
return this.environment.resolveUserDataPath(PREFERENCES_FILE_NAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { CatalogListing } from '../../domain/models/CatalogListing'
|
||||||
|
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
|
||||||
|
import {
|
||||||
|
MINIMUM_ENGINE_VERSION, isAtLeast, parseVersionNumbers, type EngineVersion
|
||||||
|
} from '../../domain/models/EngineVersion'
|
||||||
|
import type { Game } from '../../domain/models/Game'
|
||||||
|
import type { InstalledStore } from '../../domain/models/InstalledStore'
|
||||||
|
import type { StorePaths } from '../../domain/models/StorePaths'
|
||||||
|
import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
|
||||||
|
import { readRecord, readRecordArray, readStringArray, type JsonRecord } from '../json/JsonRecord'
|
||||||
|
import { EngineGameMapper } from '../mappers/EngineGameMapper'
|
||||||
|
import { EngineStorePathsMapper } from '../mappers/EngineStorePathsMapper'
|
||||||
|
import type { PythonEngineProcessRunner } from '../process/PythonEngineProcessRunner'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The store engine, driven as a child process.
|
||||||
|
*
|
||||||
|
* The only adapter that knows the CLI exists. Everything above it sees the port.
|
||||||
|
*/
|
||||||
|
export class PythonStoreCatalogGateway implements StoreCatalogGateway {
|
||||||
|
public constructor (
|
||||||
|
private readonly runner: PythonEngineProcessRunner,
|
||||||
|
private readonly gameMapper: EngineGameMapper = new EngineGameMapper(),
|
||||||
|
private readonly pathsMapper: EngineStorePathsMapper = new EngineStorePathsMapper()
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async listGames (
|
||||||
|
store: InstalledStore,
|
||||||
|
progress?: EngineProgressListener
|
||||||
|
): Promise<CatalogListing> {
|
||||||
|
const records = await this.runner.runCommand(store, ['list'], progress)
|
||||||
|
const answer = this.lastRecord(records)
|
||||||
|
if (answer === null) return { games: [], skipped: [], paths: null }
|
||||||
|
const games: readonly Game[] = readRecordArray(answer, 'games')
|
||||||
|
.map((record: JsonRecord): Game => this.gameMapper.toModel(record))
|
||||||
|
const paths = readRecord(answer, 'paths')
|
||||||
|
return {
|
||||||
|
games,
|
||||||
|
skipped: readStringArray(answer, 'skipped'),
|
||||||
|
paths: paths === null ? null : this.pathsMapper.toModel(paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async readPaths (
|
||||||
|
store: InstalledStore,
|
||||||
|
progress?: EngineProgressListener
|
||||||
|
): Promise<StorePaths> {
|
||||||
|
const records = await this.runner.runCommand(store, ['paths'], progress)
|
||||||
|
const answer = this.lastRecord(records)
|
||||||
|
return this.pathsMapper.toModel(answer ?? {})
|
||||||
|
}
|
||||||
|
|
||||||
|
public async syncGames (
|
||||||
|
store: InstalledStore,
|
||||||
|
names: readonly string[],
|
||||||
|
progress?: EngineProgressListener
|
||||||
|
): Promise<void> {
|
||||||
|
await this.runner.runCommand(store, ['sync', ...names], progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeGame (
|
||||||
|
store: InstalledStore,
|
||||||
|
name: string,
|
||||||
|
progress?: EngineProgressListener
|
||||||
|
): Promise<void> {
|
||||||
|
await this.runner.runCommand(store, ['remove', name], progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
public readEngineVersion (store: InstalledStore): EngineVersion | null {
|
||||||
|
const text = this.runner.readVersionText(store)
|
||||||
|
if (text === null) return null
|
||||||
|
const numbers = parseVersionNumbers(text)
|
||||||
|
return { text, numbers, supported: isAtLeast(numbers, MINIMUM_ENGINE_VERSION) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The result object is the last line: the stream events come first, and both
|
||||||
|
* arrive on the same pipe.
|
||||||
|
*/
|
||||||
|
private lastRecord (records: readonly JsonRecord[]): JsonRecord | null {
|
||||||
|
for (let index = records.length - 1; index >= 0; index -= 1) {
|
||||||
|
const record = records[index]
|
||||||
|
if (record !== undefined && typeof record['event'] !== 'string') return record
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import { BrowserWindow, dialog, type App, type IpcMain, type Shell } from 'electron'
|
||||||
|
import { ServiceContainer } from './composition/ServiceContainer'
|
||||||
|
import { MainWindowFactory } from './MainWindowFactory'
|
||||||
|
import { SelfTestRunner } from './diagnostics/SelfTestRunner'
|
||||||
|
|
||||||
|
const SELFTEST_FLAG = '--selftest'
|
||||||
|
const SELFTEST_USER_DATA_DIRECTORY = 'warpstore-gui-selftest'
|
||||||
|
const PRODUCT_NAME = 'WarpEngine Client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The application's lifecycle.
|
||||||
|
*
|
||||||
|
* Thin on purpose: it owns the window and the process events, and hands everything
|
||||||
|
* else to the container. The self-test mode is part of the lifecycle because it has
|
||||||
|
* to bypass two of its rules — see below.
|
||||||
|
*/
|
||||||
|
export class ElectronApplication {
|
||||||
|
private readonly selfTest: boolean
|
||||||
|
private readonly container: ServiceContainer
|
||||||
|
private readonly windowFactory: MainWindowFactory
|
||||||
|
private window: BrowserWindow | null = null
|
||||||
|
|
||||||
|
public constructor (
|
||||||
|
private readonly app: App,
|
||||||
|
private readonly ipc: IpcMain,
|
||||||
|
shell: Shell,
|
||||||
|
argv: readonly string[] = process.argv
|
||||||
|
) {
|
||||||
|
this.selfTest = argv.includes(SELFTEST_FLAG)
|
||||||
|
this.container = new ServiceContainer(app, shell)
|
||||||
|
this.windowFactory = new MainWindowFactory((message: string, level: number): void => {
|
||||||
|
if (level >= 2 || this.selfTest) console.log(`[renderer] ${message}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public start (): void {
|
||||||
|
// A test run must never be swallowed by a copy the user already has open: it
|
||||||
|
// gets its own user-data directory and skips the single-instance lock. Without
|
||||||
|
// this the second process exits silently with status 0, which reads as a pass.
|
||||||
|
if (this.selfTest) {
|
||||||
|
this.app.setPath('userData', path.join(this.app.getPath('temp'), SELFTEST_USER_DATA_DIRECTORY))
|
||||||
|
} else if (!this.app.requestSingleInstanceLock()) {
|
||||||
|
this.app.quit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.container.registerIpc(this.ipc)
|
||||||
|
this.app.on('second-instance', (): void => { this.focusWindow() })
|
||||||
|
this.app.on('activate', (): void => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) this.openWindow()
|
||||||
|
})
|
||||||
|
this.app.on('window-all-closed', (): void => {
|
||||||
|
if (process.platform !== 'darwin') this.app.quit()
|
||||||
|
})
|
||||||
|
process.on('unhandledRejection', (reason: unknown): void => {
|
||||||
|
dialog.showErrorBox(PRODUCT_NAME, reason instanceof Error ? reason.message : String(reason))
|
||||||
|
})
|
||||||
|
|
||||||
|
void this.app.whenReady().then((): void => { this.openWindow() })
|
||||||
|
}
|
||||||
|
|
||||||
|
private openWindow (): void {
|
||||||
|
const window = this.windowFactory.createWindow()
|
||||||
|
this.window = window
|
||||||
|
this.container.streams.attachWindow(window)
|
||||||
|
window.on('closed', (): void => {
|
||||||
|
this.container.streams.detachWindow()
|
||||||
|
this.window = null
|
||||||
|
})
|
||||||
|
if (this.selfTest) this.scheduleSelfTest(window)
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleSelfTest (window: BrowserWindow): void {
|
||||||
|
const runner = new SelfTestRunner(window)
|
||||||
|
window.webContents.once('did-finish-load', (): void => {
|
||||||
|
// The first listing has to finish before there is anything to look at.
|
||||||
|
setTimeout((): void => {
|
||||||
|
runner.run().then(
|
||||||
|
(passed: boolean): void => { this.app.exit(passed ? 0 : 1) },
|
||||||
|
(error: unknown): void => {
|
||||||
|
console.log(`SELFTEST ERROR ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
this.app.exit(1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}, runner.settleDelayMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusWindow (): void {
|
||||||
|
const window = this.window
|
||||||
|
if (window === null) return
|
||||||
|
if (window.isMinimized()) window.restore()
|
||||||
|
window.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import { BrowserWindow, shell, type BrowserWindowConstructorOptions } from 'electron'
|
||||||
|
|
||||||
|
const WINDOW_OPTIONS: BrowserWindowConstructorOptions = {
|
||||||
|
width: 1040,
|
||||||
|
height: 720,
|
||||||
|
minWidth: 760,
|
||||||
|
minHeight: 520,
|
||||||
|
backgroundColor: '#11151c',
|
||||||
|
title: 'WarpEngine Client'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one window.
|
||||||
|
*
|
||||||
|
* Locked down deliberately: context isolation on, node integration off, sandbox on,
|
||||||
|
* and the page carries a CSP of its own. Nothing here should ever navigate away or
|
||||||
|
* open a second window — a link the user clicks goes to their browser instead.
|
||||||
|
*/
|
||||||
|
export class MainWindowFactory {
|
||||||
|
public constructor (private readonly onRendererMessage: (message: string, level: number) => void) {}
|
||||||
|
|
||||||
|
public createWindow (): BrowserWindow {
|
||||||
|
const window = new BrowserWindow({
|
||||||
|
...WINDOW_OPTIONS,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, '..', 'preload', 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
webSecurity: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
void window.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))
|
||||||
|
this.forwardRendererDiagnostics(window)
|
||||||
|
this.denyNavigation(window)
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A renderer error is invisible from the main process otherwise. */
|
||||||
|
private forwardRendererDiagnostics (window: BrowserWindow): void {
|
||||||
|
window.webContents.on('console-message', (details): void => {
|
||||||
|
const level = details.level === 'error' ? 3 : details.level === 'warning' ? 2 : 1
|
||||||
|
this.onRendererMessage(details.message, level)
|
||||||
|
})
|
||||||
|
window.webContents.on('render-process-gone', (_event, details): void => {
|
||||||
|
this.onRendererMessage(`gone: ${details.reason}`, 3)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private denyNavigation (window: BrowserWindow): void {
|
||||||
|
window.webContents.setWindowOpenHandler(({ url }: { url: string }): { action: 'deny' } => {
|
||||||
|
if (url.startsWith('https://')) void shell.openExternal(url)
|
||||||
|
return { action: 'deny' }
|
||||||
|
})
|
||||||
|
window.webContents.on('will-navigate', (event, url: string): void => {
|
||||||
|
if (url === window.webContents.getURL()) return
|
||||||
|
event.preventDefault()
|
||||||
|
if (url.startsWith('https://')) void shell.openExternal(url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { App, IpcMain, Shell } from 'electron'
|
||||||
|
import { ApplicationStateService } from '../../application/services/ApplicationStateService'
|
||||||
|
import { CatalogService } from '../../application/services/CatalogService'
|
||||||
|
import { GameLaunchService } from '../../application/services/GameLaunchService'
|
||||||
|
import { PreferencesService } from '../../application/services/PreferencesService'
|
||||||
|
import { StoreProvisioningService } from '../../application/services/StoreProvisioningService'
|
||||||
|
import { StoreSelectionService } from '../../application/services/StoreSelectionService'
|
||||||
|
import { ElectronApplicationEnvironment } from '../../infrastructure/electron/ElectronApplicationEnvironment'
|
||||||
|
import { ElectronGameLauncher } from '../../infrastructure/electron/ElectronGameLauncher'
|
||||||
|
import { HttpTextClient } from '../../infrastructure/http/HttpTextClient'
|
||||||
|
import { PythonEngineProcessRunner } from '../../infrastructure/process/PythonEngineProcessRunner'
|
||||||
|
import { SystemPythonRuntimeLocator } from '../../infrastructure/process/SystemPythonRuntimeLocator'
|
||||||
|
import { FileSystemInstalledStoreRepository } from '../../infrastructure/repositories/FileSystemInstalledStoreRepository'
|
||||||
|
import { HttpStoreEngineInstaller } from '../../infrastructure/repositories/HttpStoreEngineInstaller'
|
||||||
|
import { HttpStoreRegistryRepository } from '../../infrastructure/repositories/HttpStoreRegistryRepository'
|
||||||
|
import { JsonFilePreferencesRepository } from '../../infrastructure/repositories/JsonFilePreferencesRepository'
|
||||||
|
import { PythonStoreCatalogGateway } from '../../infrastructure/repositories/PythonStoreCatalogGateway'
|
||||||
|
import { AppIpcController } from '../ipc/AppIpcController'
|
||||||
|
import { CatalogIpcController } from '../ipc/CatalogIpcController'
|
||||||
|
import { IpcRouter } from '../ipc/IpcRouter'
|
||||||
|
import { SingleFlightGuard } from '../ipc/SingleFlightGuard'
|
||||||
|
import { StoreIpcController } from '../ipc/StoreIpcController'
|
||||||
|
import { WindowStreamBroadcaster } from '../streams/WindowStreamBroadcaster'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The composition root: the only file that knows which implementation backs which
|
||||||
|
* port.
|
||||||
|
*
|
||||||
|
* Every layer above depends on interfaces, so swapping the engine for a stub or the
|
||||||
|
* registry for a local endpoint is a change here and nowhere else.
|
||||||
|
*/
|
||||||
|
export class ServiceContainer {
|
||||||
|
public readonly streams: WindowStreamBroadcaster
|
||||||
|
public readonly guard: SingleFlightGuard
|
||||||
|
public readonly catalog: CatalogService
|
||||||
|
public readonly selection: StoreSelectionService
|
||||||
|
public readonly provisioning: StoreProvisioningService
|
||||||
|
public readonly state: ApplicationStateService
|
||||||
|
public readonly launching: GameLaunchService
|
||||||
|
|
||||||
|
private readonly controllers: readonly { register: (router: IpcRouter) => void }[]
|
||||||
|
|
||||||
|
public constructor (app: App, shell: Shell) {
|
||||||
|
this.streams = new WindowStreamBroadcaster()
|
||||||
|
this.guard = new SingleFlightGuard((busy: boolean): void => { this.streams.publishBusyChanged(busy) })
|
||||||
|
|
||||||
|
const environment = new ElectronApplicationEnvironment(app)
|
||||||
|
const httpClient = new HttpTextClient()
|
||||||
|
const pythonLocator = new SystemPythonRuntimeLocator()
|
||||||
|
const engineRunner = new PythonEngineProcessRunner(pythonLocator)
|
||||||
|
|
||||||
|
const stores = new FileSystemInstalledStoreRepository()
|
||||||
|
const catalogGateway = new PythonStoreCatalogGateway(engineRunner)
|
||||||
|
const registry = new HttpStoreRegistryRepository(httpClient)
|
||||||
|
const installer = new HttpStoreEngineInstaller(httpClient)
|
||||||
|
const preferencesRepository = new JsonFilePreferencesRepository(environment)
|
||||||
|
|
||||||
|
const preferences = new PreferencesService(preferencesRepository, environment)
|
||||||
|
this.selection = new StoreSelectionService(stores, catalogGateway, preferences)
|
||||||
|
this.catalog = new CatalogService(catalogGateway, this.selection)
|
||||||
|
this.provisioning = new StoreProvisioningService(registry, installer, stores, this.selection)
|
||||||
|
this.launching = new GameLaunchService(new ElectronGameLauncher(shell), this.catalog)
|
||||||
|
this.state = new ApplicationStateService(
|
||||||
|
preferences, this.selection, this.provisioning, pythonLocator, environment
|
||||||
|
)
|
||||||
|
|
||||||
|
this.controllers = [
|
||||||
|
new AppIpcController(this.state, preferences, this.launching),
|
||||||
|
new CatalogIpcController(this.catalog, this.launching, this.guard, this.streams),
|
||||||
|
new StoreIpcController(this.provisioning, this.selection, this.guard, this.streams)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
public registerIpc (ipc: IpcMain): void {
|
||||||
|
const router = new IpcRouter(ipc)
|
||||||
|
for (const controller of this.controllers) controller.register(router)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import type { BrowserWindow } from 'electron'
|
||||||
|
import {
|
||||||
|
asRecord, readBoolean, readNumber, readOptionalString, readString, readStringArray
|
||||||
|
} from '../../infrastructure/json/JsonRecord'
|
||||||
|
|
||||||
|
const SETTLE_DELAY_MS = 6_000
|
||||||
|
const SWITCH_SETTLE_DELAY_MS = 8_000
|
||||||
|
const SHOT_FRAME_DELAY_MS = 400
|
||||||
|
|
||||||
|
/** What the window says about itself once it has painted. */
|
||||||
|
interface SelfTestReport {
|
||||||
|
readonly cards: number
|
||||||
|
readonly installed: number
|
||||||
|
readonly buttons: number
|
||||||
|
readonly gateVisible: boolean
|
||||||
|
readonly gateTitle: string
|
||||||
|
readonly gateChoices: readonly string[]
|
||||||
|
readonly gateAction: string
|
||||||
|
readonly appName: string
|
||||||
|
readonly storeId: string
|
||||||
|
readonly navOpen: boolean
|
||||||
|
readonly stores: readonly string[]
|
||||||
|
readonly categories: readonly string[]
|
||||||
|
readonly activeCategory: string | null
|
||||||
|
readonly paths: string
|
||||||
|
readonly logLines: number
|
||||||
|
readonly locales: readonly string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What changed after clicking a store that was not open. */
|
||||||
|
interface StoreSwitchReport {
|
||||||
|
readonly storeId: string
|
||||||
|
readonly active: string | null
|
||||||
|
readonly cards: number
|
||||||
|
readonly categories: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the window once and reports what rendered.
|
||||||
|
*
|
||||||
|
* This is the only check that would notice a renderer error at all: the main
|
||||||
|
* process log stays empty when the page throws. Counting nodes is not enough on its
|
||||||
|
* own — the collapsed-grid bug passed every count while showing neither box art nor
|
||||||
|
* buttons — so `SELFTEST_SHOT` has the window photograph itself for a human to look
|
||||||
|
* at.
|
||||||
|
*/
|
||||||
|
export class SelfTestRunner {
|
||||||
|
public constructor (
|
||||||
|
private readonly window: BrowserWindow,
|
||||||
|
private readonly shotPath: string | null = process.env['SELFTEST_SHOT'] ?? null
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public get settleDelayMs (): number {
|
||||||
|
return SETTLE_DELAY_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the window is in a state a user could work with. */
|
||||||
|
public async run (): Promise<boolean> {
|
||||||
|
const report = await this.readReport()
|
||||||
|
console.log(JSON.stringify(report, null, 2))
|
||||||
|
|
||||||
|
const switched = report.stores.length > 1 ? await this.switchStore() : null
|
||||||
|
if (switched !== null) console.log(`switched: ${JSON.stringify(switched)}`)
|
||||||
|
|
||||||
|
if (this.shotPath !== null) await this.captureShot(this.shotPath)
|
||||||
|
|
||||||
|
// A gate passes on having something to do, not on having a picker: the picker
|
||||||
|
// only appears when the registry offers more than one store, and one store is
|
||||||
|
// the ordinary case. Requiring choices here failed a perfectly good window.
|
||||||
|
const rendered = report.locales.length > 1 && (
|
||||||
|
(report.cards > 0 && !report.gateVisible && report.stores.length > 0 &&
|
||||||
|
report.categories.length > 0 && report.activeCategory !== null) ||
|
||||||
|
(report.gateVisible && report.gateAction.length > 0))
|
||||||
|
const switchedWell = switched === null || (
|
||||||
|
switched.storeId.length > 0 && switched.storeId !== report.storeId &&
|
||||||
|
switched.cards > 0 && switched.categories > 0)
|
||||||
|
|
||||||
|
const passed = rendered && switchedWell
|
||||||
|
console.log(passed ? 'SELFTEST OK' : 'SELFTEST FAILED')
|
||||||
|
return passed
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readReport (): Promise<SelfTestReport> {
|
||||||
|
const record = asRecord(JSON.parse(await this.evaluate(`JSON.stringify({
|
||||||
|
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,
|
||||||
|
gateChoices: [...document.getElementById('gate-select').options].map((option) => option.text),
|
||||||
|
gateAction: document.getElementById('gate-action').textContent,
|
||||||
|
appName: document.getElementById('app-name').textContent,
|
||||||
|
storeId: document.getElementById('store-id').textContent,
|
||||||
|
navOpen: !document.body.classList.contains('nav-closed'),
|
||||||
|
stores: [...document.querySelectorAll('#store-list .store-row')].map((row) => row.textContent),
|
||||||
|
categories: [...document.querySelectorAll('#cats .cat')].map((cat) => cat.textContent),
|
||||||
|
activeCategory: (document.querySelector('#cats .cat.is-active') || {}).textContent || null,
|
||||||
|
paths: document.getElementById('log-paths').textContent.slice(0, 120),
|
||||||
|
logLines: document.querySelectorAll('.log-line').length,
|
||||||
|
locales: [...document.getElementById('locale').options].map((option) => option.value)
|
||||||
|
})`))) ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cards: readNumber(record, 'cards'),
|
||||||
|
installed: readNumber(record, 'installed'),
|
||||||
|
buttons: readNumber(record, 'buttons'),
|
||||||
|
gateVisible: readBoolean(record, 'gateVisible'),
|
||||||
|
gateTitle: readString(record, 'gateTitle'),
|
||||||
|
gateChoices: readStringArray(record, 'gateChoices'),
|
||||||
|
gateAction: readString(record, 'gateAction'),
|
||||||
|
appName: readString(record, 'appName'),
|
||||||
|
storeId: readString(record, 'storeId'),
|
||||||
|
navOpen: readBoolean(record, 'navOpen'),
|
||||||
|
stores: readStringArray(record, 'stores'),
|
||||||
|
categories: readStringArray(record, 'categories'),
|
||||||
|
activeCategory: readOptionalString(record, 'activeCategory'),
|
||||||
|
paths: readString(record, 'paths'),
|
||||||
|
logLines: readNumber(record, 'logLines'),
|
||||||
|
locales: readStringArray(record, 'locales')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With two stores on the machine the switcher is the thing most likely to break
|
||||||
|
* without anyone noticing, so the test uses it. Skipped with one store, which
|
||||||
|
* cannot be switched away from.
|
||||||
|
*/
|
||||||
|
private async switchStore (): Promise<StoreSwitchReport> {
|
||||||
|
const record = asRecord(JSON.parse(await this.evaluate(`(async () => {
|
||||||
|
const other = [...document.querySelectorAll('#store-list .store-row')]
|
||||||
|
.find((row) => !row.classList.contains('is-active'))
|
||||||
|
other.click()
|
||||||
|
await new Promise((done) => setTimeout(done, ${String(SWITCH_SETTLE_DELAY_MS)}))
|
||||||
|
return JSON.stringify({
|
||||||
|
storeId: document.getElementById('store-id').textContent,
|
||||||
|
active: (document.querySelector('#store-list .store-row.is-active') || {}).textContent || null,
|
||||||
|
cards: document.querySelectorAll('.card').length,
|
||||||
|
categories: document.querySelectorAll('#cats .cat').length
|
||||||
|
})
|
||||||
|
})()`))) ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
storeId: readString(record, 'storeId'),
|
||||||
|
active: readOptionalString(record, 'active'),
|
||||||
|
cards: readNumber(record, 'cards'),
|
||||||
|
categories: readNumber(record, 'categories')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* capturePage hands back the last painted frame, so a window that is behind
|
||||||
|
* others — or still loading box art — photographs as a half-drawn page. Focus it,
|
||||||
|
* wait for the images, then let one frame go by.
|
||||||
|
*/
|
||||||
|
private async captureShot (target: string): Promise<void> {
|
||||||
|
this.window.show()
|
||||||
|
this.window.focus()
|
||||||
|
await this.evaluate(`(async () => {
|
||||||
|
await Promise.all([...document.images].map((image) => image.complete
|
||||||
|
? null
|
||||||
|
: new Promise((done) => { image.onload = done; image.onerror = done })))
|
||||||
|
await new Promise((done) => requestAnimationFrame(() => setTimeout(done, ${String(SHOT_FRAME_DELAY_MS)})))
|
||||||
|
return String(document.images.length)
|
||||||
|
})()`)
|
||||||
|
const image = await this.window.webContents.capturePage()
|
||||||
|
fs.writeFileSync(target, image.toPNG())
|
||||||
|
console.log(`shot: ${target}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every probe returns a JSON string, so nothing untyped crosses back. */
|
||||||
|
private async evaluate (script: string): Promise<string> {
|
||||||
|
const result: unknown = await this.window.webContents.executeJavaScript(script)
|
||||||
|
return typeof result === 'string' ? result : JSON.stringify(result ?? null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { ApplicationStateService } from '../../application/services/ApplicationStateService'
|
||||||
|
import type { GameLaunchService } from '../../application/services/GameLaunchService'
|
||||||
|
import type { PreferencesService } from '../../application/services/PreferencesService'
|
||||||
|
import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels'
|
||||||
|
import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto'
|
||||||
|
import type { LocaleSelectionDto } from '../../shared/contracts/dto/LocaleSelectionDto'
|
||||||
|
import { TranslationCatalog } from '../../shared/i18n/TranslationCatalog'
|
||||||
|
import { requireBoolean, requireString } from './IpcArguments'
|
||||||
|
import type { IpcRouter } from './IpcRouter'
|
||||||
|
|
||||||
|
/** The window's own concerns: what it needs to paint, its language, its menu state. */
|
||||||
|
export class AppIpcController {
|
||||||
|
public constructor (
|
||||||
|
private readonly state: ApplicationStateService,
|
||||||
|
private readonly preferences: PreferencesService,
|
||||||
|
private readonly launching: GameLaunchService,
|
||||||
|
private readonly translations: TranslationCatalog = new TranslationCatalog()
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public register (router: IpcRouter): void {
|
||||||
|
router.handle(IPC_CHANNELS.appReadState, (): AppStateDto => this.handleReadState())
|
||||||
|
router.handle(IPC_CHANNELS.appUpdateLocale, (locale: unknown): LocaleSelectionDto =>
|
||||||
|
this.handleUpdateLocale(requireString(locale, 'locale')))
|
||||||
|
router.handle(IPC_CHANNELS.appUpdateNavOpen, (open: unknown): boolean =>
|
||||||
|
this.handleUpdateNavOpen(requireBoolean(open, 'open')))
|
||||||
|
router.handle(IPC_CHANNELS.appOpenFolder, async (directory: unknown): Promise<boolean> =>
|
||||||
|
this.handleOpenFolder(requireString(directory, 'directory')))
|
||||||
|
router.handle(IPC_CHANNELS.appOpenUrl, async (url: unknown): Promise<boolean> =>
|
||||||
|
this.handleOpenUrl(requireString(url, 'url')))
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReadState (): AppStateDto {
|
||||||
|
return this.state.readState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleUpdateLocale (candidate: string): LocaleSelectionDto {
|
||||||
|
const locale = this.preferences.updateLocale(candidate)
|
||||||
|
return { locale, messages: this.translations.readBundle(locale) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleUpdateNavOpen (open: boolean): boolean {
|
||||||
|
return this.preferences.updateNavigationOpen(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleOpenFolder (directory: string): Promise<boolean> {
|
||||||
|
return this.launching.openFolder(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleOpenUrl (url: string): Promise<boolean> {
|
||||||
|
return this.launching.openUrl(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { CatalogService } from '../../application/services/CatalogService'
|
||||||
|
import type { GameLaunchService } from '../../application/services/GameLaunchService'
|
||||||
|
import { GameDtoMapper } from '../../application/mappers/GameDtoMapper'
|
||||||
|
import { StorePathsDtoMapper } from '../../application/mappers/StorePathsDtoMapper'
|
||||||
|
import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels'
|
||||||
|
import type { CatalogListingDto } from '../../shared/contracts/dto/CatalogListingDto'
|
||||||
|
import type { StorePathsDto } from '../../shared/contracts/dto/StorePathsDto'
|
||||||
|
import type { WindowStreamBroadcaster } from '../streams/WindowStreamBroadcaster'
|
||||||
|
import { requireString, requireStringArray } from './IpcArguments'
|
||||||
|
import type { IpcRouter } from './IpcRouter'
|
||||||
|
import type { SingleFlightGuard } from './SingleFlightGuard'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything that touches the catalog.
|
||||||
|
*
|
||||||
|
* The four engine calls go through the guard; launching does not, because it starts
|
||||||
|
* someone else's program and writes nothing.
|
||||||
|
*/
|
||||||
|
export class CatalogIpcController {
|
||||||
|
public constructor (
|
||||||
|
private readonly catalog: CatalogService,
|
||||||
|
private readonly launching: GameLaunchService,
|
||||||
|
private readonly guard: SingleFlightGuard,
|
||||||
|
private readonly streams: WindowStreamBroadcaster,
|
||||||
|
private readonly gameMapper: GameDtoMapper = new GameDtoMapper(),
|
||||||
|
private readonly pathsMapper: StorePathsDtoMapper = new StorePathsDtoMapper()
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public register (router: IpcRouter): void {
|
||||||
|
router.handle(IPC_CHANNELS.catalogListGames, async (): Promise<CatalogListingDto> =>
|
||||||
|
this.handleListGames())
|
||||||
|
router.handle(IPC_CHANNELS.catalogReadPaths, async (): Promise<StorePathsDto> =>
|
||||||
|
this.handleReadPaths())
|
||||||
|
router.handle(IPC_CHANNELS.catalogSyncGames, async (names: unknown): Promise<void> =>
|
||||||
|
this.handleSyncGames(names === undefined ? [] : requireStringArray(names, 'names')))
|
||||||
|
router.handle(IPC_CHANNELS.catalogRemoveGame, async (name: unknown): Promise<void> =>
|
||||||
|
this.handleRemoveGame(requireString(name, 'name')))
|
||||||
|
router.handle(IPC_CHANNELS.catalogLaunchGame, async (name: unknown): Promise<boolean> =>
|
||||||
|
this.handleLaunchGame(requireString(name, 'name')))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleListGames (): Promise<CatalogListingDto> {
|
||||||
|
return this.guard.run(async (): Promise<CatalogListingDto> => {
|
||||||
|
const listing = await this.catalog.listGames(this.streams.asProgressListener())
|
||||||
|
const baseUrl = listing.paths?.catalogBaseUrl ?? ''
|
||||||
|
return {
|
||||||
|
games: this.gameMapper.toDtoList(listing.games, baseUrl),
|
||||||
|
skipped: listing.skipped,
|
||||||
|
paths: listing.paths === null ? null : this.pathsMapper.toDto(listing.paths)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleReadPaths (): Promise<StorePathsDto> {
|
||||||
|
return this.guard.run(async (): Promise<StorePathsDto> =>
|
||||||
|
this.pathsMapper.toDto(await this.catalog.readPaths(this.streams.asProgressListener())))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSyncGames (names: readonly string[]): Promise<void> {
|
||||||
|
await this.guard.run(async (): Promise<void> => {
|
||||||
|
await this.catalog.syncGames(names, this.streams.asProgressListener())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRemoveGame (name: string): Promise<void> {
|
||||||
|
await this.guard.run(async (): Promise<void> => {
|
||||||
|
await this.catalog.removeGame(name, this.streams.asProgressListener())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleLaunchGame (name: string): Promise<boolean> {
|
||||||
|
return this.launching.launchGame(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { asRecord, readString } from '../../infrastructure/json/JsonRecord'
|
||||||
|
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reading what came over the bridge.
|
||||||
|
*
|
||||||
|
* The window is ours, but the channel is an interface: a payload is checked here
|
||||||
|
* once, so no service below has to wonder whether a string is really a string.
|
||||||
|
*/
|
||||||
|
export function requireString (value: unknown, name: string): string {
|
||||||
|
if (typeof value !== 'string' || value.length === 0) {
|
||||||
|
throw new TypeError(`${name} must be a non-empty string`)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireBoolean (value: unknown, name: string): boolean {
|
||||||
|
if (typeof value !== 'boolean') throw new TypeError(`${name} must be a boolean`)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireStringArray (value: unknown, name: string): readonly string[] {
|
||||||
|
if (!Array.isArray(value)) throw new TypeError(`${name} must be an array of strings`)
|
||||||
|
return value.map((item: unknown, index: number): string => requireString(item, `${name}[${String(index)}]`))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireRegistryStore (value: unknown): RegistryStoreDto {
|
||||||
|
const record = asRecord(value)
|
||||||
|
if (record === null) throw new TypeError('a store record is required')
|
||||||
|
const repository = readString(record, 'storeRepositoryUrl')
|
||||||
|
const store: RegistryStoreDto = {
|
||||||
|
name: readString(record, 'name'),
|
||||||
|
catalogUrl: readString(record, 'catalogUrl'),
|
||||||
|
// Optional: a store with no repository installs on the engine's defaults.
|
||||||
|
storeRepositoryUrl: repository.length > 0 ? repository : null,
|
||||||
|
storeId: readString(record, 'storeId')
|
||||||
|
}
|
||||||
|
if (store.name.length === 0 || store.catalogUrl.length === 0) {
|
||||||
|
throw new TypeError('a store record needs a name and a catalog URL')
|
||||||
|
}
|
||||||
|
return store
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { IpcMain, IpcMainInvokeEvent } from 'electron'
|
||||||
|
import { DomainError } from '../../domain/errors/DomainError'
|
||||||
|
import type { IpcChannel } from '../../shared/contracts/IpcChannels'
|
||||||
|
|
||||||
|
/** What a channel does with the arguments it was invoked with. */
|
||||||
|
export type IpcHandler<TResult> = (...args: readonly unknown[]) => Promise<TResult> | TResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place a channel is registered.
|
||||||
|
*
|
||||||
|
* Errors are normalised on the way out: a domain error crosses as `CODE: message`
|
||||||
|
* so the log drawer shows something a person can act on, and an unexpected one is
|
||||||
|
* logged here rather than vanishing into a rejected promise the window cannot read.
|
||||||
|
*/
|
||||||
|
export class IpcRouter {
|
||||||
|
public constructor (private readonly ipc: IpcMain) {}
|
||||||
|
|
||||||
|
public handle<TResult>(channel: IpcChannel, handler: IpcHandler<TResult>): void {
|
||||||
|
this.ipc.handle(channel, async (_event: IpcMainInvokeEvent, ...args: readonly unknown[]): Promise<TResult> => {
|
||||||
|
try {
|
||||||
|
return await handler(...args)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw this.describe(channel, error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private describe (channel: IpcChannel, error: unknown): Error {
|
||||||
|
if (error instanceof DomainError) return new Error(`${error.code}: ${error.message}`)
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error(`[ipc] ${channel} failed: ${error.message}`)
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
console.error(`[ipc] ${channel} failed: ${String(error)}`)
|
||||||
|
return new Error(String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { BusyError } from '../../domain/errors/BusyError'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One engine call at a time.
|
||||||
|
*
|
||||||
|
* The store writes files, and two writers would race. Callers are told which state
|
||||||
|
* the guard is in, so the window can disable exactly what would start a second
|
||||||
|
* call and leave the rest alive.
|
||||||
|
*/
|
||||||
|
export class SingleFlightGuard {
|
||||||
|
private running = false
|
||||||
|
|
||||||
|
public constructor (private readonly onBusyChanged: (busy: boolean) => void) {}
|
||||||
|
|
||||||
|
public get busy (): boolean {
|
||||||
|
return this.running
|
||||||
|
}
|
||||||
|
|
||||||
|
public async run<TResult>(task: () => Promise<TResult>): Promise<TResult> {
|
||||||
|
if (this.running) throw new BusyError()
|
||||||
|
this.running = true
|
||||||
|
this.onBusyChanged(true)
|
||||||
|
try {
|
||||||
|
return await task()
|
||||||
|
} finally {
|
||||||
|
this.running = false
|
||||||
|
this.onBusyChanged(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { InstalledStoreDtoMapper } from '../../application/mappers/InstalledStoreDtoMapper'
|
||||||
|
import { EngineVersionDtoMapper } from '../../application/mappers/EngineVersionDtoMapper'
|
||||||
|
import { RegistryStoreDtoMapper } from '../../application/mappers/RegistryStoreDtoMapper'
|
||||||
|
import type { StoreProvisioningService } from '../../application/services/StoreProvisioningService'
|
||||||
|
import type { StoreSelectionService } from '../../application/services/StoreSelectionService'
|
||||||
|
import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels'
|
||||||
|
import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto'
|
||||||
|
import type { RegistryResultDto } from '../../shared/contracts/dto/RegistryResultDto'
|
||||||
|
import type { StoreSelectionDto } from '../../shared/contracts/dto/StoreSelectionDto'
|
||||||
|
import type { WindowStreamBroadcaster } from '../streams/WindowStreamBroadcaster'
|
||||||
|
import { requireRegistryStore, requireString } from './IpcArguments'
|
||||||
|
import type { IpcRouter } from './IpcRouter'
|
||||||
|
import type { SingleFlightGuard } from './SingleFlightGuard'
|
||||||
|
|
||||||
|
/** Which stores exist, which one is open, and installing a new one. */
|
||||||
|
export class StoreIpcController {
|
||||||
|
public constructor (
|
||||||
|
private readonly provisioning: StoreProvisioningService,
|
||||||
|
private readonly selection: StoreSelectionService,
|
||||||
|
private readonly guard: SingleFlightGuard,
|
||||||
|
private readonly streams: WindowStreamBroadcaster,
|
||||||
|
private readonly registryMapper: RegistryStoreDtoMapper = new RegistryStoreDtoMapper(),
|
||||||
|
private readonly storeMapper: InstalledStoreDtoMapper = new InstalledStoreDtoMapper(),
|
||||||
|
private readonly engineMapper: EngineVersionDtoMapper = new EngineVersionDtoMapper()
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public register (router: IpcRouter): void {
|
||||||
|
router.handle(IPC_CHANNELS.storeListRegistry, async (): Promise<RegistryResultDto> =>
|
||||||
|
this.handleListRegistry())
|
||||||
|
router.handle(IPC_CHANNELS.storeInstallStore, async (store: unknown): Promise<InstalledStoreDto> =>
|
||||||
|
this.handleInstallStore(store))
|
||||||
|
router.handle(IPC_CHANNELS.storeSelectStore, (home: unknown): StoreSelectionDto =>
|
||||||
|
this.handleSelectStore(requireString(home, 'home')))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The registry lookup never rejects: the window has to say *why* there is nothing
|
||||||
|
* to install, and an unreachable site and an empty list need different words.
|
||||||
|
*/
|
||||||
|
private async handleListRegistry (): Promise<RegistryResultDto> {
|
||||||
|
try {
|
||||||
|
const stores = await this.provisioning.listAvailableStores()
|
||||||
|
return {
|
||||||
|
stores: this.registryMapper.toDtoList(stores),
|
||||||
|
sourceUrl: this.provisioning.registryUrl,
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
stores: [],
|
||||||
|
sourceUrl: this.provisioning.registryUrl,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleInstallStore (payload: unknown): Promise<InstalledStoreDto> {
|
||||||
|
const chosen = this.registryMapper.toModel(requireRegistryStore(payload))
|
||||||
|
return this.guard.run(async (): Promise<InstalledStoreDto> => {
|
||||||
|
const installed = await this.provisioning.installStore(chosen, this.streams.asProgressListener())
|
||||||
|
return this.storeMapper.toDto(installed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSelectStore (home: string): StoreSelectionDto {
|
||||||
|
const store = this.selection.selectStore(home)
|
||||||
|
const engine = this.selection.findEngineVersion(store)
|
||||||
|
return {
|
||||||
|
store: this.storeMapper.toDto(store),
|
||||||
|
engine: engine === null ? null : this.engineMapper.toDto(engine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { app, ipcMain, shell } from 'electron'
|
||||||
|
import { ElectronApplication } from './ElectronApplication'
|
||||||
|
|
||||||
|
// The entry point does one thing: everything else is a class with a name.
|
||||||
|
new ElectronApplication(app, ipcMain, shell).start()
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { BrowserWindow } from 'electron'
|
||||||
|
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
|
||||||
|
import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels'
|
||||||
|
import type { SyncEventDto } from '../../shared/contracts/dto/SyncEventDto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three one-way streams to the window: log lines, progress events, busy state.
|
||||||
|
*
|
||||||
|
* Holds no window of its own — the reference is handed in when one exists and
|
||||||
|
* cleared when it does not, so a stream that outlives the window is a no-op rather
|
||||||
|
* than a crash.
|
||||||
|
*/
|
||||||
|
export class WindowStreamBroadcaster {
|
||||||
|
private window: BrowserWindow | null = null
|
||||||
|
|
||||||
|
public attachWindow (window: BrowserWindow): void {
|
||||||
|
this.window = window
|
||||||
|
}
|
||||||
|
|
||||||
|
public detachWindow (): void {
|
||||||
|
this.window = null
|
||||||
|
}
|
||||||
|
|
||||||
|
public publishLog (line: string): void {
|
||||||
|
this.send(IPC_CHANNELS.streamLog, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
public publishSyncEvent (event: SyncEventDto): void {
|
||||||
|
this.send(IPC_CHANNELS.streamSyncEvent, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
public publishBusyChanged (busy: boolean): void {
|
||||||
|
this.send(IPC_CHANNELS.streamBusyChanged, busy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A progress listener wired to these streams, for handing to the engine. */
|
||||||
|
public asProgressListener (): EngineProgressListener {
|
||||||
|
return {
|
||||||
|
onLog: (line: string): void => { this.publishLog(line) },
|
||||||
|
onEvent: (event: SyncEventDto): void => { this.publishSyncEvent(event) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private send (channel: string, payload: unknown): void {
|
||||||
|
const window = this.window
|
||||||
|
if (window === null || window.isDestroyed()) return
|
||||||
|
window.webContents.send(channel, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||||
|
import {
|
||||||
|
BRIDGE_GLOBAL_NAME, type BridgeApi, type StreamListener
|
||||||
|
} from '../shared/contracts/BridgeApi'
|
||||||
|
import { IPC_CHANNELS } from '../shared/contracts/IpcChannels'
|
||||||
|
import type { AppStateDto } from '../shared/contracts/dto/AppStateDto'
|
||||||
|
import type { CatalogListingDto } from '../shared/contracts/dto/CatalogListingDto'
|
||||||
|
import type { InstalledStoreDto } from '../shared/contracts/dto/InstalledStoreDto'
|
||||||
|
import type { LocaleSelectionDto } from '../shared/contracts/dto/LocaleSelectionDto'
|
||||||
|
import type { RegistryResultDto } from '../shared/contracts/dto/RegistryResultDto'
|
||||||
|
import type { RegistryStoreDto } from '../shared/contracts/dto/RegistryStoreDto'
|
||||||
|
import type { StorePathsDto } from '../shared/contracts/dto/StorePathsDto'
|
||||||
|
import type { StoreSelectionDto } from '../shared/contracts/dto/StoreSelectionDto'
|
||||||
|
import type { SyncEventDto } from '../shared/contracts/dto/SyncEventDto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bridge, and nothing else.
|
||||||
|
*
|
||||||
|
* This file is the whole surface the window gets: no Node, no filesystem, no child
|
||||||
|
* processes. It is bundled into a single script on purpose — a sandboxed preload
|
||||||
|
* cannot require its own modules — and it implements `BridgeApi`, so the renderer
|
||||||
|
* and the main process are compiled against the same contract.
|
||||||
|
*/
|
||||||
|
const bridge: BridgeApi = {
|
||||||
|
readState: async (): Promise<AppStateDto> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.appReadState) as Promise<AppStateDto>,
|
||||||
|
updateLocale: async (locale: string): Promise<LocaleSelectionDto> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.appUpdateLocale, locale) as Promise<LocaleSelectionDto>,
|
||||||
|
updateNavOpen: async (open: boolean): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.appUpdateNavOpen, open) as Promise<boolean>,
|
||||||
|
|
||||||
|
listGames: async (): Promise<CatalogListingDto> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.catalogListGames) as Promise<CatalogListingDto>,
|
||||||
|
readPaths: async (): Promise<StorePathsDto> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.catalogReadPaths) as Promise<StorePathsDto>,
|
||||||
|
syncGames: async (names: readonly string[]): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.catalogSyncGames, names) as Promise<void>,
|
||||||
|
removeGame: async (name: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.catalogRemoveGame, name) as Promise<void>,
|
||||||
|
launchGame: async (name: string): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.catalogLaunchGame, name) as Promise<boolean>,
|
||||||
|
|
||||||
|
listRegistryStores: async (): Promise<RegistryResultDto> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.storeListRegistry) as Promise<RegistryResultDto>,
|
||||||
|
installStore: async (store: RegistryStoreDto): Promise<InstalledStoreDto> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.storeInstallStore, store) as Promise<InstalledStoreDto>,
|
||||||
|
selectStore: async (home: string): Promise<StoreSelectionDto> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.storeSelectStore, home) as Promise<StoreSelectionDto>,
|
||||||
|
|
||||||
|
openFolder: async (directory: string): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.appOpenFolder, directory) as Promise<boolean>,
|
||||||
|
openUrl: async (url: string): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.appOpenUrl, url) as Promise<boolean>,
|
||||||
|
|
||||||
|
onLog: (listener: StreamListener<string>): void => {
|
||||||
|
ipcRenderer.on(IPC_CHANNELS.streamLog, (_event: IpcRendererEvent, line: string): void => {
|
||||||
|
listener(line)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSyncEvent: (listener: StreamListener<SyncEventDto>): void => {
|
||||||
|
ipcRenderer.on(IPC_CHANNELS.streamSyncEvent, (_event: IpcRendererEvent, payload: SyncEventDto): void => {
|
||||||
|
listener(payload)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onBusyChanged: (listener: StreamListener<boolean>): void => {
|
||||||
|
ipcRenderer.on(IPC_CHANNELS.streamBusyChanged, (_event: IpcRendererEvent, busy: boolean): void => {
|
||||||
|
listener(busy)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld(BRIDGE_GLOBAL_NAME, bridge)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { BRIDGE_GLOBAL_NAME, type BridgeApi } from '../shared/contracts/BridgeApi'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
readonly storeApi?: BridgeApi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bridge the preload published.
|
||||||
|
*
|
||||||
|
* Absent means the preload did not run, which is a packaging fault rather than a
|
||||||
|
* runtime condition — so it fails here, once, with a sentence that says what happened.
|
||||||
|
*/
|
||||||
|
export function requireBridge (): BridgeApi {
|
||||||
|
const bridge = window.storeApi
|
||||||
|
if (bridge === undefined) {
|
||||||
|
throw new Error(`window.${BRIDGE_GLOBAL_NAME} is missing — the preload script did not run`)
|
||||||
|
}
|
||||||
|
return bridge
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { BridgeApi } from '../shared/contracts/BridgeApi'
|
||||||
|
import { requireBridge } from './BridgeAccess'
|
||||||
|
import { CatalogController } from './controllers/CatalogController'
|
||||||
|
import { EngineStreamController } from './controllers/EngineStreamController'
|
||||||
|
import { PreferencesController } from './controllers/PreferencesController'
|
||||||
|
import { StoreController } from './controllers/StoreController'
|
||||||
|
import { AppStore, type AppState } from './state/AppStore'
|
||||||
|
import { buildCategorySections, resolveFilter, type CategoryFilter } from './state/CategoryFilter'
|
||||||
|
import { CatalogGridView } from './views/CatalogGridView'
|
||||||
|
import { GameCardView } from './views/GameCardView'
|
||||||
|
import { GateView } from './views/GateView'
|
||||||
|
import { LogDrawerView } from './views/LogDrawerView'
|
||||||
|
import { SideMenuView } from './views/SideMenuView'
|
||||||
|
import { TopBarView } from './views/TopBarView'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window, assembled.
|
||||||
|
*
|
||||||
|
* The flow is one direction only: a control calls a controller, the controller calls
|
||||||
|
* the bridge and writes to the store, and the store re-renders every view. No view
|
||||||
|
* reads another view, and nothing but the store decides what is on screen.
|
||||||
|
*/
|
||||||
|
export class RendererApplication {
|
||||||
|
private readonly store = new AppStore()
|
||||||
|
private readonly bridge: BridgeApi
|
||||||
|
private readonly log: LogDrawerView
|
||||||
|
private readonly gate: GateView
|
||||||
|
private readonly grid: CatalogGridView
|
||||||
|
private readonly topBar: TopBarView
|
||||||
|
private readonly sideMenu: SideMenuView
|
||||||
|
private readonly catalog: CatalogController
|
||||||
|
private readonly stores: StoreController
|
||||||
|
private readonly preferences: PreferencesController
|
||||||
|
private readonly streams: EngineStreamController
|
||||||
|
|
||||||
|
public constructor (bridge: BridgeApi = requireBridge()) {
|
||||||
|
this.bridge = bridge
|
||||||
|
|
||||||
|
this.log = new LogDrawerView({
|
||||||
|
onOpenFolder: (directory: string): void => { void this.bridge.openFolder(directory) }
|
||||||
|
})
|
||||||
|
this.gate = new GateView((url: string): void => { void this.bridge.openUrl(url) })
|
||||||
|
this.catalog = new CatalogController(this.bridge, this.store, this.log)
|
||||||
|
this.stores = new StoreController(this.bridge, this.store, this.log, this.catalog)
|
||||||
|
this.preferences = new PreferencesController(this.bridge, this.store)
|
||||||
|
this.streams = new EngineStreamController(this.bridge, this.store, this.log)
|
||||||
|
|
||||||
|
this.grid = new CatalogGridView(new GameCardView({
|
||||||
|
onInstall: (name: string): void => { void this.catalog.syncGames([name]) },
|
||||||
|
onLaunch: (name: string): void => { void this.catalog.launchGame(name) },
|
||||||
|
onRemove: (name: string): void => { void this.catalog.removeGame(name) }
|
||||||
|
}))
|
||||||
|
this.topBar = new TopBarView({
|
||||||
|
onToggleNavigation: (): void => { void this.preferences.toggleNavigation() }
|
||||||
|
})
|
||||||
|
this.sideMenu = new SideMenuView({
|
||||||
|
onSelectStore: (home: string): void => { void this.stores.selectStore(home) },
|
||||||
|
onAddStore: (): void => { void this.stores.offerStores() },
|
||||||
|
onRefresh: (): void => { void this.catalog.refresh() },
|
||||||
|
onSelectCategory: (filter: CategoryFilter): void => { this.store.applyFilter(filter) },
|
||||||
|
onSelectLocale: (locale: string): void => { void this.preferences.selectLocale(locale) }
|
||||||
|
})
|
||||||
|
|
||||||
|
this.store.subscribe((state: AppState): void => { this.render(state) })
|
||||||
|
this.streams.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decides what the window is showing, then hands over to the views. */
|
||||||
|
public async start (): Promise<void> {
|
||||||
|
this.store.applyAppState(await this.bridge.readState())
|
||||||
|
const state = this.store.readState()
|
||||||
|
|
||||||
|
if (state.pythonVersion === null) {
|
||||||
|
this.stores.showMissingPythonGate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state.currentStore === null) {
|
||||||
|
await this.stores.offerStores()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state.engine !== null && !state.engine.supported) {
|
||||||
|
this.stores.showOutdatedEngineGate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.store.applyGate(null)
|
||||||
|
await this.catalog.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private render (state: AppState): void {
|
||||||
|
// The filter is corrected before anything is drawn from it, so the menu and the
|
||||||
|
// grid can never disagree about which category is active.
|
||||||
|
const corrected = resolveFilter(state.filter, buildCategorySections(state.games, state.messages))
|
||||||
|
if (corrected !== state.filter) {
|
||||||
|
this.store.applyFilter(corrected)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.classList.toggle('nav-closed', !state.navigationOpen)
|
||||||
|
this.topBar.render(state)
|
||||||
|
this.sideMenu.render(state)
|
||||||
|
this.log.render(state)
|
||||||
|
|
||||||
|
// The gate and the grid are alternatives, decided by one field, so they cannot
|
||||||
|
// both be on screen — which is what happened while this was two imperative calls.
|
||||||
|
if (state.gate === null) {
|
||||||
|
this.gate.hide()
|
||||||
|
this.grid.render(state)
|
||||||
|
} else {
|
||||||
|
this.gate.show(state.gate, state.messages)
|
||||||
|
this.grid.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { BridgeApi } from '../../shared/contracts/BridgeApi'
|
||||||
|
import type { CatalogListingDto } from '../../shared/contracts/dto/CatalogListingDto'
|
||||||
|
import type { AppStore } from '../state/AppStore'
|
||||||
|
import type { LogDrawerView } from '../views/LogDrawerView'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalog operations, as the window performs them.
|
||||||
|
*
|
||||||
|
* Every one of them ends in a refresh: the engine is the authority on what is
|
||||||
|
* installed, so the window asks again rather than guessing what changed.
|
||||||
|
*/
|
||||||
|
export class CatalogController {
|
||||||
|
public constructor (
|
||||||
|
private readonly bridge: BridgeApi,
|
||||||
|
private readonly store: AppStore,
|
||||||
|
private readonly log: LogDrawerView
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async refresh (): Promise<void> {
|
||||||
|
try {
|
||||||
|
const listing: CatalogListingDto = await this.bridge.listGames()
|
||||||
|
this.store.applyCatalog(listing.games, listing.paths)
|
||||||
|
for (const reason of listing.skipped) this.log.appendLine(`skipped ${reason}`)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.reportFailure(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async syncGames (names: readonly string[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.bridge.syncGames(names)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.reportFailure(error)
|
||||||
|
}
|
||||||
|
await this.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeGame (name: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.bridge.removeGame(name)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.reportFailure(error)
|
||||||
|
}
|
||||||
|
await this.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
public async launchGame (name: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const launched = await this.bridge.launchGame(name)
|
||||||
|
if (!launched) this.log.appendLine(`${name}: ${this.store.readState().messages.failed}`)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.reportFailure(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportFailure (error: unknown): void {
|
||||||
|
this.log.appendLine(error instanceof Error ? error.message : String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { BridgeApi } from '../../shared/contracts/BridgeApi'
|
||||||
|
import type { SyncEventDto } from '../../shared/contracts/dto/SyncEventDto'
|
||||||
|
import type { AppStore, SyncProgress } from '../state/AppStore'
|
||||||
|
import type { LogDrawerView } from '../views/LogDrawerView'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The engine's own voice: its log, its progress events, and whether it is busy.
|
||||||
|
*
|
||||||
|
* Subscribed once at boot. The counter is derived from the events rather than
|
||||||
|
* guessed: `plan` says how many titles there are, `begin` and `installed` move it.
|
||||||
|
*/
|
||||||
|
export class EngineStreamController {
|
||||||
|
private progress: SyncProgress | null = null
|
||||||
|
|
||||||
|
public constructor (
|
||||||
|
private readonly bridge: BridgeApi,
|
||||||
|
private readonly store: AppStore,
|
||||||
|
private readonly log: LogDrawerView
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public subscribe (): void {
|
||||||
|
this.bridge.onLog((line: string): void => { this.log.appendLine(line) })
|
||||||
|
this.bridge.onBusyChanged((busy: boolean): void => {
|
||||||
|
if (!busy) this.progress = null
|
||||||
|
this.store.applyBusy(busy)
|
||||||
|
})
|
||||||
|
this.bridge.onSyncEvent((event: SyncEventDto): void => { this.handleEvent(event) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleEvent (event: SyncEventDto): void {
|
||||||
|
const messages = this.store.readState().messages
|
||||||
|
switch (event.event) {
|
||||||
|
case 'plan':
|
||||||
|
this.progress = { total: event.count, done: 0, label: `0 ${messages.of} ${String(event.count)}` }
|
||||||
|
this.store.applyProgress(this.progress)
|
||||||
|
return
|
||||||
|
case 'begin': {
|
||||||
|
const current = this.progress
|
||||||
|
if (current === null) return
|
||||||
|
this.progress = {
|
||||||
|
...current,
|
||||||
|
label: `${String(current.done + 1)} ${messages.of} ${String(current.total)} · ${event.title}`
|
||||||
|
}
|
||||||
|
this.store.applyProgress(this.progress)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'installed': {
|
||||||
|
const current = this.progress
|
||||||
|
if (current !== null) {
|
||||||
|
this.progress = { ...current, done: current.done + 1 }
|
||||||
|
this.store.applyProgress(this.progress)
|
||||||
|
}
|
||||||
|
this.log.appendLine(`${event.title} — ${event.changed ? messages.installed : messages.upToDate}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'failed':
|
||||||
|
this.log.appendLine(`${event.name}: ${messages.failed} — ${event.error}`)
|
||||||
|
return
|
||||||
|
case 'removed':
|
||||||
|
this.log.appendLine(`${event.name} — ${messages.removed}`)
|
||||||
|
return
|
||||||
|
case 'pruned':
|
||||||
|
case 'finished':
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { BridgeApi } from '../../shared/contracts/BridgeApi'
|
||||||
|
import type { AppStore } from '../state/AppStore'
|
||||||
|
|
||||||
|
/** The two things the window remembers: its language and whether the menu is open. */
|
||||||
|
export class PreferencesController {
|
||||||
|
public constructor (
|
||||||
|
private readonly bridge: BridgeApi,
|
||||||
|
private readonly store: AppStore
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async selectLocale (candidate: string): Promise<void> {
|
||||||
|
const selection = await this.bridge.updateLocale(candidate)
|
||||||
|
this.store.applyMessages(selection.locale, selection.messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async toggleNavigation (): Promise<void> {
|
||||||
|
const open = !this.store.readState().navigationOpen
|
||||||
|
this.store.applyNavigationOpen(open)
|
||||||
|
await this.bridge.updateNavOpen(open)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { BridgeApi } from '../../shared/contracts/BridgeApi'
|
||||||
|
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
|
||||||
|
import type { AppStore } from '../state/AppStore'
|
||||||
|
import type { LogDrawerView } from '../views/LogDrawerView'
|
||||||
|
import type { CatalogController } from './CatalogController'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which store the window drives, and how one gets onto the machine.
|
||||||
|
*
|
||||||
|
* The gate screens live here because they are all the same decision seen from
|
||||||
|
* different angles: there is no store to show a catalog for, and this is what can be
|
||||||
|
* done about it.
|
||||||
|
*/
|
||||||
|
export class StoreController {
|
||||||
|
public constructor (
|
||||||
|
private readonly bridge: BridgeApi,
|
||||||
|
private readonly store: AppStore,
|
||||||
|
private readonly log: LogDrawerView,
|
||||||
|
private readonly catalog: CatalogController
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Open another store that is already on this machine. */
|
||||||
|
public async selectStore (home: string): Promise<void> {
|
||||||
|
const messages = this.store.readState().messages
|
||||||
|
try {
|
||||||
|
const selection = await this.bridge.selectStore(home)
|
||||||
|
this.store.applySelectedStore(selection.store, selection.engine)
|
||||||
|
if (selection.engine !== null && !selection.engine.supported) {
|
||||||
|
this.showOutdatedEngineGate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.store.applyGate(null)
|
||||||
|
await this.catalog.refresh()
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.log.appendLine(`${messages.switchFailed}: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which stores exist is the site's answer, not this client's: the registry is asked
|
||||||
|
* for it, and its records carry the catalog and the config repository.
|
||||||
|
*/
|
||||||
|
public async offerStores (): Promise<void> {
|
||||||
|
const state = this.store.readState()
|
||||||
|
const messages = state.messages
|
||||||
|
const result = await this.bridge.listRegistryStores()
|
||||||
|
|
||||||
|
if (result.error !== null) {
|
||||||
|
this.store.applyGate({
|
||||||
|
title: messages.registryFailed,
|
||||||
|
body: `${result.sourceUrl}\n\n${result.error}`,
|
||||||
|
action: { label: messages.registryRetry, perform: (): void => { void this.offerStores() } }
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.stores.length === 0) {
|
||||||
|
this.store.applyGate({
|
||||||
|
title: messages.setupTitle,
|
||||||
|
body: `${messages.registryEmpty}\n\n${result.sourceUrl}`
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store.applyGate({
|
||||||
|
title: messages.setupTitle,
|
||||||
|
body: `${messages.setupBody}\n\n${state.defaultStoreRoot}`,
|
||||||
|
action: {
|
||||||
|
label: messages.setupAction,
|
||||||
|
perform: (chosen: RegistryStoreDto | null): void => {
|
||||||
|
if (chosen !== null) void this.installStore(chosen)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
choices: result.stores
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public showOutdatedEngineGate (): void {
|
||||||
|
const state = this.store.readState()
|
||||||
|
const engineText = state.engine === null ? '' : state.engine.text
|
||||||
|
this.store.applyGate({
|
||||||
|
title: state.messages.oldEngineTitle,
|
||||||
|
body: `${state.messages.oldEngineBody}\n\n${engineText} → ${state.minimumEngineVersion}`,
|
||||||
|
action: { label: state.messages.oldEngineAction, perform: (): void => { void this.offerStores() } }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public showMissingPythonGate (): void {
|
||||||
|
const messages = this.store.readState().messages
|
||||||
|
this.store.applyGate({
|
||||||
|
title: messages.noPythonTitle,
|
||||||
|
body: messages.noPythonBody,
|
||||||
|
link: { label: messages.pythonLink, url: 'https://www.python.org/downloads/' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async installStore (chosen: RegistryStoreDto): Promise<void> {
|
||||||
|
const messages = this.store.readState().messages
|
||||||
|
this.store.applyProgress({ total: 0, done: 0, label: messages.setupWorking })
|
||||||
|
try {
|
||||||
|
await this.bridge.installStore(chosen)
|
||||||
|
this.store.applyAppState(await this.bridge.readState())
|
||||||
|
this.store.applyGate(null)
|
||||||
|
await this.catalog.refresh()
|
||||||
|
await this.catalog.syncGames([])
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.log.appendLine(error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
this.store.applyProgress(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* The DOM chores this window has, in one place.
|
||||||
|
*
|
||||||
|
* `requireElement` throws rather than returning null, and checks what it found
|
||||||
|
* against the element type asked for: every id it is called with is in index.html, so
|
||||||
|
* a missing or retyped one is a mistake in this repository and should say so loudly
|
||||||
|
* instead of silently rendering half a window.
|
||||||
|
*/
|
||||||
|
export function requireElement<TElement extends HTMLElement> (
|
||||||
|
id: string,
|
||||||
|
type: abstract new () => TElement
|
||||||
|
): TElement {
|
||||||
|
const found = document.getElementById(id)
|
||||||
|
if (found === null) throw new Error(`the element #${id} is missing from index.html`)
|
||||||
|
if (!(found instanceof type)) throw new Error(`#${id} is not a ${type.name}`)
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createElement<TTag extends keyof HTMLElementTagNameMap> (
|
||||||
|
tag: TTag,
|
||||||
|
className?: string,
|
||||||
|
text?: string
|
||||||
|
): HTMLElementTagNameMap[TTag] {
|
||||||
|
const node = document.createElement(tag)
|
||||||
|
if (className !== undefined) node.className = className
|
||||||
|
if (text !== undefined) node.textContent = text
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setText (node: HTMLElement, value: string | number | null): void {
|
||||||
|
node.textContent = value === null ? '' : String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setHidden (node: HTMLElement, hidden: boolean): void {
|
||||||
|
node.hidden = hidden
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
runs: the app ships its own script and stylesheet. -->
|
runs: the app ships its own script and stylesheet. -->
|
||||||
<meta http-equiv="Content-Security-Policy"
|
<meta http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' https: data:; font-src 'self'; connect-src 'none'">
|
content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' https: data:; font-src 'self'; connect-src 'none'">
|
||||||
<title>WarpEngine Store</title>
|
<title>WarpEngine Client</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -16,8 +16,10 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="bar-title">
|
<div class="bar-title">
|
||||||
<span class="logo" aria-hidden="true">▚</span>
|
<span class="logo" aria-hidden="true">▚</span>
|
||||||
<span id="app-name">WarpEngine Store</span>
|
<span id="app-name">WarpEngine Client</span>
|
||||||
<span class="store-id" id="store-id"></span>
|
<!-- Kept for the window check, which reads it to see which store is open; the
|
||||||
|
side menu is where a person reads that. -->
|
||||||
|
<span class="store-id" id="store-id" hidden></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bar-actions">
|
<div class="bar-actions">
|
||||||
<span class="progress" id="progress" hidden></span>
|
<span class="progress" id="progress" hidden></span>
|
||||||
@@ -36,7 +38,6 @@
|
|||||||
|
|
||||||
<section class="side-block">
|
<section class="side-block">
|
||||||
<h2 class="side-head" id="head-actions"></h2>
|
<h2 class="side-head" id="head-actions"></h2>
|
||||||
<button id="sync-all" class="btn btn-primary btn-wide" disabled></button>
|
|
||||||
<button id="refresh" class="btn btn-wide" disabled></button>
|
<button id="refresh" class="btn btn-wide" disabled></button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="side-block side-foot">
|
<section class="side-block side-foot">
|
||||||
|
<button id="log-toggle" class="side-quiet" aria-expanded="false" aria-controls="log-panel"></button>
|
||||||
<label class="side-lang">
|
<label class="side-lang">
|
||||||
<span id="head-lang"></span>
|
<span id="head-lang"></span>
|
||||||
<select id="locale" class="select" aria-label="Language"></select>
|
<select id="locale" class="select" aria-label="Language"></select>
|
||||||
@@ -72,11 +74,11 @@
|
|||||||
|
|
||||||
<section id="empty" class="empty" hidden></section>
|
<section id="empty" class="empty" hidden></section>
|
||||||
|
|
||||||
<footer class="log">
|
<!-- Only on screen while the log is open; the window has no permanent footer. -->
|
||||||
<button id="log-toggle" class="log-toggle" aria-expanded="false"></button>
|
<section class="log-panel" id="log-panel" hidden>
|
||||||
<div class="log-lines" id="log-lines" hidden></div>
|
<div class="log-lines" id="log-lines"></div>
|
||||||
<div class="log-paths" id="log-paths"></div>
|
<div class="log-paths" id="log-paths"></div>
|
||||||
</footer>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { RendererApplication } from './RendererApplication'
|
||||||
|
|
||||||
|
// The renderer's entry point. Errors here would otherwise be invisible: the main
|
||||||
|
// process log stays empty when the page throws, so the window says it out loud.
|
||||||
|
void new RendererApplication().start().catch((error: unknown): void => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error))
|
||||||
|
})
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto'
|
||||||
|
import type { EngineVersionDto } from '../../shared/contracts/dto/EngineVersionDto'
|
||||||
|
import type { GameDto } from '../../shared/contracts/dto/GameDto'
|
||||||
|
import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto'
|
||||||
|
import type { StorePathsDto } from '../../shared/contracts/dto/StorePathsDto'
|
||||||
|
import { ENGLISH_MESSAGES } from '../../shared/i18n/EnglishMessages'
|
||||||
|
import type { Locale, MessageBundle } from '../../shared/i18n/MessageBundle'
|
||||||
|
import { ALL_CATEGORIES, type CategoryFilter } from './CategoryFilter'
|
||||||
|
import type { GatePresentation } from './GatePresentation'
|
||||||
|
|
||||||
|
/** How far a running sync has got, for the counter in the bar. */
|
||||||
|
export interface SyncProgress {
|
||||||
|
readonly total: number
|
||||||
|
readonly done: number
|
||||||
|
readonly label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the window draws itself from. Read-only to everyone but the store. */
|
||||||
|
export interface AppState {
|
||||||
|
readonly locale: Locale
|
||||||
|
readonly locales: readonly Locale[]
|
||||||
|
readonly messages: MessageBundle
|
||||||
|
readonly navigationOpen: boolean
|
||||||
|
readonly pythonVersion: string | null
|
||||||
|
readonly stores: readonly InstalledStoreDto[]
|
||||||
|
readonly currentStore: InstalledStoreDto | null
|
||||||
|
readonly engine: EngineVersionDto | null
|
||||||
|
readonly minimumEngineVersion: string
|
||||||
|
readonly registryUrl: string
|
||||||
|
readonly defaultStoreRoot: string
|
||||||
|
readonly games: readonly GameDto[]
|
||||||
|
readonly paths: StorePathsDto | null
|
||||||
|
readonly filter: CategoryFilter
|
||||||
|
readonly busy: boolean
|
||||||
|
readonly progress: SyncProgress | null
|
||||||
|
/** Non-null while the setup screen is up, which is also what hides the grid. */
|
||||||
|
readonly gate: GatePresentation | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_STATE: AppState = {
|
||||||
|
locale: 'en',
|
||||||
|
locales: ['en'],
|
||||||
|
messages: ENGLISH_MESSAGES,
|
||||||
|
navigationOpen: true,
|
||||||
|
pythonVersion: null,
|
||||||
|
stores: [],
|
||||||
|
currentStore: null,
|
||||||
|
engine: null,
|
||||||
|
minimumEngineVersion: '',
|
||||||
|
registryUrl: '',
|
||||||
|
defaultStoreRoot: '',
|
||||||
|
games: [],
|
||||||
|
paths: null,
|
||||||
|
filter: ALL_CATEGORIES,
|
||||||
|
busy: false,
|
||||||
|
progress: null,
|
||||||
|
gate: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppStateListener = (state: AppState) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window's single source of truth.
|
||||||
|
*
|
||||||
|
* Every mutator is named after what it changes, and every one of them notifies: the
|
||||||
|
* views re-render from the state rather than being poked individually, so there is no
|
||||||
|
* way to update the model and forget the screen.
|
||||||
|
*/
|
||||||
|
export class AppStore {
|
||||||
|
private state: AppState = INITIAL_STATE
|
||||||
|
private readonly listeners: AppStateListener[] = []
|
||||||
|
|
||||||
|
public readState (): AppState {
|
||||||
|
return this.state
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribe (listener: AppStateListener): void {
|
||||||
|
this.listeners.push(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
public applyAppState (dto: AppStateDto): void {
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
locale: dto.locale,
|
||||||
|
locales: dto.locales,
|
||||||
|
messages: dto.messages,
|
||||||
|
navigationOpen: dto.navigationOpen,
|
||||||
|
pythonVersion: dto.pythonVersion,
|
||||||
|
stores: dto.stores,
|
||||||
|
currentStore: dto.currentStore,
|
||||||
|
engine: dto.engine,
|
||||||
|
minimumEngineVersion: dto.minimumEngineVersion,
|
||||||
|
registryUrl: dto.registryUrl,
|
||||||
|
defaultStoreRoot: dto.defaultStoreRoot
|
||||||
|
}
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
public applyMessages (locale: Locale, messages: MessageBundle): void {
|
||||||
|
this.state = { ...this.state, locale, messages }
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
public applyNavigationOpen (open: boolean): void {
|
||||||
|
this.state = { ...this.state, navigationOpen: open }
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
public applyCatalog (games: readonly GameDto[], paths: StorePathsDto | null): void {
|
||||||
|
this.state = { ...this.state, games, paths: paths ?? this.state.paths }
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
public applySelectedStore (store: InstalledStoreDto, engine: EngineVersionDto | null): void {
|
||||||
|
this.state = { ...this.state, currentStore: store, engine, games: [], paths: null, filter: ALL_CATEGORIES }
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
public applyGate (gate: GatePresentation | null): void {
|
||||||
|
this.state = { ...this.state, gate }
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
public applyFilter (filter: CategoryFilter): void {
|
||||||
|
this.state = { ...this.state, filter }
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
public applyBusy (busy: boolean): void {
|
||||||
|
this.state = { ...this.state, busy, progress: busy ? this.state.progress : null }
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
public applyProgress (progress: SyncProgress | null): void {
|
||||||
|
this.state = { ...this.state, progress }
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify (): void {
|
||||||
|
for (const listener of this.listeners) listener(this.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { GameDto } from '../../shared/contracts/dto/GameDto'
|
||||||
|
import type { MessageBundle } from '../../shared/i18n/MessageBundle'
|
||||||
|
|
||||||
|
/** Which axis a category narrows the grid along. */
|
||||||
|
export type CategoryKind = 'group' | 'platform' | 'mode'
|
||||||
|
|
||||||
|
export interface CategoryFilter {
|
||||||
|
readonly kind: CategoryKind
|
||||||
|
readonly value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryItem extends CategoryFilter {
|
||||||
|
readonly label: string
|
||||||
|
readonly count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategorySection {
|
||||||
|
readonly title: string | null
|
||||||
|
readonly items: readonly CategoryItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ALL_CATEGORIES: CategoryFilter = { kind: 'group', value: 'all' }
|
||||||
|
|
||||||
|
export function isSameFilter (left: CategoryFilter, right: CategoryFilter): boolean {
|
||||||
|
return left.kind === right.kind && left.value === right.value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesFilter (game: GameDto, filter: CategoryFilter): boolean {
|
||||||
|
switch (filter.kind) {
|
||||||
|
case 'platform':
|
||||||
|
return game.platform === filter.value
|
||||||
|
case 'mode':
|
||||||
|
// A title this machine cannot install has no mode worth filtering on: the engine
|
||||||
|
// sends none, and counting it as native would put a C64 cartridge under "native".
|
||||||
|
return game.installable && game.mode === filter.value
|
||||||
|
case 'group':
|
||||||
|
return matchesGroup(game, filter.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesGroup (game: GameDto, group: string): boolean {
|
||||||
|
switch (group) {
|
||||||
|
case 'installed':
|
||||||
|
return game.installed
|
||||||
|
case 'updates':
|
||||||
|
return game.updateAvailable
|
||||||
|
case 'available':
|
||||||
|
return game.installable && !game.installed
|
||||||
|
case 'unsupported':
|
||||||
|
return !game.installable
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The categories, built from what the catalog actually contains.
|
||||||
|
*
|
||||||
|
* There is no genre in a WarpEngine catalog, so the useful axes are the state of a
|
||||||
|
* title on this machine, the platform it was built with, and whether it runs here or
|
||||||
|
* in a browser. Empty axes are left out rather than shown as zeroes, and an axis with
|
||||||
|
* a single value is left out too — a filter that changes nothing is noise.
|
||||||
|
*/
|
||||||
|
export function buildCategorySections (
|
||||||
|
games: readonly GameDto[],
|
||||||
|
messages: MessageBundle
|
||||||
|
): readonly CategorySection[] {
|
||||||
|
const count = (predicate: (game: GameDto) => boolean): number => games.filter(predicate).length
|
||||||
|
const sections: CategorySection[] = []
|
||||||
|
|
||||||
|
const groups: readonly CategoryItem[] = [
|
||||||
|
{ kind: 'group', value: 'all', label: messages.catAll, count: games.length },
|
||||||
|
{ kind: 'group', value: 'installed', label: messages.catInstalled, count: count((game: GameDto): boolean => game.installed) },
|
||||||
|
{ kind: 'group', value: 'updates', label: messages.catUpdates, count: count((game: GameDto): boolean => game.updateAvailable) },
|
||||||
|
{ kind: 'group', value: 'available', label: messages.catAvailable, count: count((game: GameDto): boolean => game.installable && !game.installed) },
|
||||||
|
{ kind: 'group', value: 'unsupported', label: messages.catUnsupported, count: count((game: GameDto): boolean => !game.installable) }
|
||||||
|
]
|
||||||
|
sections.push({
|
||||||
|
title: null,
|
||||||
|
items: groups.filter((item: CategoryItem): boolean => item.value === 'all' || item.count > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const platforms = [...new Set(games.map((game: GameDto): string => game.platform))]
|
||||||
|
.filter((platform: string): boolean => platform.length > 0)
|
||||||
|
.sort((left: string, right: string): number => left.localeCompare(right))
|
||||||
|
if (platforms.length > 1) {
|
||||||
|
sections.push({
|
||||||
|
title: messages.catPlatform,
|
||||||
|
items: platforms.map((platform: string): CategoryItem => ({
|
||||||
|
kind: 'platform',
|
||||||
|
value: platform,
|
||||||
|
label: platform,
|
||||||
|
count: count((game: GameDto): boolean => game.platform === platform)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const installable = games.filter((game: GameDto): boolean => game.installable)
|
||||||
|
const modes = [...new Set(installable.map((game: GameDto): string => game.mode))]
|
||||||
|
if (modes.length > 1) {
|
||||||
|
sections.push({
|
||||||
|
title: messages.catMode,
|
||||||
|
items: modes.map((mode: string): CategoryItem => ({
|
||||||
|
kind: 'mode',
|
||||||
|
value: mode,
|
||||||
|
label: mode === 'web' ? messages.hosted : messages.native,
|
||||||
|
count: count((game: GameDto): boolean => game.installable && game.mode === mode)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A category can vanish under us — the last title of a platform is removed, or an
|
||||||
|
* update is applied — and a filter matching nothing would look like an empty
|
||||||
|
* catalog. Falling back to everything is the honest answer.
|
||||||
|
*/
|
||||||
|
export function resolveFilter (
|
||||||
|
filter: CategoryFilter,
|
||||||
|
sections: readonly CategorySection[]
|
||||||
|
): CategoryFilter {
|
||||||
|
const known = sections
|
||||||
|
.flatMap((section: CategorySection): readonly CategoryItem[] => section.items)
|
||||||
|
.some((item: CategoryItem): boolean => isSameFilter(item, filter))
|
||||||
|
return known ? filter : ALL_CATEGORIES
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
|
||||||
|
|
||||||
|
/** A button on the gate, and what choosing it does. */
|
||||||
|
export interface GateAction {
|
||||||
|
readonly label: string
|
||||||
|
readonly perform: (chosen: RegistryStoreDto | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GateLink {
|
||||||
|
readonly label: string
|
||||||
|
readonly url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the gate is showing.
|
||||||
|
*
|
||||||
|
* Part of the state rather than a call on a view: whether the gate is up decides
|
||||||
|
* whether the grid is drawn, and the two disagreed when this was imperative — the
|
||||||
|
* gate went up and the empty-catalog line stayed underneath it.
|
||||||
|
*/
|
||||||
|
export interface GatePresentation {
|
||||||
|
readonly title: string
|
||||||
|
readonly body: string
|
||||||
|
readonly action?: GateAction
|
||||||
|
readonly link?: GateLink
|
||||||
|
readonly choices?: readonly RegistryStoreDto[]
|
||||||
|
}
|
||||||
@@ -14,6 +14,27 @@
|
|||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* The scrollbars are part of the theme too: the platform's own are light, and a white
|
||||||
|
track down the side menu of a dark window looks like a mistake. */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #33414f transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #33414f;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #46586b; background-clip: content-box; }
|
||||||
|
|
||||||
|
/* An explicit `display` beats the browser's own [hidden] rule, and most of the
|
||||||
|
regions here have one — the gate's store picker showed as an empty stub because
|
||||||
|
of exactly that. This makes `hidden` mean hidden everywhere. */
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
@@ -60,7 +81,11 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
|||||||
|
|
||||||
.side-block { display: flex; flex-direction: column; gap: 6px; flex: none; }
|
.side-block { display: flex; flex-direction: column; gap: 6px; flex: none; }
|
||||||
.side-cats { flex: 1; min-height: 0; }
|
.side-cats { flex: 1; min-height: 0; }
|
||||||
.side-foot { padding-top: 12px; border-top: 1px solid var(--line); }
|
.side-foot {
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
.side-head {
|
.side-head {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -239,6 +264,12 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.card.is-installed { border-color: #2f5a49; }
|
.card.is-installed { border-color: #2f5a49; }
|
||||||
|
/* Listed, but not for this machine: dimmed rather than hidden, and it says why. */
|
||||||
|
.card.is-unavailable { opacity: .55; }
|
||||||
|
.card.is-unavailable:hover { opacity: .8; }
|
||||||
|
.card.is-unavailable .art { filter: grayscale(1); }
|
||||||
|
.badge-unavailable { color: var(--ink-dim); border-color: var(--line); border-style: dashed; }
|
||||||
|
.actions-note { font-size: 12px; color: var(--ink-dim); margin-top: auto; }
|
||||||
|
|
||||||
.art {
|
.art {
|
||||||
/* One height for every card, art or not, so the titles and the buttons line up
|
/* One height for every card, art or not, so the titles and the buttons line up
|
||||||
@@ -279,23 +310,29 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
|||||||
}
|
}
|
||||||
.actions { display: flex; gap: 8px; margin-top: auto; }
|
.actions { display: flex; gap: 8px; margin-top: auto; }
|
||||||
|
|
||||||
/* --- log ---------------------------------------------------------------- */
|
/* --- log ----------------------------------------------------------------
|
||||||
.log {
|
No permanent footer: the panel is in the flow only while it is open, and the
|
||||||
|
switch for it sits in the side menu with everything else that is not a title. */
|
||||||
|
.log-panel {
|
||||||
flex: none;
|
flex: none;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
padding: 8px 18px 10px;
|
padding: 8px 18px 10px;
|
||||||
}
|
}
|
||||||
.log-toggle {
|
.side-quiet {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ink-dim);
|
color: var(--ink-dim);
|
||||||
background: none;
|
background: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 0 0 4px;
|
border-radius: 6px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
margin-left: -6px;
|
||||||
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.side-quiet:hover { color: var(--ink); background: var(--panel-2); }
|
||||||
|
.side-quiet.is-active { color: var(--accent); }
|
||||||
.log-lines {
|
.log-lines {
|
||||||
max-height: 150px;
|
max-height: 150px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { GameDto } from '../../shared/contracts/dto/GameDto'
|
||||||
|
import { requireElement, setHidden, setText } from '../dom/Dom'
|
||||||
|
import type { AppState } from '../state/AppStore'
|
||||||
|
import { matchesFilter } from '../state/CategoryFilter'
|
||||||
|
import type { GameCardView } from './GameCardView'
|
||||||
|
|
||||||
|
/** The grid, and the sentence that stands in for it when there is nothing to show. */
|
||||||
|
export class CatalogGridView {
|
||||||
|
private readonly grid = requireElement('grid', HTMLElement)
|
||||||
|
private readonly empty = requireElement('empty', HTMLElement)
|
||||||
|
|
||||||
|
public constructor (private readonly cards: GameCardView) {}
|
||||||
|
|
||||||
|
public render (state: AppState): void {
|
||||||
|
const shown = state.games.filter((game: GameDto): boolean => matchesFilter(game, state.filter))
|
||||||
|
this.grid.replaceChildren(...shown.map((game: GameDto): HTMLElement =>
|
||||||
|
this.cards.createCard(game, state.messages, state.busy)))
|
||||||
|
setHidden(this.grid, shown.length === 0)
|
||||||
|
this.grid.scrollTop = 0
|
||||||
|
setHidden(this.empty, shown.length !== 0)
|
||||||
|
setText(this.empty, state.games.length === 0 ? state.messages.noGames : state.messages.noMatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
public hide (): void {
|
||||||
|
setHidden(this.grid, true)
|
||||||
|
setHidden(this.empty, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { GameDto } from '../../shared/contracts/dto/GameDto'
|
||||||
|
import type { MessageBundle } from '../../shared/i18n/MessageBundle'
|
||||||
|
import { createElement } from '../dom/Dom'
|
||||||
|
|
||||||
|
export interface GameCardViewCallbacks {
|
||||||
|
readonly onInstall: (name: string) => void
|
||||||
|
readonly onLaunch: (name: string) => void
|
||||||
|
readonly onRemove: (name: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One card.
|
||||||
|
*
|
||||||
|
* A card is a function of a title and the strings: it holds no state of its own, so
|
||||||
|
* the grid can throw the lot away and rebuild after every listing.
|
||||||
|
*/
|
||||||
|
export class GameCardView {
|
||||||
|
public constructor (private readonly callbacks: GameCardViewCallbacks) {}
|
||||||
|
|
||||||
|
public createCard (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||||
|
const card = createElement('article', 'card')
|
||||||
|
if (game.installed) card.classList.add('is-installed')
|
||||||
|
if (!game.installable) card.classList.add('is-unavailable')
|
||||||
|
card.appendChild(this.createArt(game))
|
||||||
|
card.appendChild(this.createBody(game, messages, busy))
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
private createArt (game: GameDto): HTMLElement {
|
||||||
|
const art = createElement('div', 'art')
|
||||||
|
if (game.imageUrl !== null) {
|
||||||
|
const image = createElement('img')
|
||||||
|
image.src = game.imageUrl
|
||||||
|
image.alt = ''
|
||||||
|
image.loading = 'lazy'
|
||||||
|
art.appendChild(image)
|
||||||
|
return art
|
||||||
|
}
|
||||||
|
// No box art in the catalog: the first letter, on the same band an image would
|
||||||
|
// fill, so a row of cards stays aligned either way.
|
||||||
|
art.appendChild(createElement('span', 'art-glyph', game.title.slice(0, 1).toUpperCase()))
|
||||||
|
return art
|
||||||
|
}
|
||||||
|
|
||||||
|
private createBody (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||||
|
const body = createElement('div', 'body')
|
||||||
|
body.appendChild(createElement('h2', undefined, game.title))
|
||||||
|
body.appendChild(this.createMeta(game, messages))
|
||||||
|
if (game.description.length > 0) {
|
||||||
|
body.appendChild(createElement('p', 'desc', game.description))
|
||||||
|
}
|
||||||
|
body.appendChild(this.createActions(game, messages, busy))
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
private createMeta (game: GameDto, messages: MessageBundle): HTMLElement {
|
||||||
|
const meta = createElement('div', 'meta')
|
||||||
|
if (game.installable) {
|
||||||
|
const mode = createElement('span', `badge badge-${game.mode}`,
|
||||||
|
game.mode === 'web' ? messages.hosted : messages.native)
|
||||||
|
mode.title = game.mode === 'web' ? messages.hostedHint : messages.nativeHint
|
||||||
|
meta.appendChild(mode)
|
||||||
|
} else {
|
||||||
|
// Which of the two it is matters: a platform this store does not carry is a
|
||||||
|
// different disappointment from a game with no build for your machine.
|
||||||
|
const label = game.unavailableReason === 'platformOff'
|
||||||
|
? messages.unsupportedPlatform
|
||||||
|
: messages.unsupportedBuild
|
||||||
|
const badge = createElement('span', 'badge badge-unavailable', label)
|
||||||
|
badge.title = game.unavailableDetail ?? label
|
||||||
|
meta.appendChild(badge)
|
||||||
|
}
|
||||||
|
meta.appendChild(createElement('span', 'badge badge-plain', game.platform))
|
||||||
|
meta.appendChild(createElement('span', 'version',
|
||||||
|
game.installed && game.installedVersion !== null
|
||||||
|
? `${game.installedVersion} · ${messages.installed}`
|
||||||
|
: game.version))
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
private createActions (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||||
|
const actions = createElement('div', 'actions')
|
||||||
|
|
||||||
|
// Nothing to offer, so nothing to press: a disabled Install would invite a click
|
||||||
|
// that can never work. The badge above says why.
|
||||||
|
if (!game.installable) {
|
||||||
|
actions.appendChild(createElement('span', 'actions-note',
|
||||||
|
game.unavailableDetail ?? messages.unsupportedPlatform))
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
|
const primary = createElement('button', 'btn btn-primary')
|
||||||
|
primary.disabled = busy
|
||||||
|
|
||||||
|
if (game.installed && !game.updateAvailable) {
|
||||||
|
primary.textContent = game.mode === 'web' ? messages.open : messages.play
|
||||||
|
primary.disabled = busy || !game.launchable
|
||||||
|
primary.addEventListener('click', (): void => { this.callbacks.onLaunch(game.name) })
|
||||||
|
} else {
|
||||||
|
primary.textContent = game.updateAvailable ? messages.update : messages.install
|
||||||
|
primary.addEventListener('click', (): void => { this.callbacks.onInstall(game.name) })
|
||||||
|
}
|
||||||
|
actions.appendChild(primary)
|
||||||
|
|
||||||
|
if (game.installed) {
|
||||||
|
const remove = createElement('button', 'btn btn-ghost', messages.remove)
|
||||||
|
remove.disabled = busy
|
||||||
|
remove.addEventListener('click', (): void => { this.callbacks.onRemove(game.name) })
|
||||||
|
actions.appendChild(remove)
|
||||||
|
}
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
|
||||||
|
import { createElement, requireElement, setHidden, setText } from '../dom/Dom'
|
||||||
|
import type { MessageBundle } from '../../shared/i18n/MessageBundle'
|
||||||
|
import type { GateLink, GatePresentation } from '../state/GatePresentation'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The screen shown instead of the grid when there is nothing to drive: no Python, no
|
||||||
|
* store yet, an engine too old, or a registry that cannot be reached.
|
||||||
|
*/
|
||||||
|
export class GateView {
|
||||||
|
private readonly section = requireElement('gate', HTMLElement)
|
||||||
|
private readonly title = requireElement('gate-title', HTMLElement)
|
||||||
|
private readonly body = requireElement('gate-body', HTMLElement)
|
||||||
|
private readonly choice = requireElement('gate-choice', HTMLElement)
|
||||||
|
private readonly choiceLabel = requireElement('gate-choice-label', HTMLElement)
|
||||||
|
private readonly select = requireElement('gate-select', HTMLSelectElement)
|
||||||
|
private readonly button = requireElement('gate-action', HTMLButtonElement)
|
||||||
|
private readonly link = requireElement('gate-link', HTMLAnchorElement)
|
||||||
|
|
||||||
|
public constructor (private readonly onOpenUrl: (url: string) => void) {}
|
||||||
|
|
||||||
|
public show (presentation: GatePresentation, messages: MessageBundle): void {
|
||||||
|
setHidden(this.section, false)
|
||||||
|
setText(this.title, presentation.title)
|
||||||
|
setText(this.body, presentation.body)
|
||||||
|
this.renderChoices(presentation.choices ?? [], messages)
|
||||||
|
this.renderAction(presentation)
|
||||||
|
this.renderLink(presentation.link ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
public hide (): void {
|
||||||
|
setHidden(this.section, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only shown when the registry offers more than one store; with a single one there is nothing to decide. */
|
||||||
|
private renderChoices (choices: readonly RegistryStoreDto[], messages: MessageBundle): void {
|
||||||
|
setHidden(this.choice, choices.length < 2)
|
||||||
|
if (choices.length < 2) return
|
||||||
|
setText(this.choiceLabel, messages.setupChoose)
|
||||||
|
this.select.replaceChildren(...choices.map((store: RegistryStoreDto, index: number): HTMLOptionElement => {
|
||||||
|
const option = createElement('option', undefined, store.name)
|
||||||
|
option.value = String(index)
|
||||||
|
return option
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderAction (presentation: GatePresentation): void {
|
||||||
|
const action = presentation.action
|
||||||
|
setHidden(this.button, action === undefined)
|
||||||
|
this.button.disabled = false
|
||||||
|
if (action === undefined) return
|
||||||
|
setText(this.button, action.label)
|
||||||
|
this.button.onclick = (): void => {
|
||||||
|
const choices = presentation.choices ?? []
|
||||||
|
const index = Number(this.select.value)
|
||||||
|
action.perform(choices[Number.isFinite(index) ? index : 0] ?? choices[0] ?? null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderLink (link: GateLink | null): void {
|
||||||
|
setHidden(this.link, link === null)
|
||||||
|
if (link === null) return
|
||||||
|
setText(this.link, link.label)
|
||||||
|
this.link.onclick = (event: MouseEvent): void => {
|
||||||
|
event.preventDefault()
|
||||||
|
this.onOpenUrl(link.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { createElement, requireElement, setHidden, setText } from '../dom/Dom'
|
||||||
|
import type { AppState } from '../state/AppStore'
|
||||||
|
|
||||||
|
const MAX_LOG_LINES = 400
|
||||||
|
|
||||||
|
export interface LogDrawerViewCallbacks {
|
||||||
|
readonly onOpenFolder: (directory: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The store's own output, verbatim, and the folders everything lands in.
|
||||||
|
*
|
||||||
|
* Off screen until asked for. The window used to carry a footer with a toggle and a
|
||||||
|
* line of absolute paths at all times; both were clutter next to the one thing the
|
||||||
|
* window is for, which is the titles. The switch lives in the side menu with the rest
|
||||||
|
* of what is not a title, and the panel appears above the grid only while it is on.
|
||||||
|
*/
|
||||||
|
export class LogDrawerView {
|
||||||
|
private readonly toggle = requireElement('log-toggle', HTMLButtonElement)
|
||||||
|
private readonly panel = requireElement('log-panel', HTMLElement)
|
||||||
|
private readonly lines = requireElement('log-lines', HTMLElement)
|
||||||
|
private readonly pathsBox = requireElement('log-paths', HTMLElement)
|
||||||
|
private open = false
|
||||||
|
|
||||||
|
public constructor (private readonly callbacks: LogDrawerViewCallbacks) {
|
||||||
|
this.toggle.addEventListener('click', (): void => { this.setOpen(!this.open) })
|
||||||
|
}
|
||||||
|
|
||||||
|
public render (state: AppState): void {
|
||||||
|
setText(this.toggle, state.messages.log)
|
||||||
|
this.pathsBox.replaceChildren()
|
||||||
|
const paths = state.paths
|
||||||
|
if (paths === null) return
|
||||||
|
|
||||||
|
this.pathsBox.appendChild(createElement('div', 'paths-line',
|
||||||
|
`${state.messages.paths}: ${paths.storeFolder} · ${paths.menuGroup}`))
|
||||||
|
|
||||||
|
const folders: readonly (readonly [string, string])[] = [
|
||||||
|
[state.messages.openStoreFolder, paths.storeFolder],
|
||||||
|
[state.messages.openMenuFolder, paths.menuGroup]
|
||||||
|
]
|
||||||
|
for (const [label, directory] of folders) {
|
||||||
|
const button = createElement('button', 'btn btn-tiny', label)
|
||||||
|
button.addEventListener('click', (): void => { this.callbacks.onOpenFolder(directory) })
|
||||||
|
this.pathsBox.appendChild(button)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setOpen (open: boolean): void {
|
||||||
|
this.open = open
|
||||||
|
setHidden(this.panel, !open)
|
||||||
|
this.toggle.setAttribute('aria-expanded', String(open))
|
||||||
|
this.toggle.classList.toggle('is-active', open)
|
||||||
|
if (open) this.lines.scrollTop = this.lines.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A line arriving while the panel is shut does not open it: the store logs on every
|
||||||
|
* refresh, and a window that unfolded a panel by itself would be worse than one that
|
||||||
|
* kept quiet. The lines are kept, so opening it later shows what happened.
|
||||||
|
*/
|
||||||
|
public appendLine (line: string): void {
|
||||||
|
this.lines.appendChild(createElement('div', 'log-line', line))
|
||||||
|
while (this.lines.childElementCount > MAX_LOG_LINES) {
|
||||||
|
const first = this.lines.firstElementChild
|
||||||
|
if (first === null) break
|
||||||
|
first.remove()
|
||||||
|
}
|
||||||
|
this.lines.scrollTop = this.lines.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user