6 Commits
Author SHA1 Message Date
mr.zeroandClaude Opus 5 a364a5ce5f The publishing step needs the secret after all
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
A manual build printed a forge credential, so the last change dropped the secret and
relied on it. The first tag build then built all four packages and died at the publishing
step with no credential at all: a build started by the tag webhook does not get one.

So `gitea_token` is a repository secret again, mapped into the step, with the forge
credential kept as a fallback for the manual case. The README and the wiki now describe
what was measured rather than what the manual build suggested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 17:24:42 +02:00
mr.zeroandClaude Opus 5 7026e0cc6a Publish from CI without a secret
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline failed
Woodpecker hands every step a forge credential for cloning — an access token of the
repository's owner — and a one-off diagnostic in the check step confirmed it is there.
scripts/ci-upload.sh now uses it when no `gitea_token` secret is set, so publishing a
release needs nothing configured. Gitea takes such a credential as `token …` or
`Bearer …` depending on how Woodpecker was set up, so the script probes which of the two
`/user` accepts rather than assuming, and says which one it used.

The secret mapping is gone from the step as well: referencing a secret that does not
exist is a failure mode of its own, and the fallback is the normal path now. Adding a
`gitea_token` secret and mapping it back in is how you publish as somebody else.

Documents the release flow the pipeline now implements: push a vX.Y.Z tag, the pipeline
builds Linux and Windows and creates the release with them in it, and `make release` from
a Mac pushes the macOS package onto the same release. Either half can go first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 17:15:58 +02:00
mr.zeroandClaude Opus 5 2f725fdd14 CI publishes the release itself
ci/woodpecker/push/woodpecker Pipeline is pending
ci/woodpecker/manual/woodpecker Pipeline was successful
The flow is now: a vX.Y.Z tag starts the pipeline, the pipeline creates the release with
the Linux and Windows packages in it, and the macOS package is pushed on top from a Mac
with make release. scripts/ci-upload.sh therefore creates the release when the tag has
none, taking its body from RELEASE_NOTES.md, instead of requiring one to exist.

Also carries a one-off diagnostic in the check step: whether Woodpecker hands steps a
forge credential of their own. If it does, the release step needs no secret.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 17:12:16 +02:00
mr.zeroandClaude Opus 5 526c67b069 Registry-only stores, a quieter window, and a CI that builds
ci/woodpecker/manual/woodpecker Pipeline was successful
A store no longer needs a repository of its own. The engine's built-in defaults
already cover the host-to-asset mapping, the install modes, the platforms and the
behaviour; what they cannot know is identity — a slug, a name and a catalog URL — and
that is exactly what a registry record carries. So `storeRepositoryUrl` is optional: a
record with a name and a catalog is a complete store, the id falls back from the
repository name to the catalog host (`teletypegames.org` becomes `teletypegames`) to the
display name, and the client writes a three-section config. Given a repository it still
reads it, and that file stays the authority on how the store behaves; a repository
without a config.json is treated as no repository at all.

Measured end to end against a local registry serving one record with a null repository:
the engine and the core downloaded, engine 1.1.0 accepted the written config, it listed
the same ten titles the configured store does, and a hosted title synced into a sandbox
with its menu entry written.

The "Install all" button is gone, and with it the string it used. Titles are installed
one at a time from their own cards.

No footer. The window carried a bar at the bottom at all times — a toggle and a line of
absolute paths — for something most sessions never need. The log is still there, folder
buttons included, behind a quiet switch at the bottom of the side menu; it takes no room
until it is opened, and an arriving line does not open it, because the store logs on
every refresh and a window that unfolds panels by itself is worse than one that keeps
quiet.

Three faults that every automated count had passed, found by photographing the setup
screen: the store badge rendered as an empty pill with no store open; the gate's picker
showed as an empty dropdown stub, because an explicit `display` beats the browser's own
`[hidden]` rule; and the gate went up while the empty-catalog line stayed on screen
underneath it. The last was a design fault — whether the gate is up was a call on a
view rather than state, so the two could disagree. The setup screen is now a field in
the state store, and that one field decides which of the gate and the grid is drawn.
The window test's gate assertion was wrong too: it demanded a store picker, which only
appears when the registry offers more than one store, so one store — the ordinary case —
failed it.

CI builds the packages this machine cannot. `.woodpecker.yaml` runs the checks on every
push and, on a tag or by hand, builds the Linux packages in
`electronuserland/builder:22` and the Windows ones in `:22-wine`, then attaches them to
the release with scripts/ci-upload.sh. The pipeline lives here rather than in the update
server's `/build/config` extension, which serves game-platform pipelines publishing into
the site's catalog — a different product with a different target. macOS stays a local
build: Apple's toolchain and its signing exist only on a Mac.

Both build steps verify what they produced, because a half-finished Wine build leaves a
162 KB stub named like the real installer and `ls` is happy with it. The Linux step was
rehearsed locally in the same image (AppImage 128 MB, deb 100 MB); the Wine step cannot
be rehearsed on Apple Silicon, where 16 KB host pages break Wine's 4 KB assumption, so
the runner is where it is proven. The size check was tested against both outcomes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:52:59 +02:00
mr.zeroandClaude Opus 5 3d63c8a0b0 TypeScript, in layers, with a strict linter
The client was one main.js, one preload.js, three files in lib/ and one renderer
script. It is now a typed application whose imports point inward: domain (models,
ports, errors) knows nothing about Electron, Node or Python; application orchestrates
it through those ports; infrastructure holds the adapters — the Python CLI, HTTP, the
filesystem, Electron itself — and main, preload and renderer sit on top as hosts.

STRUCTURE.md is the map, and the deliverable as much as the code is: every layer, every
pattern in use (ports and adapters, repository vs gateway, service, DTO and mapper,
composition root, controller and router, single flight, observer streams, state store
with unidirectional flow, passive view, coded error hierarchy, frozen constant tables,
untrusted-data readers) and the naming rules — files, classes, and a verb vocabulary
for methods where find/require/read/list/apply/render/handle each state a contract.

Two properties fell out of the move, and they are why it was worth doing:

  - The catalog can be driven with no window and no Electron at all. The smoke test
    assembles the same services against the same ports in a plain Node process; it used
    to be a script that reimplemented the bridge.
  - The window never receives a filesystem path. A title crosses the bridge without
    one, and launching is asked for by name, resolved in the main process from the
    store's own state. Verified with a fake launcher: an unknown name answers false, a
    native title resolves to its menu entry, a hosted one to its catalog URL.

Types are mandatory, including where inference would manage: explicit return,
parameter and property types, strict plus noUncheckedIndexedAccess,
exactOptionalPropertyTypes, noImplicitOverride and noPropertyAccessFromIndexSignature,
typescript-eslint strictTypeChecked and stylisticTypeChecked, exhaustive switches, no
any, no non-null assertions, and no casts on foreign data — engine stdout and the
registry go through readers that turn unknown into typed values. naming-convention
enforces the patterns rather than trusting them.

Two rule conflicts had to be decided rather than papered over. typedef and
no-inferrable-types disagree about `fallback: string = ''`: the annotation wins, since a
signature states its types. erasableSyntaxOnly is off, because it forbids constructor
parameter properties, which are how dependencies are declared here.

The preload and the renderer are bundled by esbuild into one file each: a sandboxed
preload may not require its own modules, and a module script over file:// is blocked by
the page's own origin rules. tsc compiles the rest. The package ships build/** and
package.json — 111 entries, no sources, no toolchain.

New targets: build, typecheck, lint, lint-fix, and check — typecheck, lint, then both
test suites, cheapest failure first. Every script that runs the app builds first, so a
stale bundle cannot be tested.

Nothing about the window changed: same side menu, same categories, same switcher, same
two languages. make check is clean, both test suites pass with one store and with two,
the packaged 1.3.0 bundle drives the real store, and the window was photographed before
and after — the two are the same picture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 15:29:18 +02:00
mr.zeroandClaude Opus 5 a25acf6e35 Retry a failed upload instead of losing the release
Publishing 1.2.0 put the first package up and then failed the second with
"invalid username, password or token" — the same token, seconds later, and the
identical command succeeded on the next run. A flake at 118 MB should not cost a
rebuild, so each upload gets up to three attempts, and the attachment already on
the release is dropped before every attempt so a retry cannot leave two copies.

The shipped bundle was checked rather than assumed: unpacked from the release zip,
`codesign --verify --deep --strict` is valid on disk and satisfies its designated
requirement, and the app drives the real store — ten cards, the menu populated,
the categories counted. With the quarantine flag set it is killed on launch
(exit 137), which is the ad-hoc-signing limitation the README already documents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 14:46:34 +02:00
121 changed files with 6673 additions and 1760 deletions
+1
View File
@@ -1,2 +1,3 @@
node_modules/
build/
dist/
+73
View File
@@ -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
+23 -4
View File
@@ -7,6 +7,7 @@
#
# make list the targets
# make setup install the dependencies
# make check typecheck, lint and both test suites
# make dist package for this machine
# make release package and publish to Gitea
#
@@ -23,8 +24,8 @@ TAG ?= v$(VERSION)
.DEFAULT_GOAL := help
.PHONY: help setup node-check start smoke uitest test dist dist-mac dist-win dist-linux \
release publish clean distclean version
.PHONY: help setup node-check build typecheck lint lint-fix check start smoke uitest test \
dist dist-mac dist-win dist-linux release publish clean distclean version
help: ## List available targets
@echo "WarpEngine Store GUI $(VERSION) — usage: make <target>"
@@ -45,6 +46,22 @@ node-check: ## Check the Node version Electron's installer needs
setup: node-check ## Install the dependencies
npm install
build: ## Compile TypeScript and bundle the preload and the renderer
npm run build
typecheck: ## Type-check everything, emitting nothing
npm run typecheck
lint: ## Lint with the strict rule set
npm run lint
lint-fix: ## Lint and fix what can be fixed automatically
npm run lint:fix
# The order is deliberate: a type error explains a lint error, and both explain a
# failing test, so the cheapest check that can fail runs first.
check: typecheck lint test ## Type-check, lint, and run both test suites
start: ## Run the app against whatever store is installed
npm start
@@ -75,8 +92,8 @@ publish: ## Upload the packages already in dist/ to the Gitea release
# what this version produced.
release: clean dist publish ## Package for this machine and publish it
clean: ## Remove the built packages
rm -rf dist
clean: ## Remove the compiled output and the built packages
rm -rf build dist
distclean: clean ## Remove the packages and the dependencies
rm -rf node_modules
@@ -86,5 +103,7 @@ version: ## Show the versions involved
@printf "node "; node --version 2>/dev/null || echo "missing"
@printf "npm "; npm --version 2>/dev/null || echo "missing"
@printf "electron "; node -p "require('./package.json').devDependencies.electron" 2>/dev/null || echo "missing"
@printf "typescript "; npx tsc --version 2>/dev/null || echo "missing"
@printf "eslint "; npx eslint --version 2>/dev/null || echo "missing"
@printf "tea "; tea --version 2>/dev/null | head -1 || echo "missing — devarea: make tea"
@printf "python3 "; python3 --version 2>/dev/null || echo "missing"
+174 -49
View File
@@ -1,9 +1,10 @@
# warp-engine-desktop-gui — a window for the desktop store
# warp-engine-client — the WarpEngine Store app
A graphical client for
[`warp-engine-desktop-store`](https://git.teletypegames.org/stores/warp-engine-desktop-store):
the catalog as a grid of cards, one click to install a title into your own
application menu, one to play it, one to remove it. Linux, macOS and Windows.
The graphical client for a WarpEngine store — the app is called **WarpEngine
Store** — driving
[`warp-engine-desktop-store`](https://git.teletypegames.org/stores/warp-engine-desktop-store)
underneath: the catalog as a grid of cards, one click to install a title into your
own application menu, one to play it, one to remove it. Linux, macOS and Windows.
The 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
@@ -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
store installs under your own user account.
To *develop* it you also need **Node 22 or newer** — see below — and nothing else: the
toolchain (TypeScript, ESLint, esbuild, electron-builder) installs with `make setup`.
## Install
Grab the package for your machine from the
[releases](https://git.teletypegames.org/stores/warp-engine-desktop-gui/releases)
[releases](https://git.teletypegames.org/stores/warp-engine-client/releases)
and open it. On first run, if there is no store on the machine yet, the window
offers to download one — that is the whole setup.
@@ -60,28 +64,39 @@ and there is nothing to decide; several and the setup screen shows a picker.
```json
[
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org", "storeRepositoryUrl": null },
{
"name": "Teletype Games",
"catalogUrl": "https://teletypegames.org",
"storeRepositoryUrl": "https://git.teletypegames.org/stores/ttg-desktop-store"
"name": "Some Other Store",
"catalogUrl": "https://games.example.org",
"storeRepositoryUrl": "https://git.example.org/stores/other-desktop-store"
}
]
```
**A store needs no repository of its own.** A name and a catalog are enough: the
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:
- **`storeRepositoryUrl`** → the store's `config.json`, read from
`…/raw/branch/master/config.json`. That file is the authority on how the store
behaves: which platforms, which statuses, where things land.
- **the store id** — which names the store home and the folder games land in —
comes from the repository name when there is one (`ttg-desktop-store` becomes
`ttg`), otherwise from the catalog host (`teletypegames.org` becomes
`teletypegames`), otherwise from the display name. A `config.json` that sets its
own id keeps it.
- **`catalogUrl` and `name`** override the config's own `store.base_url` and
`store.name`. The registry says which catalog this store is *for*, so it wins.
- **the store id** — which names the store home and the folder games land in —
comes from the repository name: `ttg-desktop-store` becomes `ttg`. A
`config.json` that sets its own id keeps it.
- **`storeRepositoryUrl`**, when given → the store's `config.json`, read from
`…/raw/branch/master/config.json`. That file stays the authority on how the store
behaves: which platforms, which statuses, where things land. A repository
**without** a `config.json` is treated as no repository at all.
A repository **without** a `config.json` still works. The engine merges whatever
it is handed onto its own defaults, so the client writes a three-field config and
the store behaves like the default one pointed at that catalog.
What the defaults produce, for a record with no repository: the games land in a
folder named after the store id, and released, archived **and demo** titles are
listed — a catalog that publishes a demo means it to be played.
The registry address is the single thing about a particular site left in the
client, and `STORES_API` overrides it:
@@ -104,8 +119,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
the id would not tell them apart. **Add a store…** brings up the registry
picker, the same one the first run offers.
- **Actions** holds **Install all**, which fetches everything the catalog offers
for this machine, and **Refresh**, which re-reads the catalog.
- **Actions** holds **Refresh**, which re-reads the catalog. Titles are installed
one at a time from their own cards; there is no install-everything button.
- **Categories** narrows the grid, one category at a time, with the count next to
each: *Everything*, *Installed*, *Updates*, *Not installed*, then a row per
**platform** (`godot`, `tic80`, `love`, …) and per **kind** (native or hosted).
@@ -113,6 +128,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
*Everything* rather than leaving an empty grid. There is no genre in a
WarpEngine catalog, so these are the categories there are.
- **Log** opens the store's own output — its words, verbatim — together with the two
folders everything lands in. Off screen until asked for: the window has no footer,
because a permanent bar of absolute paths is not what a store is for.
- **Language** follows the system and can be switched; **English and Hungarian**.
In the grid, a card's button is **Install**, **Update**, or **Play** / **Open**
@@ -121,9 +139,6 @@ 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
the network.
The **Log** drawer at the bottom carries the store's own output verbatim, and next
to it are buttons that open the two folders everything lands in.
Every card carries a band of box art the same height — the first letter of the
title when the catalog has no image — so titles and buttons line up across a row.
Until this was photographed, the grid was quietly broken: the rows split the
@@ -145,19 +160,83 @@ names. `make` on its own lists everything.
| Target | What it does |
|---|---|
| `make setup` | install the dependencies (checks the Node version first) |
| `make build` | compile TypeScript, bundle the preload and the renderer |
| `make typecheck` | type-check everything, emitting nothing |
| `make lint` | the strict rule set (`lint-fix` fixes what it can) |
| `make check` | **typecheck, lint and both test suites** — the gate |
| `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 |
| `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 publish` | upload the packages already in `dist/` to the Gitea release |
| `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 |
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
@@ -165,15 +244,27 @@ Makefile adds no logic of its own beyond the release step.
make release
```
The tag comes from `package.json`, so `npm version patch` is the only place a
version is set. The release is created if it is not there yet, 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.
This is the **macOS half** of a release; the Linux and Windows packages come from the
pipeline when the tag is pushed (see above). The tag comes from `package.json`, so
`npm version patch` is the only place a version is set. The release is created if it is
not there yet — either half can go first — and an attachment whose name is already on it
is **replaced** rather than refused, so a rebuild and a second `make publish` lands
rather than erroring.
Release notes come from `RELEASE_NOTES.md` when the file is present, otherwise the
release gets a one-line note. The repository is read from `origin`, so a fork
publishes to the fork.
Each upload is retried up to three times, and the existing attachment is dropped
before every attempt so a retry cannot leave two copies. A 100 MB upload does fail
on its own: publishing 1.2.0 got *"invalid username, password or token"* on the
second package while the first had just gone up with the same token, and the same
command succeeded immediately afterwards.
Package names contain a space — `WarpEngine Store-1.2.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.
Overridable: `TAG`, `REPO`, `TEA_LOGIN`, `NOTES`, `DIST`.
@@ -200,33 +291,67 @@ SMOKE_HOME=/tmp/sandbox-root/ttg-desktop npm run smoke
### How it is put together
| File | What it does |
TypeScript, in layers, with the dependency rule pointing inward. **[STRUCTURE.md](STRUCTURE.md)
is the map** — the layers, every pattern in use, and the naming rules. The short version:
| Layer | What lives there |
|---|---|
| `main.js` | the window, the IPC, and the one-call-at-a-time guard |
| `preload.js` | the entire surface the renderer gets — no Node reaches it |
| `lib/store.js` | finds the store and Python, runs the CLI, parses its JSON |
| `lib/bootstrap.js` | reads the registry, then downloads the engine, the core and a config |
| `lib/i18n.js` | the two string tables |
| `renderer/` | plain HTML, CSS and JS — no framework, no build step |
| `Makefile` | the named sequences; no logic of its own beyond the release |
| `src/shared/` | the IPC channel table, the bridge contract, the DTOs, the two message bundles |
| `src/domain/` | models, ports and errors — no Electron, no Node, no Python |
| `src/application/` | services and the domain → DTO mappers |
| `src/infrastructure/` | the adapters: the Python CLI, HTTP, the filesystem, Electron itself |
| `src/main/` | the window, the IPC controllers, the composition root, the self-test |
| `src/preload/` | the bridge, bundled into one file — a sandboxed preload cannot require modules |
| `src/renderer/` | the state store, the views and the renderer controllers |
| `src/scripts/` | the smoke test: the same services with no window at all |
| `scripts/release.sh` | creates the Gitea release and replaces its attachments |
| `scripts/after-pack.js` | ad-hoc signs the macOS bundle during packaging |
| `scripts/build-assets.mjs` | bundles the preload and the renderer, copies the page |
`contextIsolation` is on, `nodeIntegration` off, `sandbox` on, and the page
carries a CSP that allows only its own script and stylesheet plus images over
HTTPS. Links open in the real browser; the window itself never navigates.
Two properties are worth stating because they are what the layers buy:
`lib/store.js` talks to the CLI through `--json`, which puts data on stdout and
the human-readable log on stderr. That flag arrived with engine **1.1.0**, and the
client checks: an older store is met with an offer to refresh it rather than a
failed call.
- **The catalog can be driven without a window.** `make smoke` assembles the same
services against the same ports with no Electron in the process at all.
- **The window never receives a filesystem path.** A `GameDto` carries no paths; the
window asks to launch a title *by name* and the main process resolves what that means
from the store's own state.
The bridge keeps an `ENGINES` list with one entry today. The RetroArch store has
the same command shape, so a second entry is the whole change needed to drive it
too — that is why the indirection is there.
`contextIsolation` is on, `nodeIntegration` off, `sandbox` on, and the page carries a
CSP that allows only its own script and stylesheet plus images over HTTPS. Links open
in the real browser; the window itself never navigates.
`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
The pipeline's commands were run in the same containers it uses, before the pipeline was
committed: `electronuserland/builder:22` installs, type-checks, lints, passes the smoke
test (registry reached, store skipped as it should be on a machine that has none) and
produces the AppImage (128 MB) and the deb (100 MB). The Wine step could not be
rehearsed here — see above — and the size check that guards it was tested against both
outcomes: it rejects the 162 KB stub the failed Wine build left and accepts the two real
Linux packages.
A store with no repository was installed end to end from a local registry serving
one record with `storeRepositoryUrl: null`: the id came out as `teletypegames`, the
engine and the shared core downloaded, the written config had the three sections,
engine 1.1.0 accepted it, and it listed the same ten titles the configured store
does — then a hosted title synced into a sandbox and its menu entry appeared. The
setup gate was also photographed on a machine with no store at all.
The 1.3.0 refactor was measured rather than trusted: `make check` is clean — no type
errors, no lint findings, both test suites green — the window was photographed before
and after and the two are the same picture, and the packaged 1.3.0 bundle was run from
`dist/` and drove the real store. The published package contains `build/` and
`package.json` and nothing else: 111 entries, no sources, no toolchain.
Exercised on macOS (arm64), with the packaged app from the release rather than a
dev run: the store is discovered, the catalog lists, a sync installs, the window
renders the installed state, and `npm run uitest` passes with the grid rendered
+70 -38
View File
@@ -1,32 +1,71 @@
# WarpEngine Store 1.2.0
# WarpEngine Store 1.4.0
**A side menu.** Everything that is not a title moved out of the bar into a menu on
the left that folds away with `☰`: the stores on this machine, the two actions, the
categories and the language. Open or closed is remembered between runs.
**A store no longer needs a repository of its own.** Until now every store in the
registry pointed at a repository holding its `config.json`, and the client read that
file to know what to install. It turns out almost nothing in there was necessary: the
store engine's built-in defaults already cover the host-to-asset mapping, the install
modes, the platforms and the behaviour. What defaults cannot know is *identity* — a
slug, a name and a catalog URL — and that is exactly what a registry record carries.
**Categories.** The grid narrows to *Installed*, *Updates* or *Not installed*, to a
platform (`godot`, `tic80`, `love`, …), or to native/hosted titles — one at a time,
each with its count. The axes are built from what the catalog actually contains, so
nothing empty is listed, and a category that disappears under you falls back to
*Everything* rather than leaving a blank grid.
So `storeRepositoryUrl` is now optional. A record with a name and a catalog URL is a
complete store: the client derives the slug from the catalog host
(`teletypegames.org``teletypegames`), writes a small config and installs. Given a
repository it still reads it, and that file remains the authority on how the store
behaves — which platforms it offers, which statuses it shows, where things land. A
repository without a `config.json` is treated as no repository at all.
**Switching stores.** With more than one store installed, clicking another in the
menu opens it: the grid, the categories and the folders follow, and the client
reopens on the store last used. Two stores installed from the same catalog into
different folders are told apart by their folder, since their id is identical.
With the defaults, games land in a folder named after the store id and released,
archived **and demo** titles are listed: a catalog that publishes a demo means it to
be played.
While the store is working, the menu, the log drawer and the filters keep working —
only what would start a second call is disabled.
The site's registry endpoint changed to match — `storeRepositoryUrl` answers `null`
when there is none — and adding a store is now genuinely one database row with two
fields filled in.
**The grid was broken, and now is not.** Its rows split the window's height evenly
rather than following their content, so every card came out 94px tall: the box art
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.
**The "Install all" button is gone.** Titles are installed one at a time from their
own cards.
Unchanged from 1.1.0: which stores exist is the site's answer (`GET /api/stores`),
not something baked into this app, and `STORES_API` overrides that address.
**No footer.** The window carried a bar at the bottom at all times — a toggle and a
line of absolute paths — for something most sessions never need. The log is still
there, with the two folder buttons in it, but it lives behind a quiet switch at the
bottom of the side menu and takes no room until it is opened. The grid gets the height
back.
**The repository is now `warp-engine-client`.** The app has always been called
WarpEngine Store; `warp-engine-desktop-gui` described the role rather than the
product, and the host-specific engines keep their own shape
(`warp-engine-desktop-store`, `-retroarch-store`, `-batocera-store`). Gitea keeps a
redirect from the old path, and the releases and tags moved with the repository, so
existing links and clones still resolve.
### Three things a screenshot found
Photographing the setup screen — which no automated count had ever looked at — turned
up three faults that every check had passed:
- the store badge in the bar rendered as an empty pill when no store was open;
- the gate's store picker showed as an empty dropdown stub, because an explicit
`display` in the stylesheet beats the browser's own `[hidden]` rule;
- the gate went up while the *"No installable titles in the catalog"* line stayed on
screen underneath it.
The last one was a design fault, not a typo: whether the gate is up was an imperative
call on a view rather than state, so the gate and the grid could disagree. The setup
screen is now a field in the state store, and that one field decides which of the two
is drawn. The window test's gate assertion was wrong too — it demanded a store picker,
which only appears when the registry offers more than one store, so a perfectly good
window failed it.
### Linux and Windows packages now come from CI
A `vX.Y.Z` tag now starts the pipeline, which builds the AppImage, the deb, the NSIS
installer and the portable exe — Windows through Wine — **creates this release** and
attaches all four. macOS stays a local build, because Apple's toolchain and its signing
exist only on a Mac, so `make release` from a Mac pushes that package onto the same
release afterwards. Publishing uses a `gitea_token` repository secret in Woodpecker.
The Windows installer is not signed: Windows will warn about an unknown publisher until
there is a certificate.
### Opening it on macOS
@@ -36,23 +75,16 @@ Ad-hoc signed, **not notarised**, so macOS asks first:
xattr -dr com.apple.quarantine "/Applications/WarpEngine Store.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
**macOS arm64 only**, the machine this was built and verified on. Windows and
Linux packages need a build on those platforms (`make dist-win` / `dist-linux`).
The macOS arm64 package, built and verified here, plus whatever the pipeline attaches
for Linux (AppImage, deb) and Windows (installer, portable).
### Verified
`SELFTEST_SHOT=shot.png npm run uitest` has the window photograph itself, which is
how the collapsed rows were found and how the fix was confirmed — in English and in
Hungarian, with the menu open and closed.
`npm run uitest` loads the window and reports what rendered; with two stores in one
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.
A repository-less store was installed end to end against 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 three sections, engine 1.1.0
accepted it, it listed the same ten titles the configured store does, and a hosted
title synced into a sandbox with its menu entry written. `make check` is clean, and
the setup gate was photographed on a machine with no store at all.
+294
View File
@@ -0,0 +1,294 @@
# 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
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.
### 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.
+49
View File
@@ -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']
}
}
)
-159
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
-357
View File
@@ -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))
})
}
+1464 -13
View File
File diff suppressed because it is too large Load Diff
+25 -17
View File
@@ -1,36 +1,43 @@
{
"name": "warp-engine-desktop-gui",
"name": "warp-engine-client",
"productName": "WarpEngine Store",
"version": "1.2.0",
"version": "1.4.0",
"description": "Graphical client for a WarpEngine desktop store: install the catalog into your own application menu.",
"license": "MIT",
"author": "Teletype Games <games@teletype.hu>",
"homepage": "https://git.teletypegames.org/stores/warp-engine-desktop-gui",
"main": "main.js",
"homepage": "https://git.teletypegames.org/stores/warp-engine-client",
"main": "build/main/main.js",
"engines": {
"node": ">=22"
},
"scripts": {
"start": "electron .",
"smoke": "node scripts/smoke.js",
"dist": "electron-builder",
"dist:mac": "electron-builder --mac",
"dist:win": "electron-builder --win",
"dist:linux": "electron-builder --linux",
"uitest": "electron . --selftest"
"build": "tsc -p tsconfig.build.json && node scripts/build-assets.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"start": "npm run build && electron .",
"smoke": "npm run build && node build/scripts/SmokeTest.js",
"uitest": "npm run build && electron . --selftest",
"dist": "npm run build && electron-builder",
"dist:mac": "npm run build && electron-builder --mac",
"dist:win": "npm run build && electron-builder --win",
"dist:linux": "npm run build && electron-builder --linux"
},
"devDependencies": {
"@types/node": "^26.2.0",
"electron": "^43.4.0",
"electron-builder": "^26.15.3"
"electron-builder": "^26.15.3",
"esbuild": "^0.28.2",
"eslint": "^10.8.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0"
},
"build": {
"appId": "org.teletypegames.warpstore.gui",
"productName": "WarpEngine Store",
"files": [
"main.js",
"preload.js",
"lib/**/*",
"renderer/**/*"
"build/**/*",
"package.json"
],
"mac": {
"category": "public.app-category.games",
@@ -55,6 +62,7 @@
"afterPack": "scripts/after-pack.js"
},
"allowScripts": {
"electron@43.4.0": true
"electron@43.4.0": true,
"esbuild@0.28.2": true
}
}
-29
View File
@@ -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
View File
@@ -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()
+54
View File
@@ -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}`)
}
+114
View File
@@ -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 Store Setup 1.4.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"'
+35
View File
@@ -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
+29 -12
View File
@@ -99,27 +99,44 @@ RELEASE_ID="$(release_id)"
[ -n "$RELEASE_ID" ] || die "the release $TAG could not be created or found"
# --- the attachments -------------------------------------------------------
while IFS= read -r asset; do
[ -n "$asset" ] || continue
[ -f "$asset" ] || die "no such file: $asset"
name="$(basename "$asset")"
# Replacing rather than refusing: a second run after a rebuild should land.
old="$(tea api "/repos/$REPO/releases/$RELEASE_ID/assets" | python3 -c "
# Anything already attached under this name, dropped: replacing rather than
# refusing is what makes a rebuild-and-upload repeatable. Called before every
# attempt, so a retry cannot leave two copies behind.
drop_existing() {
ids="$(tea api "/repos/$REPO/releases/$RELEASE_ID/assets" | python3 -c "
import json, sys
name = sys.argv[1]
for a in json.load(sys.stdin):
if a['name'] == name:
print(a['id'])
" "$name")"
for id in $old; do
say "replacing $name"
" "$1")"
for id in $ids; do
say "replacing $1"
tea api -X DELETE "/repos/$REPO/releases/$RELEASE_ID/assets/$id" >/dev/null
done
}
while IFS= read -r asset; do
[ -n "$asset" ] || continue
[ -f "$asset" ] || die "no such file: $asset"
name="$(basename "$asset")"
size="$(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"
say "done:"
-128
View File
@@ -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 }
}
}
+48
View File
@@ -0,0 +1,48 @@
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)
}
}
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] ?? ''
}
}
+10
View File
@@ -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')
}
}
+14
View File
@@ -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)
}
}
+9
View File
@@ -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}`)
}
}
+9
View File
@@ -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}`)
}
}
+9
View File
@@ -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
}
+13
View File
@@ -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
}
+34
View File
@@ -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('.')
}
+27
View File
@@ -0,0 +1,27 @@
/** How a title runs: unpacked on this machine, or served as a web build. */
export type GameMode = 'app' | 'web'
/**
* 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
}
+9
View File
@@ -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
}
+9
View File
@@ -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
}
+6
View File
@@ -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
}
+14
View File
@@ -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
}
+23
View File
@@ -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]
+39
View File
@@ -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, '')
}
+12
View File
@@ -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
}
+8
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
import type { PythonRuntime } from '../models/PythonRuntime'
export interface PythonRuntimeLocator {
findRuntime: () => PythonRuntime | null
}
+19
View File
@@ -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
}
+17
View File
@@ -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,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()
}
}
+60
View File
@@ -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)
})
}
}
+53
View File
@@ -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,36 @@
import type { Game, GameMode } 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')
}
}
private toMode (value: string): GameMode {
return value === 'web' ? 'web' : 'app'
}
}
@@ -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,54 @@
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 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 even that is overridable `STORES_API`
* points it at another site or at a local endpoint.
*
* 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) {
const configured = process.env['STORES_API']
this.sourceUrl = sourceUrl ?? (configured !== undefined && configured.length > 0
? configured
: 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
}
}
+96
View File
@@ -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 Store'
/**
* 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()
}
}
+63
View File
@@ -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 Store'
}
/**
* 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)
})
}
}
+78
View File
@@ -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)
}
}
+176
View File
@@ -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)
}
}
+52
View File
@@ -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)
}
}
+74
View File
@@ -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)
}
}
+42
View File
@@ -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
}
+37
View File
@@ -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))
}
}
+30
View File
@@ -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)
}
}
}
+73
View File
@@ -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)
}
}
}
+5
View File
@@ -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)
}
}
+72
View File
@@ -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)
+21
View File
@@ -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
}
+113
View File
@@ -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)
}
}
+112
View File
@@ -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)
}
}
}
+36
View File
@@ -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
}
@@ -36,7 +36,6 @@
<section class="side-block">
<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>
</section>
@@ -46,6 +45,7 @@
</section>
<section class="side-block side-foot">
<button id="log-toggle" class="side-quiet" aria-expanded="false" aria-controls="log-panel"></button>
<label class="side-lang">
<span id="head-lang"></span>
<select id="locale" class="select" aria-label="Language"></select>
@@ -72,11 +72,11 @@
<section id="empty" class="empty" hidden></section>
<footer class="log">
<button id="log-toggle" class="log-toggle" aria-expanded="false"></button>
<div class="log-lines" id="log-lines" hidden></div>
<!-- Only on screen while the log is open; the window has no permanent footer. -->
<section class="log-panel" id="log-panel" hidden>
<div class="log-lines" id="log-lines"></div>
<div class="log-paths" id="log-paths"></div>
</footer>
</section>
</div>
</div>
+7
View File
@@ -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))
})
+142
View File
@@ -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)
}
}
+122
View File
@@ -0,0 +1,122 @@
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':
return 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.installed
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.installed) }
]
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 modes = [...new Set(games.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.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
}
+27
View File
@@ -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[]
}
+21 -6
View File
@@ -14,6 +14,11 @@
* { box-sizing: border-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 {
margin: 0;
background: var(--bg);
@@ -60,7 +65,11 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
.side-block { display: flex; flex-direction: column; gap: 6px; flex: none; }
.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 {
font-size: 11px;
font-weight: 700;
@@ -279,23 +288,29 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
}
.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;
background: var(--panel);
border-top: 1px solid var(--line);
padding: 8px 18px 10px;
}
.log-toggle {
.side-quiet {
font: inherit;
font-size: 12px;
font-weight: 600;
color: var(--ink-dim);
background: none;
border: 0;
padding: 0 0 4px;
border-radius: 6px;
padding: 4px 6px;
margin-left: -6px;
text-align: left;
cursor: pointer;
}
.side-quiet:hover { color: var(--ink); background: var(--panel-2); }
.side-quiet.is-active { color: var(--accent); }
.log-lines {
max-height: 150px;
overflow-y: auto;
+28
View File
@@ -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)
}
}
+92
View File
@@ -0,0 +1,92 @@
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')
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')
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)
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')
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
}
}
+69
View File
@@ -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)
}
}
}
+71
View File
@@ -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
}
}
+119
View File
@@ -0,0 +1,119 @@
import { createElement, requireElement, setText } from '../dom/Dom'
import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto'
import type { Locale } from '../../shared/i18n/MessageBundle'
import {
buildCategorySections, isSameFilter, type CategoryFilter, type CategoryItem
} from '../state/CategoryFilter'
import type { AppState } from '../state/AppStore'
export interface SideMenuViewCallbacks {
readonly onSelectStore: (home: string) => void
readonly onAddStore: () => void
readonly onRefresh: () => void
readonly onSelectCategory: (filter: CategoryFilter) => void
readonly onSelectLocale: (locale: string) => void
}
/**
* The side menu: which store, what to do with it, and what to look at.
*
* Everything that is not a title lives here, and the bar's button folds it away.
*/
export class SideMenuView {
private readonly storesHead = requireElement('head-stores', HTMLElement)
private readonly actionsHead = requireElement('head-actions', HTMLElement)
private readonly categoriesHead = requireElement('head-cats', HTMLElement)
private readonly languageHead = requireElement('head-lang', HTMLElement)
private readonly storeList = requireElement('store-list', HTMLElement)
private readonly addStore = requireElement('add-store', HTMLButtonElement)
private readonly refresh = requireElement('refresh', HTMLButtonElement)
private readonly categories = requireElement('cats', HTMLElement)
private readonly locale = requireElement('locale', HTMLSelectElement)
public constructor (private readonly callbacks: SideMenuViewCallbacks) {
this.addStore.addEventListener('click', callbacks.onAddStore)
this.refresh.addEventListener('click', callbacks.onRefresh)
this.locale.addEventListener('change', (): void => { callbacks.onSelectLocale(this.locale.value) })
}
public render (state: AppState): void {
setText(this.storesHead, state.messages.stores)
setText(this.actionsHead, state.messages.actions)
setText(this.categoriesHead, state.messages.categories)
setText(this.languageHead, state.messages.language)
setText(this.addStore, state.messages.addStore)
setText(this.refresh, state.messages.refresh)
this.renderStores(state)
this.renderCategories(state)
this.renderLocales(state)
this.renderEnabled(state)
}
private renderStores (state: AppState): void {
const activeHome = state.currentStore === null ? null : state.currentStore.home
// 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 ambiguousIds = new Set(state.stores
.filter((store: InstalledStoreDto, index: number): boolean =>
state.stores.findIndex((other: InstalledStoreDto): boolean => other.id === store.id) !== index)
.map((store: InstalledStoreDto): string => store.id))
this.storeList.replaceChildren(...state.stores.map((store: InstalledStoreDto): HTMLElement => {
const row = createElement('button', 'store-row')
if (store.home === activeHome) row.classList.add('is-active')
row.appendChild(createElement('span', 'store-row-name', store.name))
row.appendChild(createElement('span', 'store-row-id',
ambiguousIds.has(store.id) ? store.home : store.id))
row.title = store.home
row.addEventListener('click', (): void => {
if (store.home !== activeHome) this.callbacks.onSelectStore(store.home)
})
return row
}))
}
private renderCategories (state: AppState): void {
const sections = buildCategorySections(state.games, state.messages)
const nodes: HTMLElement[] = []
for (const section of sections) {
if (section.title !== null) nodes.push(createElement('div', 'cat-group', section.title))
for (const item of section.items) nodes.push(this.createCategoryRow(item, state.filter))
}
this.categories.replaceChildren(...nodes)
}
private createCategoryRow (item: CategoryItem, active: CategoryFilter): HTMLElement {
const row = createElement('button', 'cat')
if (isSameFilter(item, active)) row.classList.add('is-active')
row.appendChild(createElement('span', 'cat-label', item.label))
row.appendChild(createElement('span', 'cat-count', String(item.count)))
row.addEventListener('click', (): void => {
this.callbacks.onSelectCategory({ kind: item.kind, value: item.value })
})
return row
}
private renderLocales (state: AppState): void {
const current = this.locale.value
if (current === state.locale && this.locale.options.length === state.locales.length) return
this.locale.replaceChildren(...state.locales.map((locale: Locale): HTMLOptionElement => {
const option = createElement('option', undefined, locale.toUpperCase())
option.value = locale
option.selected = locale === state.locale
return option
}))
}
/**
* While the store is working only what would start a second call is disabled; the
* filters stay live because they change what is on screen and nothing on disk.
*/
private renderEnabled (state: AppState): void {
const hasStore = state.currentStore !== null
this.refresh.disabled = state.busy || !hasStore
this.addStore.disabled = state.busy
for (const row of this.storeList.querySelectorAll('button')) row.disabled = state.busy
}
}

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