WarpEngine Client is the desktop app for a WarpEngine store: the catalog as a grid of cards, one click to install a title into your application menu, one to play it, one to remove it. Linux, macOS and Windows.

The store engine is part of the app. Reading the catalog, picking the release that fits the machine, unpacking it, writing the menu entry and remembering what went where all happen inside the application. Nothing to install first, no interpreter to find.
Which store it installs is not baked in: the client asks a registry (GET /api/stores on the site), and a record there is a name and a catalog, nothing more. Behaviour is the client's own business, and the games folder is a slug of the catalog host, so two stores on one machine never touch each other's files.
python3, python and py -3, and says so plainly if none answer.Take the package for your machine from the releases and open it. On first run, with no store on the machine, the window offers to download one. That is the whole setup.
The build is ad-hoc signed but not notarised, so macOS asks before running a copy that came from a browser:
xattr -dr com.apple.quarantine "/Applications/WarpEngine Client.app"
Open Anyway under System Settings ▸ Privacy & Security works too, after a blocked attempt. Only notarisation removes the step, and that needs a paid Apple Developer ID. Files the store downloads later are unaffected: Python does not set the quarantine flag.
v1.0.0 could not be opened at all — it reported "is damaged". The bundle was never signed; only its main executable had the linker's ad-hoc signature, so there was no resource seal and Gatekeeper refused instead of asking. scripts/after-pack.js now signs the bundle during the build, and the result verifies as valid on disk.
Nothing in this client knows anything about a particular store. Price, whether an account is needed, where to buy, where to sign in — all of it comes from the catalog's server: WarpEngine 0.5 answers GET /api/service with what it offers and puts an access block on every entry. That is why one build serves any shop.
Where the server offers no sign-in (every WarpEngine before 0.5, and any store that sells nothing) the window shows none. Where it does:
safeStorage, one per store. With no keychain it is not stored at all rather than written in the clear; the cost is signing in again next run.On a card, what a person may do is separate from what the machine can run:
| State | What the card offers |
|---|---|
| owned, or free | Install |
| not owned | the price, and Buy, which opens the store page in a browser |
| signed out, catalog gates it | Sign in to install — the catalog cannot say whose it is until it knows who is asking |
Buying happens in the browser: a checkout rebuilt here would be a second place to get card handling wrong. Refresh afterwards turns the card into an Install.
Two categories come with this — Owned and To buy — because owning is not the same as having installed.
An unbought title is not dimmed. Dimming means the machine cannot run it; nothing is wrong with the machine when a title simply costs money.
A bearer token is never sent to a host that did not issue it. A gated download redirects to signed storage — often somebody else's server — and some object stores reject a request that carries both an Authorization header and a signature.
On first run the client fetches the registry. One store and there is nothing to decide; several and the setup screen shows a picker.
[
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org" },
{ "name": "Some Other Store", "catalogUrl": "https://games.example.org" }
]
A name and a catalog are the whole record. The client already knows host-to-asset mapping, install modes, platforms and behaviour. What it cannot know is identity, and identity is all a registry says. From a record it derives:
teletypegames.org → teletypegames), or of the display name if that fails. It names the store home and the games folder. Two records naming the same catalog are the same store and land in the same place, so reinstalling never orphans anything.The registry address is the one site-specific thing left in the client, decided in three places, most specific first: a runtime STORES_API, the address the package was built with, then ours.
STORES_API=http://127.0.0.1:8731/stores npm start # trying something out
make dist STORES_API=https://games.example.org/api/stores # shipping it
The build variant is written into the packaged app's package.json (warpEngine.registryUrl), so a client built for someone else's catalog needs no source change and nothing set on the user's machine. Adding a store is a database row on the site, not a release of this app.
Everything that is not a title lives in the side menu; the menu button folds it away and the state survives restarts.
| Section | What it holds |
|---|---|
| Stores | every store on this machine, the open one marked. Clicking another switches the grid, the categories and the folders, and the client reopens there next time. Two stores from the same catalog in different folders show their folder instead of their id. Add a store opens the registry picker. |
| Account | Sign in… / Sign out, only where the catalog offers a sign-in. |
| Actions | Refresh, which re-reads the catalog. Titles install one at a time from their cards; there is no install-everything button. |
| Categories | one at a time, with counts: Everything, Installed, Updates, Not installed, then a row per platform and per kind (native or hosted). Built from what the catalog contains, so an empty platform is not listed, and a category that disappears falls back to Everything. A WarpEngine catalog has no genre field. |
| Log | the store's own output, verbatim, plus the two folders everything lands in. Hidden until asked for. |
| Language | follows the system, switchable: English and Hungarian. |
A card's button is Install, Update, or Play / Open once installed. 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, so its entry opens a page and needs the network.
Everything in the catalog is listed, including what this machine cannot install. Those cards are dimmed, carry an unsupported platform or no build for this machine badge with the engine's explanation, and have nothing to press — a C64 cartridge on a desktop, say. A store that hides them leaves the visitor guessing whether the catalog is small or their machine is odd. They have their own category, Not for this machine, and stay out of the native/hosted counts, because a title with no build has no mode to count. Needs desktop store engine 1.2.0 on warpstore 1.4.0; with an older engine the listing is all-installable, as before.
Every card carries a band of box art of the same height — the first letter of the title when there is no image — so buttons line up across a row. This was quietly broken until the window was photographed: rows split the window height evenly instead of following their content, which collapsed the art and clipped the buttons, while every automated count passed because the nodes were all in the page.
While the store is working, only the things that would start a second call are disabled. The menu, the log drawer and the filters keep working, because they change the screen and nothing on disk.
Anything installed here 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.
make is the front door; it wraps the npm scripts. make on its own lists everything.
| Target | What it does |
|---|---|
make setup |
install dependencies (checks the Node version first) |
make build |
compile TypeScript, bundle the preload and the renderer |
make typecheck |
type-check, emit nothing |
make lint |
the strict rule set (make lint-fix fixes what it can) |
make check |
typecheck, lint and both test suites — the gate |
make start |
run against whatever store is installed |
make smoke |
drive the store bridge with no window |
make uitest |
load the window once and report what rendered |
make test |
both checks |
make dist |
package for this machine (dist-mac, dist-win, dist-linux) |
make publish |
upload the packages in dist/ to the Gitea release |
make release |
package and publish in one go |
make clean |
remove built 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 beyond the release step.
.woodpecker.yaml lives in this repository, not in the update server's config extension: that extension serves game-platform pipelines, which build a cartridge and publish into the site's catalog, while this builds an application and publishes to a release.
| Step | Image | What it does |
|---|---|---|
check |
electronuserland/builder:22 |
install, type-check, lint, smoke test |
linux |
electronuserland/builder:22 |
AppImage and deb |
windows |
electronuserland/builder:22-wine |
NSIS installer and portable exe, through Wine |
release |
alpine |
on a tag only: creates the release and attaches what it built |
macOS stays a local build — Apple's toolchain exists only on a Mac — so a release has two halves: pushing a vX.Y.Z tag builds Linux and Windows and creates the release, and make release on a Mac pushes the macOS package onto the same release. Either half can go first. The window test is local too: it needs a display and a store on the machine.
Publishing needs a gitea_token repository secret with write access. Woodpecker does hand steps a forge credential, and the script uses it when the secret is missing, but it cannot be relied on: a manual build has it, a build started by the tag webhook does not. Two publishers exist — scripts/release.sh drives the tea login on a workstation, scripts/ci-upload.sh speaks the API with whatever credential CI has.
The Windows installer is not signed: Windows warns about an unknown publisher until there is a code-signing certificate. Linux packages carry no signature by convention.
make release
make publish TAG=v1.0.2 # a tag other than package.json's
scripts/release.sh dist/one-file.dmg # just one package
The tag comes from package.json, so npm version patch is the only place a version is set. The release is created if missing, and an attachment whose name is already there is replaced, so a rebuild plus a second make publish lands instead of erroring. Release notes come from RELEASE_NOTES.md when present. The repository is read from origin, so a fork publishes to the fork.
It needs tea installed and logged in — the devarea repo has make tea. Overridable: TAG, REPO, TEA_LOGIN, NOTES, DIST.
Node 22+ is needed to install, not to run: Electron's installer is ESM-only and older Node cannot require() it. The packaged app carries its own runtime.
npm run uitest uses its own user-data directory and skips 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, which is how this repo is tested without touching a working installation:
STORE_ROOT=/tmp/sandbox-root npm start
SMOKE_HOME=/tmp/sandbox-root/ttg-desktop npm run smoke
TypeScript in layers, dependencies pointing inward. STRUCTURE.md in the repo is the map — layers, patterns, naming rules — and it is the file to read before adding anything.
| Layer | What lives there |
|---|---|
src/shared/ |
the IPC channel table, the bridge contract, the DTOs, the message bundles |
src/domain/ |
models, ports and errors — no Electron, no Node, no Python |
src/application/ |
services and the domain-to-DTO mappers |
src/infrastructure/ |
adapters: the Python CLI, HTTP, the filesystem, Electron |
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 |
Two properties the layers buy:
The linter is strict on purpose: explicit types on every signature, no any, no non-null assertions, exhaustive switches, outside data read through checked readers rather than cast. A translation with a missing key does not compile, and neither does a channel the preload does not implement.
| File | What it does |
|---|---|
main.js |
the window, the IPC, 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, 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 |
scripts/release.sh |
creates the Gitea release and replaces its attachments |
scripts/after-pack.js |
ad-hoc signs the macOS bundle during packaging |
contextIsolation on, nodeIntegration off, sandbox on, and a CSP that allows only the app's own script and stylesheet plus images over HTTPS. Links open in the real browser; the window never navigates.
lib/store.js talks to the CLI through --json: data on stdout, the human 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 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.
Exercised on macOS (arm64) with the packaged app from the release, not 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.
SELFTEST_SHOT set the window captures itself into a PNG, which is how the collapsed rows were found and the fix confirmed — both languages, menu open and closed.config.json and one without. Both installed, and the engine listed all ten titles with the synthesised config. uitest was run with a store (grid), without one (setup gate, both names in the picker), and with the registry unreachable (retry gate).codesign --verify --deep --strict clean, syspolicy_check reporting only the expected "adhoc signed" warning. A quarantined copy is still stopped until approved — 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 has the same gap: .desktop and .lnk launchers have been generated and read, but nobody has clicked one.