EN /HU | Login

WarpEngine Client

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 WarpEngine Client window: the store picker and the category filters on the left, the catalog as a grid of game cards on the right

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.

What it needs

  • Python 3, because the store is a Python program. The app tries python3, python and py -3, and says so plainly if none answer.
  • Nothing else. No Node, no package manager, no admin rights — it installs under your own user account.

Install

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.

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:

SH
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.

Signing in, and titles that cost money

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:

  • the side menu grows an Account block: Sign in…, then Sign out;
  • signing in shows a short code. The browser opens the store's page, the code is typed there, and approving it signs this device in. No password ever reaches the client — that is the point of the detour.
  • the token lives in the OS keychain (Keychain, libsecret, DPAPI) via Electron's 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.

Which store it installs

On first run the client fetches the registry. One store and there is nothing to decide; several and the setup screen shows a picker.

JSON
[
  { "name": "Teletype Games", "catalogUrl": "https://teletypegames.org" },
  { "name": "Some Other Store", "catalogUrl": "https://games.example.org" }
]

A name and a catalog are the whole record. The 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:

  • the store id — a slug of the catalog host (teletypegames.orgteletypegames), 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 games folder — that slug inside the OS's usual place for installed programs. It is also the only subtree this store ever deletes from.
  • which titles are listed — released, archived and demo. A catalog that publishes a demo means it to be played.

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.

SH
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.

Use

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.

Development

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.

Continuous integration

.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.

Publishing a release

SH
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:

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, 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 catalog can be driven with no window and no Electron — the smoke test assembles the same services against the same ports;
  • the window never receives a filesystem path. A title crosses the bridge without one, and launching is asked for by name and resolved in the main process from the store's own state.

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.

Verified, and not

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.

  • The grid was checked by looking at it. With 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.
  • The side menu was measured with two stores in one root (a sandbox copy beside the real install); the window check clicks the store that is not open and verifies that the bar, the grid and the categories follow. With one store the switch is skipped.
  • The registry path was exercised against a local endpoint serving the site's payload with two records, one store repository with a 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).
  • The signing was measured, not assumed: quarantine flag set on a copy unzipped from the release artifact, 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.