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>
379 lines
20 KiB
Markdown
379 lines
20 KiB
Markdown
# warp-engine-client — the WarpEngine Store app
|
|
|
|
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
|
|
guard — the window never touches the filesystem itself.
|
|
|
|
It is also **the Windows install path**. The store's own installer is
|
|
`curl … | sh`, which Windows does not have; this app downloads the store engine
|
|
itself, into the same folder the shell installer would use.
|
|
|
|
Which store it installs is not baked in: the client asks a registry — `GET
|
|
/api/stores` on the site — and each record says what the store is called, which
|
|
catalog it serves and where its configuration lives.
|
|
|
|
## What it needs
|
|
|
|
- **Python 3** on the machine, because the store is a Python program. The app
|
|
looks for `python3`, `python` and `py -3`, and says so plainly if none answer.
|
|
- Nothing else at runtime. No Node, no package manager, no admin rights: the
|
|
store installs under your own user account.
|
|
|
|
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-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.
|
|
|
|
### Opening it on macOS
|
|
|
|
The build is ad-hoc signed but **not notarised**, so macOS asks before running a
|
|
copy that came from a browser. The reliable way through:
|
|
|
|
```sh
|
|
xattr -dr com.apple.quarantine "/Applications/WarpEngine Store.app"
|
|
```
|
|
|
|
If macOS offers *Open Anyway* under **System Settings ▸ Privacy & Security** after
|
|
a blocked attempt, that works as well. Notarisation is the only thing that removes
|
|
the step entirely, and it needs a paid Apple Developer ID.
|
|
|
|
Nothing the store itself downloads is affected: Python fetches those files, and
|
|
Python does not set the quarantine flag.
|
|
|
|
**v1.0.0 could not be opened at all** — it reported *"is damaged"*. The bundle had
|
|
never been signed; only its main executable carried the linker's ad-hoc signature,
|
|
so there was no resource seal and Gatekeeper refused it outright rather than
|
|
asking. `scripts/after-pack.js` signs the bundle during the build now, and the
|
|
result verifies as `valid on disk`.
|
|
|
|
## Which store it installs
|
|
|
|
On first run the client fetches the registry and offers what it finds. One store
|
|
and there is nothing to decide; several and the setup screen shows a picker.
|
|
|
|
```json
|
|
[
|
|
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org", "storeRepositoryUrl": null },
|
|
{
|
|
"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:
|
|
|
|
- **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.
|
|
- **`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.
|
|
|
|
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:
|
|
|
|
```sh
|
|
STORES_API=http://127.0.0.1:8731/stores npm start
|
|
```
|
|
|
|
Adding a store is therefore a database row on the site — see its ActiveAdmin
|
|
panel — and not a release of this app.
|
|
|
|
## Use
|
|
|
|
Everything that is not a title lives in the **side menu** on the left, and the
|
|
`☰` button in the bar folds it away — the state is remembered between runs.
|
|
|
|
- **Stores** lists every store on this machine, the open one marked. Clicking
|
|
another switches to it: the grid, the categories and the folders all follow, and
|
|
the client reopens on that store next time. Two stores installed from the same
|
|
catalog into different folders show their folder instead of their id, because
|
|
the id would not tell them apart. **Add a store…** brings up the registry
|
|
picker, the same one the first run offers.
|
|
- **Actions** holds **Refresh**, which re-reads the catalog. Titles are installed
|
|
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).
|
|
The axes are built from what the catalog actually contains — a platform with no
|
|
titles is not listed, and a category that disappears under you falls back to
|
|
*Everything* rather than leaving an empty grid. There is no genre in a
|
|
WarpEngine catalog, so these are the categories there are.
|
|
- **Log** opens the store's own output — its words, verbatim — together with the two
|
|
folders everything lands in. Off screen until asked for: the window has no footer,
|
|
because a permanent bar of absolute paths is not what a store is for.
|
|
- **Language** follows the system and can be switched; **English and Hungarian**.
|
|
|
|
In the grid, a card's button is **Install**, **Update**, or **Play** / **Open**
|
|
once it is there. **Remove** takes a title back out. Each card says whether it is
|
|
**native** — unpacked and run locally, works offline — or **hosted**: a browser
|
|
build the catalog serves rather than packages, so its entry opens a page and needs
|
|
the network.
|
|
|
|
Every card carries a band of box art the same height — the first letter of the
|
|
title when the catalog has no image — so titles and buttons line up across a row.
|
|
Until this was photographed, the grid was quietly broken: the rows split the
|
|
window's height evenly instead of following their content, which collapsed the art
|
|
to nothing and clipped the buttons out of sight.
|
|
|
|
While the store is working, only the things that would start a second call are
|
|
disabled: the menu, the log drawer and the category filters keep working, because
|
|
they change what is on screen and nothing on disk.
|
|
|
|
Anything installed from the window is a normal menu entry, so it also shows up in
|
|
your launcher, Dock or Start menu — the app does not have to be running to play.
|
|
|
|
## Development
|
|
|
|
`make` is the front door; it wraps the npm scripts so the useful sequences have
|
|
names. `make` on its own lists everything.
|
|
|
|
| Target | What it does |
|
|
|---|---|
|
|
| `make setup` | install the dependencies (checks the Node version first) |
|
|
| `make build` | compile TypeScript, bundle the preload and the renderer |
|
|
| `make typecheck` | type-check everything, emitting nothing |
|
|
| `make lint` | the strict rule set (`lint-fix` fixes what it can) |
|
|
| `make check` | **typecheck, lint and both test suites** — the gate |
|
|
| `make start` | run the app against whatever store is installed |
|
|
| `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 test suites |
|
|
| `make dist` | package for this machine (`dist-mac`, `dist-win`, `dist-linux` to pick) |
|
|
| `make publish` | upload the packages already in `dist/` to the Gitea release |
|
|
| `make release` | **package and publish in one go** |
|
|
| `make clean` | remove `build/` and the packages (`distclean` also drops `node_modules`) |
|
|
| `make version` | the versions involved, including whether `tea` is there |
|
|
|
|
The npm scripts still work directly (`npm start`, `npm run dist:mac`) — the
|
|
Makefile adds no logic of its own beyond the release step. Every script that runs the
|
|
app builds first, so there is no way to test a stale bundle.
|
|
|
|
### 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 **no secret**. It authenticates with the forge credential
|
|
Woodpecker hands every step for cloning, which is an access token of the repository's
|
|
owner; Gitea takes it as `token …` or `Bearer …` depending on how Woodpecker was set up,
|
|
so `scripts/ci-upload.sh` probes which of the two works instead of assuming. To publish
|
|
as somebody else, add a `gitea_token` repository secret and map it into the step.
|
|
|
|
There are two publishers on purpose: `scripts/release.sh` drives `tea`, which is logged
|
|
in on a workstation, and `scripts/ci-upload.sh` speaks the API with whatever credential
|
|
CI has. Each is short enough to read in full; one script with two ways to authenticate
|
|
would not be.
|
|
|
|
Both build steps end by checking what they produced: a package under 10 MB did not
|
|
finish. That check exists because a half-finished Wine build leaves a stub *named* like
|
|
the real installer — 162 KB of it — and `ls` is perfectly happy with that.
|
|
|
|
**The Windows step cannot be rehearsed on an Apple Silicon Mac.** Wine assumes 4 KB
|
|
memory pages and this host has 16 KB ones, so an emulated amd64 container dies with
|
|
`anon_mmap_fixed: Assertion failed`. It is a property of the machine, not of the
|
|
pipeline; the x86_64 runner is where that step is proven. The Linux step was rehearsed
|
|
locally in the same image and produced both packages.
|
|
|
|
The Windows installer is **not signed**: Windows will warn about an unknown publisher
|
|
until there is a code-signing certificate. Linux packages carry no signature by
|
|
convention.
|
|
|
|
### Publishing a release
|
|
|
|
```sh
|
|
make release
|
|
```
|
|
|
|
This is the **macOS half** of a release; the Linux and Windows packages come from the
|
|
pipeline when the tag is pushed (see above). The tag comes from `package.json`, so
|
|
`npm version patch` is the only place a version is set. The release is created if it is
|
|
not there yet — either half can go first — and an attachment whose name is already on it
|
|
is **replaced** rather than refused, so a rebuild and a second `make publish` lands
|
|
rather than erroring.
|
|
|
|
Release notes come from `RELEASE_NOTES.md` when the file is present, otherwise the
|
|
release gets a one-line note. The repository is read from `origin`, so a fork
|
|
publishes to the fork.
|
|
|
|
Each upload is retried up to three times, and the existing attachment is dropped
|
|
before every attempt so a retry cannot leave two copies. A 100 MB upload does fail
|
|
on its own: publishing 1.2.0 got *"invalid username, password or token"* on the
|
|
second package while the first had just gone up with the same token, and the same
|
|
command succeeded immediately afterwards.
|
|
|
|
Package names 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`.
|
|
|
|
```sh
|
|
make publish TAG=v1.0.2 # a tag other than package.json's
|
|
scripts/release.sh dist/one-file.dmg # just one package
|
|
```
|
|
|
|
**Node 22 or newer is needed to install**, not to run: Electron's own installer
|
|
is ESM-only, and older Node cannot `require()` it. The packaged app carries its
|
|
own runtime.
|
|
|
|
`npm run uitest` runs with its own user-data directory and without the
|
|
single-instance lock. Otherwise a copy the user already has open swallows the test
|
|
process, which exits 0 and reads as a pass.
|
|
|
|
Both test scripts accept a sandbox store instead of the real one, which is how
|
|
this repository is tested without touching a working installation:
|
|
|
|
```sh
|
|
STORE_ROOT=/tmp/sandbox-root npm start
|
|
SMOKE_HOME=/tmp/sandbox-root/ttg-desktop npm run smoke
|
|
```
|
|
|
|
### How it is put together
|
|
|
|
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 |
|
|
|---|---|
|
|
| `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 |
|
|
|
|
Two properties are worth stating because they are what the layers buy:
|
|
|
|
- **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.
|
|
|
|
`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
|
|
and both languages in the picker. The bootstrap download was run into an empty
|
|
directory and the resulting store answered the bridge.
|
|
|
|
The grid was checked by looking at it, not only by counting nodes: `SELFTEST_SHOT`
|
|
has the window capture itself, which is how the collapsed rows were found — the DOM
|
|
had ten cards and twenty buttons all along, and every count passed while the page
|
|
showed neither art nor buttons. A screenshot from outside the app is not available
|
|
here, so the window takes its own.
|
|
|
|
The side menu was measured with two stores in one root — a sandbox copy alongside
|
|
the real install — and `npm run uitest` clicks the store that is not open and
|
|
checks that the bar, the grid and the categories follow. With a single store the
|
|
switch is skipped, which is what the normal run reports.
|
|
|
|
The registry path was exercised against a local endpoint serving the same payload
|
|
the site returns, with two records: one store whose repository has a `config.json`
|
|
and one without. Both installed, and the engine listed all ten titles with the
|
|
synthesised config. `npm run uitest` was run twice — with a store present it shows
|
|
the grid, with none it shows the setup gate and its picker carries both names —
|
|
and once more with the registry unreachable, which produces the retry gate.
|
|
|
|
The signing was measured rather than assumed, by setting the quarantine flag on a
|
|
copy unzipped from the release artifact: `codesign --verify --deep --strict` is
|
|
clean, and `syspolicy_check` reports only the expected *"adhoc signed"* warning.
|
|
A quarantined copy is still stopped until it is approved — that part is Gatekeeper
|
|
policy, not a fault in the package.
|
|
|
|
**Not tried on Linux or Windows.** The paths and the launch behaviour are written
|
|
for them, and the store CLI itself has the same gap — `.desktop` and `.lnk`
|
|
launchers have been generated and read, but nobody has clicked one.
|