13 Commits
Author SHA1 Message Date
mr.zeroandClaude Opus 5 06f3f2a3b1 The store engine moves into the client, and Python goes with it
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
Reading the catalog, choosing the release that fits this machine, unpacking it,
writing the menu entry and remembering what went where all happen in process now.
There is no interpreter to find, no child process, and no JSON-lines protocol
between the two halves — `PythonEngineProcessRunner`, the runtime locator, the two
engine mappers and the version negotiation are all gone, and with them the one
unchecked cast this codebase had (engine stdout to a typed event).

What that buys a person: on Windows and on a fresh Mac the app simply works. It
used to look for `python3`, `python` and `py -3` and draw a link to python.org
where none answered.

What lands on disk is unchanged, deliberately. `config.json` and `state.json` keep
the shell engine's snake_case shape, its `<scope>:<name>` keys and its file modes,
so a machine whose library was installed by the CLI keeps it — verified against the
Python engine on the same catalog: the same 13-title listing with zero field
differences, byte-identical payloads, identical modes and an identical Info.plist,
and a re-sync over a Python-installed home that writes nothing. Remove, prune,
prune-suppression on a named sync and the v1 state migration were each exercised.

Three things worth knowing about the new code:

  - the zip reader is ~150 lines over `node:zlib`, because Node has none and this
    application has no runtime dependencies. It restores the executable bit from
    each entry's external attributes, without which nothing installed can start,
    and it refuses zip64, unknown compression and paths that escape the
    destination rather than guessing;

  - `SUPPORTED_WARP_ENGINE_VERSIONS` names the engine versions this client is
    written against, checked against the `WarpEngine-Version` header every
    response carries. `selectCatalogDialect` switches over that list exhaustively,
    so adding a version fails the build — type checker and linter both — until
    somebody says what its catalog reads like. An absent header is read as the
    oldest version, which is what an engine before 0.4.0 is;

  - refresh and the language picker are icons at the foot of the side menu now,
    both named for a tooltip and a screen reader, the picker still a real
    `<select>` under its glyph.

The repository is free of Python as well: the Makefile, the CI check and the
release script read package.json and the forge's JSON with Node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:36:25 +02:00
mr.zeroandClaude Opus 5 8511ccbef8 WarpEngine Client: the whole catalog, and a build that can point elsewhere
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
**The app is called WarpEngine Client.** "Store" named the thing it opens rather than the
thing you run, and the store is a catalog on a site, not a window on your machine. The
window title, the bundle, the packages and the menu entry follow; the repository already
did. The store being driven is named in the side menu, so the bar stopped repeating it as
a badge — the element stays in the page, hidden, because the window check reads it.

**Every title is listed, including the ones this machine cannot install.** They arrive
from the engine with `installable: false` and a reason, and they are drawn dimmed, with an
*unsupported platform* or *no build for this machine* badge, the engine's own sentence
underneath, and nothing to press: a disabled Install would invite a click that can never
work. They get a category of their own — *Not for this machine* — and they are kept out of
the native/hosted categories and counts, because a title with no build has no mode to be
counted under. An engine older than desktop 1.2.0 is unaffected: a missing `installable`
field reads as installable, which is what those engines mean.

**A build can be pointed at another site's registry:**

    make dist STORES_API=https://games.example.org/api/stores

BuildConfiguration reads the packaged package.json, where electron-builder's
extraMetadata writes that address, so a client for somebody else's catalog needs no source
change and nothing set on the user's machine. Precedence is runtime environment, then
build, then ours — three audiences, most specific first.

Also: the scrollbars are the window's own, because the platform's light track down the
side menu of a dark window looked like a mistake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 19:45:54 +02:00
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
mr.zeroandClaude Opus 5 cd5361e222 A side menu, categories, and a store switcher
Everything that is not a title moved out of the bar into a menu on the left that
folds away with the button in the bar; the state is remembered between runs. It
carries the stores on this machine, the two actions, the categories and the
language.

Categories narrow the grid one at a time, with counts: the state of a title on
this machine, then a row per platform and per kind. The axes are built from what
the catalog contains — a WarpEngine catalog has no genre — and a category that
disappears under you falls back to Everything rather than leaving a blank grid.

With more than one store installed, clicking another in the menu opens it: the
grid, the categories and the folders follow, the choice is remembered, and stores
sharing an id are told apart by their folder. app:state now reports every store,
store:use switches, and the engine is re-checked per store because two stores can
be at different versions.

While the CLI runs, the menu, the log drawer and the filters stay live; only what
would start a second call is disabled.

Fixes the grid, which was broken and passing every check: its implicit rows split
the window's height evenly instead of following their content, so every card came
out 94px tall with its box art collapsed to nothing and its buttons clipped away.
The DOM was intact throughout — ten cards, twenty buttons — which is exactly why
the counts said nothing. Rows are content-sized now, and the art is one band of
one height, with the title's first letter where the catalog has no image.

So the window is looked at and not only counted, `SELFTEST_SHOT` has it
photograph itself; the terminal has no screen-recording permission here. The
selftest also clicks the store that is not open when there are two, and checks
that the bar, the grid and the categories follow.

Also fixes scripts/release.sh, which could not upload a package whose name has a
space in it — "WarpEngine Store-1.1.0-arm64.dmg" does — because the list was one
string split on whitespace. And `make release` cleans dist/ first, so a release
cannot pick up the previous version's artifacts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 14:12:37 +02:00
mr.zeroandClaude Opus 5 0f5da38a27 Release notes for 1.1.0
The release script uses RELEASE_NOTES.md as the body when it is there, so the
notes are reviewable in a diff rather than typed into a web form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 13:23:06 +02:00
mr.zeroandClaude Opus 5 cb8a28b156 Ask a registry which store to install
The client had our store's config URL compiled into it, which meant a second store
— or anybody else's site — needed a release of this app. It now asks
`GET /api/stores` and installs what the site offers: one record and there is
nothing to decide, several and the setup screen shows a picker.

From a record the client works the rest out. `storeRepositoryUrl` gives the
`config.json` to read, and that file stays the authority on how the store behaves;
`catalogUrl` and `name` override its `store.base_url` and `store.name`, because the
registry is what says which catalog a store is *for*. The store id — which names
the store home and the folder games land in — comes from the repository name, so
`ttg-desktop-store` becomes `ttg`.

A repository with no `config.json` still installs: 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. That was worth having
rather than an error, and it is tested.

The registry address is now the single thing about a particular site left in the
client, and `STORES_API` overrides it — which is how this was tested, against a
local endpoint serving the same payload the site returns, with one store that has a
config and one that has not. Both installed; the engine listed all ten titles with
the synthesised config.

`npm run uitest` now passes on either outcome — the grid when a store is present,
the setup gate with a populated picker when there is none — and it reports both, so
the gate cannot silently regress into an empty screen. Run with the registry
unreachable it produces the retry gate, and fails, which is the honest verdict: a
client that cannot reach the registry cannot set anything up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 13:13:36 +02:00
mr.zeroandClaude Opus 5 85c6d33b05 A Makefile over the npm scripts, and one command that publishes
Building and releasing were a build followed by remembered `tea` invocations, and
the second half was the part I got wrong by hand: the first release went out with
assets attached through a path that half-failed. `make release` is now one command
— package for this machine, then create the release and upload — and the pieces
are also available separately as `make dist` and `make publish`.

The Makefile adds no logic beyond that: every other target wraps an npm script, so
`npm start` and `npm run dist:mac` keep working. What it does add is a Node
version guard on anything that touches Electron's installer, because a Node 20
`npm install` fails deep inside a postinstall script with an ESM error that says
nothing about the cause.

`scripts/release.sh` holds the publishing, in POSIX sh like the other repositories'
scripts:

- the tag comes from package.json, so `npm version patch` is the only place a
  version is written;
- an attachment whose name is already on the release is replaced rather than
  refused, which is what makes rebuild-and-upload repeatable;
- the repository is read from `origin`, so a fork publishes to the fork;
- `RELEASE_NOTES.md` becomes the release body when it exists.

Tested against the live release with a small probe file rather than by pushing 240
MB twice: creation is skipped when the release exists, a repeat upload takes the
replace path, and an empty `dist/` refuses with the command to run instead. The
probe was removed afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 12:10:41 +02:00
mr.zeroandClaude Opus 5 e635a032ea Sign the macOS bundle, or it arrives "damaged"
The first release could not be opened: macOS said "WarpEngine Store is damaged
and can't be opened. You should move it to the Bin."

Not a wording problem — an integrity one. electron-builder found no signing
identity and skipped signing, so the bundle kept only the linker's ad-hoc
signature on its main executable, with no resource seal. `codesign --verify` said
"code has no resources but signature indicates they must be present", and
Gatekeeper reports that as damaged and offers no way past it, unlike an
un-notarised app which can at least be approved.

`scripts/after-pack.js` now signs the bundle itself during packaging. Measured on
a copy unzipped from the artifact with the quarantine flag set by hand:

  before  code has no resources but signature indicates they must be present
  after   valid on disk; satisfies its Designated Requirement

and the identifier is ours rather than `Electron`. `syspolicy_check` is down to
its expected "adhoc signed" warning. A downloaded copy still has to be approved —
that is Gatekeeper policy for anything un-notarised, and notarisation needs a paid
Developer ID — so the README and the release notes lead with the one command that
does it.

Two smaller things the failure turned up:

- The self-test was passing silently. With a copy of the app already open, the
  second process lost the single-instance lock and exited 0 with no output, which
  reads exactly like success. It now uses its own user-data directory and skips
  the lock, and it caught a real launch failure immediately afterwards.
- The README claimed right-click ▸ Open was enough. It was not, and I had not
  checked it — replaced with what the measurements support.

v1.0.0's attachments are withdrawn rather than left downloadable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 11:55:17 +02:00
141 changed files with 10231 additions and 1422 deletions
+1
View File
@@ -1,2 +1,3 @@
node_modules/
build/
dist/
+68
View File
@@ -0,0 +1,68 @@
# 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 runs here unconditionally — it needs nothing
# but Node, now that the store engine is part of the application.
- npm run smoke
# 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
+122
View File
@@ -0,0 +1,122 @@
# WarpEngine Client — the front door to the npm scripts.
#
# Everything here is a thin wrapper: the app is an Electron project, so npm still
# does the work. The Makefile exists so the useful sequences have names, and so
# `make release` is one command rather than a build followed by remembered tea
# invocations.
#
# 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
#
# Publishing assumes `tea` is installed and logged in — the devarea repo has
# `make tea` for that.
SHELL := /bin/sh
SCRIPTS := scripts
NODE_MIN := 22
# The version is package.json's, so the release tag never drifts from the app. Read with
# node, which this project already requires — nothing here needs an interpreter the app
# itself no longer depends on.
VERSION := $(shell node -p 'require("./package.json").version')
TAG ?= v$(VERSION)
# Which site's store registry a packaged build reads. Empty means the default in
# package.json (ours); set it to build a client for somebody else's catalog:
#
# make dist STORES_API=https://games.example.org/api/stores
#
# It is baked into the package's own package.json, so the built app carries it. A runtime
# STORES_API still overrides it, which is for trying something out rather than shipping.
STORES_API ?=
BUILDER_ARGS := $(if $(STORES_API),-- --config.extraMetadata.warpEngine.registryUrl=$(STORES_API),)
.DEFAULT_GOAL := help
.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 Client $(VERSION) — usage: make <target>"
@echo
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
@echo
@echo " Variables: TAG=$(TAG) TEA_LOGIN=ttg REPO=<owner/name> NOTES=RELEASE_NOTES.md"
node-check: ## Check the Node version Electron's installer needs
@node -e 'const [maj] = process.versions.node.split("."); \
if (Number(maj) < $(NODE_MIN)) { \
console.error("Node $(NODE_MIN)+ is needed to install Electron (found " + process.versions.node + \
"): its installer is ESM-only. The packaged app carries its own runtime."); \
process.exit(1); \
} else { console.log("node " + process.versions.node + " ok"); }'
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
smoke: ## Drive the store bridge with no window at all
npm run smoke
uitest: ## Load the window once and report what rendered
npm run uitest
test: smoke uitest ## Both checks
dist: node-check ## Package for this machine
npm run dist $(BUILDER_ARGS)
dist-mac: node-check ## Package for macOS (ad-hoc signed, see the README)
npm run dist:mac $(BUILDER_ARGS)
dist-win: node-check ## Package for Windows
npm run dist:win $(BUILDER_ARGS)
dist-linux: node-check ## Package for Linux
npm run dist:linux $(BUILDER_ARGS)
publish: ## Upload the packages already in dist/ to the Gitea release
@TAG=$(TAG) $(SCRIPTS)/release.sh
# clean first: dist/ keeps earlier builds, and a release should be made of exactly
# what this version produced.
release: clean dist publish ## Package for this machine and publish it
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
version: ## Show the versions involved
@echo "app $(VERSION) (tag $(TAG))"
@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 "registry "; node -p 'require("./package.json").warpEngine.registryUrl'
@if [ -n "$(STORES_API)" ]; then printf " build override: %s\n" "$(STORES_API)"; fi
+382 -55
View File
@@ -1,66 +1,298 @@
# warp-engine-desktop-gui — a window for the desktop store
# warp-engine-client — the WarpEngine Client 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 client for a WarpEngine 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 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.
**The store engine is part of this application.** Reading the catalog, choosing which
release fits this machine, unpacking it, writing the menu entry and remembering what
went where all happen in process — there is no interpreter to find and no child process
to parse. What lands on disk has not changed: `config.json` and `state.json` keep the
shape the shell engine wrote, so a machine whose library was installed by the CLI keeps
it, and the engine's own defaults still decide everything a store does not configure.
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.
That also makes this the only install path that needs nothing of the machine. The shell
store's installer was `curl … | sh`, which Windows does not have.
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.
- **Nothing.** No interpreter, no package manager, no admin rights: the app carries its
own runtime and 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.
The macOS build is **not signed or notarised**, so the first open needs
*right-click ▸ Open* (or *System Settings ▸ Privacy & Security*). Nothing the
store itself downloads is affected: those files are fetched by Python, which does
not set the quarantine flag.
### 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 Client.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: the app fetches those files over its
own HTTP client, which 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 it is decided in three places, most specific first:
```sh
STORES_API=http://127.0.0.1:8731/stores npm start # runtime: for trying something out
make dist STORES_API=https://games.example.org/api/stores # build: for shipping it
```
The build variant is baked into the packaged app's own `package.json`
(`warpEngine.registryUrl`, written by `electron-builder --config.extraMetadata`), so a
client built for somebody else's catalog needs no source change and no environment on the
user's machine. With neither set, the address is ours.
Adding a store is therefore a database row on the site — see its ActiveAdmin
panel — and not a release of this app.
## Use
- **Install all** fetches everything the catalog offers for this machine.
- 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.
- 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.
- The language follows the system and can be switched; **English and Hungarian**.
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.
**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 own explanation under it, and have nothing to press. A store
that hides them leaves you wondering whether the catalog is small or your machine is
unusual; this way it says which. They have a category of their own — *Not for this
machine* — and they are left out of the native/hosted counts, because a title with no
build has no mode to be counted under.
Every card carries a band of box art the same height — the first letter of the
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 a **`gitea_token`** repository secret — a Gitea token with
write access to this repository:
```sh
npm install
npm start # the window, against whatever store is installed
npm run smoke # the bridge only: no window, no Electron
npm run uitest # loads the window once and reports what rendered
npm run dist:mac # or dist:win / dist:linux
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
```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 Client-1.5.0-arm64.dmg` — so the list of
files is passed one path per line rather than as one string; splitting it on
whitespace is what broke the first attempt at publishing 1.1.0.
It needs `tea` installed and logged in — the devarea repo has `make tea` for that.
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:
@@ -71,34 +303,129 @@ 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` | downloads the engine, the shared core and a config |
| `lib/i18n.js` | the two string tables |
| `renderer/` | plain HTML, CSS and JS — no framework, no build step |
| `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 |
| `src/application/` | services and the domain → DTO mappers |
| `src/infrastructure/` | the adapters: the store engine, HTTP, the archive reader, 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.
`NativeStoreCatalogGateway` is the engine behind the `StoreCatalogGateway` port, and
`src/infrastructure/engine/` is the engine itself: the catalog client, the release
picker, the host match, the payload installer, the three launcher writers and the state
file. Above the port nothing knows any of that exists, which is the point — a second
host would be a second gateway, not a second code path.
`STORE_ENGINES` has one entry today. A RetroArch store writes playlists rather than
menu entries, so it would be an entry there and a gateway of its own.
### Reading a zip without a dependency
Node has no zip reader and this application has **no runtime dependencies**, so
`src/infrastructure/archive/ZipArchive.ts` is one over `node:zlib` — about 150 lines that
walk the central directory, inflate `stored` and `deflate` entries, and restore the
executable bit from each entry's external attributes. That last part is not a detail: the
archive records it, and without it nothing the store installs can start.
It reads what our own release pipeline produces and refuses the rest: a zip64 archive, an
unknown compression method and a path that would escape the destination are all errors
rather than best guesses.
### Which WarpEngine served the catalog
Every WarpEngine API response carries a `WarpEngine-Version` header, so the client knows
the engine's age without asking. `SUPPORTED_WARP_ENGINE_VERSIONS` lists the versions this
client is written against, and `selectCatalogDialect` maps each one to the `CatalogDialect`
that reads its catalog shape.
The switch over that list is exhaustive, which is the whole mechanism: adding a version to
the array stops the build — in the type checker *and* in the linter — until somebody says
what it reads like. A new engine version cannot arrive silently.
| What the header says | What happens |
|---|---|
| a supported version | its dialect reads the catalog, and the log names it |
| nothing at all | read as the oldest supported version, which is what an engine older than 0.4.0 is |
| older than anything supported | the same, and the log says so |
| newer than anything supported | the newest dialect is tried anyway, with a warning that titles may be missed |
Three versions share one dialect today, because the catalog's shape has not changed
across them. One class serving three versions is the honest way to say that.
## Verified, and not
Exercised on macOS (arm64): 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 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`
+86
View File
@@ -0,0 +1,86 @@
# WarpEngine Client 2.0.0
**The store engine is part of the app. Nothing has to be installed on the machine any
more.** Reading the catalog, choosing which release fits your computer, downloading and
unpacking it, writing the menu entry and remembering what went where all happen inside
the application now. There is no Python to find, no child process, and no JSON protocol
between the two halves — which is why this is a major version rather than a feature.
What that changes for a person: on Windows and on a fresh Mac the app simply works.
Before it looked for `python3`, `python` and `py -3`, and where none answered it drew a
screen with a link to python.org instead of a catalog. That screen is gone, along with
the one that offered to refresh a store engine too old to drive.
**Your existing library is kept.** `config.json` and `state.json` on disk are unchanged —
the same field names, the same `<scope>:<name>` keys, the same file modes — so a machine
whose games were installed by the shell store keeps them. Opening this version against
such a store lists them as installed, offers no needless update, and a sync reports
*already up to date*. A `version: 1` state file is still migrated on first read.
The two Python files an earlier install left in the store folder are removed the next
time that store is set up. Nothing reads them, and a folder that still looks like it
holds the engine invites someone to run it against a state file this app is also writing.
**The client knows which WarpEngine served a catalog.** Every WarpEngine API response
carries a `WarpEngine-Version` header, and the client now reads it, names the version in
the log, and picks the catalog dialect for it. `SUPPORTED_WARP_ENGINE_VERSIONS` lists what
this build was written against — 0.2, 0.3 and 0.4 — and the four cases are all handled:
| The header says | What the client does |
|---|---|
| a supported version | reads the catalog with that version's dialect |
| nothing at all | reads it as the oldest supported version — an engine before 0.4.0 sent no header |
| something older | the same, and says so in the log |
| something newer | tries the newest dialect anyway, warning that titles may be missed |
Adding a version to that list fails the build until somebody says what it reads like, in
the type checker and in the linter both. A new engine version cannot arrive unnoticed.
**Refresh and the language picker are icons.** They sit together at the foot of the side
menu, and the *Actions* heading that used to head a section of one button is gone. Both
carry their name as a tooltip and to a screen reader, and the language picker is still a
real `<select>` underneath — the native dropdown, keyboard and all, with only the glyph
showing.
### Also
No runtime dependencies, still: the zip reader the installer needs is about 150 lines over
`node:zlib` rather than a package. It restores the executable bit from each entry's
external attributes, which is what makes an unpacked game able to start at all, and it
refuses a zip64 archive, an unknown compression method or an entry that would be written
outside its destination rather than guessing.
The repository itself is free of Python too — the Makefile, the CI check and the release
script read `package.json` and the forge's JSON with Node now.
### Opening it on macOS
Ad-hoc signed, **not notarised**, so macOS asks first:
```sh
xattr -dr com.apple.quarantine "/Applications/WarpEngine Client.app"
```
### What is attached
The macOS package, built and verified on a Mac, plus the Linux (AppImage, deb) and
Windows (installer, portable) packages the pipeline builds when the tag is pushed.
### Verified
`make check` is clean: typecheck, lint, the headless smoke test and the window self-test.
The new engine was measured against the old one rather than trusted. On the same catalog
and the same config, the Python engine and this one produce **the same 13-title listing
with zero field differences** and the same resolved paths. Installing three titles — a
bare TIC-80 binary wrapped in a bundle, a Godot `.app` symlinked, and a hosted web entry —
gives **byte-identical payloads, identical file modes and an identical `Info.plist`**; the
only difference in the two trees is the sandbox path inside the generated launcher script.
`state.json` matches record for record.
The upgrade path was tested directly: pointed at a store home installed by the Python
engine, this one reports all three titles installed with no update available, and a
re-sync writes nothing. Remove, prune, prune-suppression on a named sync, and the v1→v2
state migration were each exercised. The zip reader was checked against Python's
`extractall` on an archive holding stored, deflated, directory and symlink entries —
identical bytes and identical modes — and its zip-slip and not-a-zip guards both fire.
+322
View File
@@ -0,0 +1,322 @@
# 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 or Node
application ← services and DTO mappers. Orchestrates the domain through its ports
infrastructure ← adapters: the store engine, 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/
engine/ the store engine: catalog, releases, install, state
dialects/ one per WarpEngine version's catalog shape
launchers/ .desktop, .app bundle, .lnk — the three hosts
archive/ ZipArchive: a zip reader over node:zlib
files/ StoreFileSystem: atomic writes and the delete guard
repositories/ the port implementations
http/ HttpTextClient, StoreHttpClient, HttpStatusError
json/ JsonRecord: reading data that came from elsewhere
config/ BuildConfiguration: what was decided when this was packaged
electron/ ApplicationEnvironment and GameLauncher adapters
main/
main.ts the entry point: one line of work
ElectronApplication.ts lifecycle, single instance, self-test mode
MainWindowFactory.ts the window and its security settings
composition/ ServiceContainer: the composition root
ipc/ IpcRouter, the controllers, the guard, argument readers
streams/ WindowStreamBroadcaster: the three one-way streams
diagnostics/ SelfTestRunner
preload/
preload.ts implements BridgeApi over ipcRenderer
renderer/
main.ts the entry point
RendererApplication.ts wires views and controllers, owns the boot decision
BridgeAccess.ts the typed window.storeApi
state/ AppStore, CategoryFilter
views/ one class per region of the window
controllers/ one class per group of actions
dom/ Dom.ts: the DOM chores
index.html, style.css copied into the build as-is
scripts/
SmokeTest.ts the second composition root, with no window
```
## Patterns
Every pattern in the codebase is listed here. If a change needs a pattern that is not
on this list, it belongs on this list.
### Ports and adapters
`domain/ports/*` are interfaces; `infrastructure/*` implements them; the composition
root is the only file that knows which implementation is in use. This is what makes the
store engine, the registry HTTP call and Electron's `shell` replaceable — by a stub in a
test, by a local endpoint in development, by a second host's engine later.
It has already paid for itself once: the engine used to be a Python CLI driven as a child
process, and replacing it with one that runs in process was a new adapter behind the same
port. Nothing in `application`, `main` or `renderer` changed shape for it.
### 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** — something with a protocol of its own, whether or not it is another
process: `StoreCatalogGateway` (the store engine), which today is
`NativeStoreCatalogGateway` in this application and was a Python CLI before it. The port
stays async because the work is: it downloads and unpacks.
### 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/engine/dialects/*` — catalog JSON → typed catalog records. These are
the only files that know a WarpEngine version's field names.
- `infrastructure/engine/StoreConfigurationReader`, `StoreStateRepository` — the two
snake_case files on disk → domain models. These are the only files that know the on-disk
field names, which are the shell engine's and stay that way.
- `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
(`StoreMissingError`, `EngineInvocationError`, `RegistryUnavailableError`, `BusyError`).
The code is what crosses the bridge.
### Frozen constant tables
`IPC_CHANNELS`, `STORE_ENGINES`, the message bundles: `as const` tables with a derived
type, so a typo is a compile error and adding an entry is the whole change. This is
the extension point for a second engine.
### Build-time configuration
`infrastructure/config/BuildConfiguration.ts` reads the packaged `package.json`, which is
where a build records the registry it was made for (`warpEngine.registryUrl`, set by
`make dist STORES_API=…`). Precedence is runtime environment, then build, then the
built-in default — most specific first, and each one is a different audience: someone
trying it out, someone shipping a client for another site, us.
### Untrusted-data readers
Anything parsed from outside — the catalog, a store's config, the state file, 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>` | `NativeStoreCatalogGateway`, `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` | `StoreMissingError` |
| 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 — and a `StoreCatalogGateway`
implementation for that host. Everything above the port is unchanged.
**A new WarpEngine version.** Add it to `SUPPORTED_WARP_ENGINE_VERSIONS`. The build then
fails in `selectCatalogDialect` until the switch says which `CatalogDialect` reads it:
either an existing one, when the catalog's shape did not change, or a new one beside
`SoftwareListCatalogDialect`.
**A new field from the catalog.** The dialect reads it into `CatalogSoftware`,
`CatalogRelease` or `CatalogAsset`; `SelectedGame` and `Game` carry it if the survey or the
window needs it; `GameDto` and `GameDtoMapper` take it across the bridge.
**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']
}
}
)
-82
View File
@@ -1,82 +0,0 @@
'use strict'
// Setting up the 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.
const fs = require('node:fs')
const https = require('node:https')
const path = require('node:path')
const FORGE = 'https://git.teletypegames.org'
const SOURCES = {
engine: `${FORGE}/stores/warp-engine-desktop-store/raw/branch/master/desktop_store.py`,
core: `${FORGE}/engines/warpstore/raw/branch/master/warpstore.py`,
config: `${FORGE}/stores/ttg-desktop-store/raw/branch/master/config.json`
}
/** GET a URL as a string, following redirects — a moved repo answers 301. */
function fetchText (url, redirects = 5) {
return new Promise((resolve, reject) => {
const request = https.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}`))
const next = new URL(res.headers.location, url).toString()
return fetchText(next, redirects - 1).then(resolve, reject)
}
if (res.statusCode !== 200) {
res.resume()
return reject(new Error(`${url} answered ${res.statusCode}`))
}
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)
})
}
/**
* Download 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. An existing config is left alone: a store that is
* already set up keeps its settings.
*/
async function install (home, { onLog = () => {} } = {}) {
fs.mkdirSync(home, { recursive: true })
const wrote = []
for (const [name, file] of [['engine', 'desktop_store.py'], ['core', 'warpstore.py']]) {
onLog(`downloading ${file}`)
const body = await fetchText(SOURCES[name])
if (!body.startsWith('#!/usr/bin/env python3')) {
throw new Error(`${file} does not look like the store engine — refusing to install it`)
}
const dest = path.join(home, file)
fs.writeFileSync(dest, body, { mode: 0o755 })
wrote.push(dest)
}
const config = path.join(home, 'config.json')
if (fs.existsSync(config)) {
onLog('keeping the config already in place')
} else {
onLog('downloading config.json')
const body = await fetchText(SOURCES.config)
JSON.parse(body) // a broken config would fail later and less clearly
fs.writeFileSync(config, body)
wrote.push(config)
}
onLog(`the store is set up in ${home}`)
return { home, config, script: path.join(home, 'desktop_store.py'), wrote }
}
module.exports = { FORGE, SOURCES, fetchText, install }
-94
View File
@@ -1,94 +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',
noGames: 'No installable titles in the catalog.',
setupTitle: 'Set up the store',
setupBody: 'The store engine is not on this machine yet. It can be downloaded now — the same files the shell installer would place, in the same folder.',
setupAction: 'Download the store',
setupWorking: 'Setting up…',
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ó',
noGames: 'Nincs telepíthető cím a katalógusban.',
setupTitle: 'A store beállítása',
setupBody: 'A store motorja még nincs ezen a gépen. Most letölthető — ugyanazok a fájlok, ugyanabba a könyvtárba, ahová a shell-telepítő tenné.',
setupAction: 'Store letöltése',
setupWorking: 'Beállítás…',
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) }
-228
View File
@@ -1,228 +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
}
function findStore () {
return findStores()[0] || null
}
/** 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, engineVersion, findPython,
findStore, findStores, list, parseVersion, paths, purge, remove, run, storeRoots, sync
}
-242
View File
@@ -1,242 +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')
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,
appName: document.getElementById('app-name').textContent,
storeId: document.getElementById('store-id').textContent,
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))
const good = result.cards > 0 && !result.gateVisible && result.locales.length > 1
console.log(good ? 'SELFTEST OK' : 'SELFTEST FAILED')
app.exit(good ? 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()
current = store.findStore()
// 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()),
python: python ? python.version : null,
store: current ? { id: current.id, home: current.home } : null,
engine: engine ? { text: engine.text, ok: engine.ok } : null,
minEngine: store.MIN_ENGINE.join('.'),
defaultHome: store.defaultHome(),
version: app.getVersion()
}
})
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())))
ipcMain.handle('store:bootstrap', () => guarded(async () => {
const home = store.defaultHome()
const result = await bootstrap.install(home, { onLog: (line) => send('store:log', line) })
current = { engine: 'desktop', id: path.basename(home).replace(/-desktop$/, ''), ...result }
return { id: current.id, home: current.home }
}))
/**
* 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 (!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
+33 -21
View File
@@ -1,36 +1,43 @@
{
"name": "warp-engine-desktop-gui",
"productName": "WarpEngine Store",
"version": "1.0.0",
"description": "Graphical client for a WarpEngine desktop store: install the catalog into your own application menu.",
"name": "warp-engine-client",
"productName": "WarpEngine Client",
"version": "2.0.0",
"description": "Graphical client for WarpEngine stores: install a 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",
"productName": "WarpEngine Client",
"files": [
"main.js",
"preload.js",
"lib/**/*",
"renderer/**/*"
"build/**/*",
"package.json"
],
"mac": {
"category": "public.app-category.games",
@@ -51,9 +58,14 @@
"AppImage",
"deb"
]
}
},
"afterPack": "scripts/after-pack.js"
},
"allowScripts": {
"electron@43.4.0": true
"electron@43.4.0": true,
"esbuild@0.28.2": true
},
"warpEngine": {
"registryUrl": "https://teletypegames.org/api/stores"
}
}
-26
View File
@@ -1,26 +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),
list: () => ipcRenderer.invoke('store:list'),
paths: () => ipcRenderer.invoke('store:paths'),
sync: (names) => ipcRenderer.invoke('store:sync', names),
remove: (name) => ipcRenderer.invoke('store:remove', name),
bootstrap: () => ipcRenderer.invoke('store:bootstrap'),
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))
})
-321
View File
@@ -1,321 +0,0 @@
'use strict'
// The whole renderer. No framework and no build step: the app is a grid of
// cards, 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 games = []
let paths = null
let busy = false
let plan = null // { total, done } while a sync is running
// --- 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
}
function setBusy (value) {
busy = value
for (const node of document.querySelectorAll('button')) {
if (node.id === 'log-toggle') 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 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 {
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 grid = el('grid')
grid.replaceChildren(...games.map(card))
grid.hidden = games.length === 0
const empty = el('empty')
empty.hidden = games.length !== 0
text(empty, T.noGames)
}
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
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()
}
// --- gate: no python, or no store yet -------------------------------------
function showGate (title, body, action, link) {
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)
const button = el('gate-action')
button.hidden = !action
if (action) {
text(button, action.label)
button.onclick = action.onClick
}
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
}
// --- 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)
renderPaths()
if (games.length) renderGrid()
}
async function boot () {
const state = await api.state()
applyStrings(state.strings)
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
}
const setUpStore = async () => {
showProgress(T.setupWorking)
try {
await api.bootstrap()
hideGate()
await refresh()
} catch (err) {
logLine(String(err && err.message ? err.message : err))
}
}
if (!state.store) {
showGate(T.setupTitle, `${T.setupBody}\n\n${state.defaultHome}`,
{ label: T.setupAction, onClick: async () => { await setUpStore(); await runSync([]) } })
return
}
if (state.engine && !state.engine.ok) {
showGate(T.oldEngineTitle,
`${T.oldEngineBody}\n\n${state.engine.text}${state.minEngine}`,
{ label: T.oldEngineAction, onClick: setUpStore })
return
}
text(el('store-id'), state.store.id)
hideGate()
await refresh()
}
el('sync-all').addEventListener('click', () => runSync([]))
el('refresh').addEventListener('click', () => refresh())
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()
-49
View File
@@ -1,49 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<!-- Nothing is loaded from the network except box art, and no inline code
runs: the app ships its own script and stylesheet. -->
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' https: data:; font-src 'self'; connect-src 'none'">
<title>WarpEngine Store</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="bar">
<div class="bar-title">
<span class="logo" aria-hidden="true"></span>
<span id="app-name">WarpEngine Store</span>
<span class="store-id" id="store-id"></span>
</div>
<div class="bar-actions">
<span class="progress" id="progress" hidden></span>
<button id="sync-all" class="btn btn-primary" disabled></button>
<button id="refresh" class="btn" disabled></button>
<select id="locale" class="select" aria-label="Language"></select>
</div>
</header>
<!-- Shown instead of the grid when there is nothing to drive yet. -->
<section id="gate" class="gate" hidden>
<h1 id="gate-title"></h1>
<p id="gate-body"></p>
<div class="gate-actions">
<button id="gate-action" class="btn btn-primary" hidden></button>
<a id="gate-link" class="link" href="#" hidden></a>
</div>
</section>
<main id="grid" class="grid" hidden></main>
<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>
<div class="log-paths" id="log-paths"></div>
</footer>
<script src="app.js"></script>
</body>
</html>
-188
View File
@@ -1,188 +0,0 @@
/* One dark theme, no assets: the box art is the only image the app loads. */
:root {
--bg: #11151c;
--panel: #182029;
--panel-2: #1e2732;
--line: #2a3440;
--ink: #e8eef5;
--ink-dim: #93a4b8;
--accent: #37b98a;
--accent-ink: #05130d;
--warn: #e0a44a;
--radius: 12px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, sans-serif;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/* --- top bar ------------------------------------------------------------ */
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 18px;
background: var(--panel);
border-bottom: 1px solid var(--line);
flex: none;
}
.bar-title { display: flex; align-items: baseline; gap: 10px; font-weight: 700; }
.logo { color: var(--accent); font-size: 18px; }
.store-id {
font-weight: 500;
font-size: 12px;
color: var(--ink-dim);
border: 1px solid var(--line);
border-radius: 999px;
padding: 1px 8px;
}
.bar-actions { display: flex; align-items: center; gap: 8px; }
.progress { color: var(--ink-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
/* --- controls ----------------------------------------------------------- */
.btn {
font: inherit;
font-weight: 600;
color: var(--ink);
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 8px;
padding: 7px 14px;
cursor: pointer;
transition: background .15s, border-color .15s, transform .05s;
}
.btn:hover:not(:disabled) { background: #26313e; border-color: #3a4757; }
.btn:active:not(:disabled) { transform: scale(.97); }
.btn:disabled { opacity: .45; cursor: default; }
.btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
.btn-primary:hover:not(:disabled) { background: #45cd9b; }
.btn-ghost { background: transparent; color: var(--ink-dim); }
.btn-tiny { padding: 3px 9px; font-size: 12px; font-weight: 500; }
.select {
font: inherit;
color: var(--ink);
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 8px;
padding: 6px 8px;
}
.link { color: var(--accent); cursor: pointer; text-decoration: underline; }
/* --- gate (no python, or no store yet) ---------------------------------- */
.gate {
margin: auto;
max-width: 520px;
padding: 28px;
text-align: center;
}
.gate h1 { font-size: 20px; margin: 0 0 10px; }
.gate p { color: var(--ink-dim); white-space: pre-line; margin: 0 0 20px; word-break: break-all; }
.gate-actions { display: flex; gap: 14px; justify-content: center; align-items: center; }
/* --- the grid ----------------------------------------------------------- */
.grid {
flex: 1;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 14px;
padding: 18px;
align-content: start;
}
.empty { margin: auto; color: var(--ink-dim); }
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
display: flex;
flex-direction: column;
}
.card.is-installed { border-color: #2f5a49; }
.art {
aspect-ratio: 4 / 3;
background: #0d1117;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.art img { width: 100%; height: 100%; object-fit: cover; }
.art-glyph { font-size: 44px; font-weight: 700; color: #263341; }
.body { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.body h2 { font-size: 15px; margin: 0; }
.meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.badge {
font-size: 11px;
font-weight: 600;
border-radius: 999px;
padding: 2px 8px;
border: 1px solid var(--line);
color: var(--ink-dim);
}
.badge-app { color: var(--accent); border-color: #2f5a49; }
.badge-web { color: var(--warn); border-color: #5a4a2f; }
.version { font-size: 12px; color: var(--ink-dim); margin-left: auto; }
.desc {
margin: 0;
font-size: 12.5px;
color: var(--ink-dim);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.actions { display: flex; gap: 8px; margin-top: auto; }
/* --- log ---------------------------------------------------------------- */
.log {
flex: none;
background: var(--panel);
border-top: 1px solid var(--line);
padding: 8px 18px 10px;
}
.log-toggle {
font: inherit;
font-size: 12px;
font-weight: 600;
color: var(--ink-dim);
background: none;
border: 0;
padding: 0 0 4px;
cursor: pointer;
}
.log-lines {
max-height: 150px;
overflow-y: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11.5px;
color: var(--ink-dim);
background: #0d1117;
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px 10px;
margin-bottom: 6px;
}
.log-line { white-space: pre-wrap; word-break: break-all; }
.log-paths { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.paths-line {
font-size: 11.5px;
color: var(--ink-dim);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
word-break: break-all;
flex: 1;
min-width: 200px;
}
+32
View File
@@ -0,0 +1,32 @@
'use strict'
// Ad-hoc sign the macOS bundle after packing.
//
// Without this the bundle carries only the linker's ad-hoc signature on the main
// executable, with no resource seal — `codesign --verify` says "code has no
// resources but signature indicates they must be present". That runs fine
// locally, but a browser download adds the quarantine flag, Gatekeeper evaluates
// the broken seal, and macOS reports the app as *damaged* rather than merely
// unverified. The first release shipped exactly that.
//
// An ad-hoc signature is not a Developer ID and does not notarise anything: the
// user still has to right-click ▸ Open the first time. It is the difference
// between "unidentified developer" and "move it to the Bin".
const { execFileSync } = require('node:child_process')
const path = require('node:path')
exports.default = async function afterPack (context) {
if (context.electronPlatformName !== 'darwin') return
if (process.platform !== 'darwin') {
console.log(' • ad-hoc signing skipped reason=codesign only exists on macOS')
return
}
const app = path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`)
// --deep is the pragmatic choice for ad-hoc signing a bundle with nested
// frameworks and helpers; Apple discourages it for real identities, where the
// inner-to-outer order matters.
execFileSync('codesign', ['--force', '--deep', '--sign', '-', app], { stdio: 'inherit' })
execFileSync('codesign', ['--verify', '--deep', '--strict', '--verbose=1', app], { stdio: 'inherit' })
console.log(` • ad-hoc signed ${app}`)
}
+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 Client Setup 1.5.0.exe" does — so the list
# lives one path per line in a file and is read with `while IFS= read -r`. A single
# variable looped over with $list splits on the space and uploads nothing.
LIST="$(mktemp)"
trap 'rm -f "$LIST"' EXIT
if [ "$#" -gt 0 ]; then
for given in "$@"; do printf '%s\n' "$given"; done > "$LIST"
else
# What this pipeline builds. The macOS packages are attached from the Mac that can
# sign them, so they are not listed here even when they happen to be present.
find "$DIST" -maxdepth 1 -type f \
\( -name '*.AppImage' -o -name '*.deb' -o -name '*.exe' \) 2>/dev/null | sort > "$LIST" || true
fi
[ -s "$LIST" ] || die "no Linux or Windows packages in $DIST"
say "$REPO $TAG"
# `curl -f` fails on the 404 a missing release answers, so the lookup is allowed to
# fail and judged by what came back rather than by its exit status.
release_id="$(curl -sS -H "$AUTH" "$FORGE/repos/$REPO/releases/tags/$TAG" | jq -r '.id // empty')"
if [ -z "$release_id" ]; then
say "no release for $TAG yet — creating it"
title="$(jq -r '(.productName // .name) + " " + (.version)' package.json)"
notes=''
[ -f "$NOTES" ] && notes="$(cat "$NOTES")"
# The body goes through jq rather than string concatenation: release notes are
# markdown with quotes and newlines in them.
payload="$(jq -n --arg tag "$TAG" --arg title "$title" --arg body "$notes" \
'{tag_name: $tag, name: $title, body: $body, draft: false, prerelease: false}')"
release_id="$(api POST "/repos/$REPO/releases" \
-H 'Content-Type: application/json' -d "$payload" | jq -r '.id // empty')"
[ -n "$release_id" ] || die "the release for $TAG could not be created"
else
say "the release already exists"
fi
while IFS= read -r asset; do
[ -n "$asset" ] || continue
[ -f "$asset" ] || die "no such file: $asset"
name="$(basename "$asset")"
encoded="$(printf '%s' "$name" | jq -sRr @uri)"
# Replace rather than refuse, so re-running a build lands.
existing="$(api GET "/repos/$REPO/releases/$release_id/assets" |
jq -r --arg name "$name" '.[] | select(.name == $name) | .id')"
for id in $existing; do
say "replacing $name"
api DELETE "/repos/$REPO/releases/$release_id/assets/$id" >/dev/null
done
say "uploading $name"
api POST "/repos/$REPO/releases/$release_id/assets?name=$encoded" \
-F "attachment=@$asset" >/dev/null
done < "$LIST"
say "done:"
api GET "/repos/$REPO/releases/$release_id" |
jq -r '.assets[] | " \(.name) \(.size / 1000000 | floor) MB"'
+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
+139
View File
@@ -0,0 +1,139 @@
#!/bin/sh
# Publish the built packages as a Gitea release.
#
# The version comes from package.json, so the tag follows whatever `npm version`
# set — there is nothing to keep in sync by hand. The release is created if it is
# not there yet, and an attachment with a name already on it is replaced rather
# than refused, which is what makes a rebuild-and-upload repeatable.
#
# scripts/release.sh every package in dist/
# scripts/release.sh dist/foo.dmg just these
#
# Assumes `tea` is installed and logged in (see the devarea repo: `make tea`).
set -eu
LOGIN="${TEA_LOGIN:-ttg}"
NOTES="${NOTES:-RELEASE_NOTES.md}"
DIST="${DIST:-dist}"
say() { echo "[release] $*"; }
die() { echo "[release] error: $*" >&2; exit 1; }
command -v tea >/dev/null 2>&1 || die "tea is not installed — see the devarea repo, 'make tea'"
command -v node >/dev/null 2>&1 || die "node is required"
[ -f package.json ] || die "run this from the repository root"
# Version and title from package.json, read with node. This project needs no interpreter
# beyond the one it already builds with — the app itself carries no Python any more, and
# neither should the script that ships it.
VERSION="$(node -p 'require("./package.json").version')"
TITLE="$(node -p 'const d = require("./package.json"); (d.productName || d.name) + " " + d.version')"
TAG="${TAG:-v$VERSION}"
[ -n "$VERSION" ] || die "cannot read the version from package.json"
[ -n "$TITLE" ] || die "cannot work out a release title"
# The repository is whatever this checkout pushes to, so a fork publishes to the
# fork without editing anything.
REPO="${REPO:-$(git remote get-url origin 2>/dev/null |
sed -e 's#.*[:/]\([^/]*/[^/]*\)$#\1#' -e 's#\.git$##')}"
[ -n "$REPO" ] || die "cannot work out the Gitea repo — set REPO=owner/name"
# What to upload: the arguments, or the packages in dist/ that belong to *this*
# version. Two things this has to get right:
#
# - the version filter, because dist/ keeps whatever earlier builds left there
# and a release would quietly get the previous version's files attached;
# - the spaces. "WarpEngine Client-1.5.0-arm64.dmg" has one, so the list lives one
# path per line in a file and is read with `while IFS= read -r`. Holding it in
# a single variable and looping over $list splits it on the space.
LIST="$(mktemp)"
trap 'rm -f "$LIST"' EXIT
if [ "$#" -gt 0 ]; then
for given in "$@"; do printf '%s\n' "$given"; done > "$LIST"
else
find "$DIST" -maxdepth 1 -type f -name "*$VERSION*" \
\( -name '*.dmg' -o -name '*-mac.zip' -o -name '*.exe' -o -name '*.AppImage' -o -name '*.deb' \) \
2>/dev/null | sort > "$LIST" || true
fi
[ -s "$LIST" ] || die "no $VERSION packages in $DIST — run 'make dist' first"
say "$REPO $TAG (version $VERSION), login $LOGIN"
# The release id, or empty when there is no such tag. `tea api` exits 0 even for a
# 404 — it answers {"message":"not found"} — so the body is what has to be read.
release_id() {
tea api "/repos/$REPO/releases/tags/$TAG" 2>/dev/null | node -e '
try {
console.log(JSON.parse(require("fs").readFileSync(0, "utf8")).id || "")
} catch {
// Not JSON, or no such release: an empty answer is the "no release yet" case.
}
'
}
# --- the release itself ----------------------------------------------------
if [ -n "$(release_id)" ]; then
say "the release already exists"
else
say "creating the release: $TITLE"
if [ -f "$NOTES" ]; then
tea releases create --login "$LOGIN" --repo "$REPO" --tag "$TAG" \
--title "$TITLE" --note-file "$NOTES" >/dev/null
else
say "no $NOTES — the release gets a one-line note"
tea releases create --login "$LOGIN" --repo "$REPO" --tag "$TAG" \
--title "$TITLE" --note "Packages built from $TAG." >/dev/null
fi
fi
RELEASE_ID="$(release_id)"
[ -n "$RELEASE_ID" ] || die "the release $TAG could not be created or found"
# --- the attachments -------------------------------------------------------
# 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" | node -e '
const name = process.argv[1]
for (const asset of JSON.parse(require("fs").readFileSync(0, "utf8"))) {
if (asset.name === name) console.log(asset.id)
}
' -- "$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="$(node -e 'console.log((require("fs").statSync(process.argv[1]).size / 1e6).toFixed(0) + " MB")' -- "$asset")"
# 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:"
tea api "/repos/$REPO/releases/$RELEASE_ID" | node -e '
const release = JSON.parse(require("fs").readFileSync(0, "utf8"))
for (const asset of release.assets || []) {
console.log(` ${asset.name} ${(asset.size / 1e6).toFixed(0)} MB`)
}
console.log(` ${release.html_url}`)
'
-103
View File
@@ -1,103 +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 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`)
}
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
})
+51
View File
@@ -0,0 +1,51 @@
import type { Game } from '../../domain/models/Game'
import type { GameDto } from '../../shared/contracts/dto/GameDto'
const ABSOLUTE_URL = /^https?:\/\//
/**
* A title as the window may see it.
*
* Two decisions live here rather than in the renderer: the box art is resolved
* against the catalog's base URL, and whether a title can be launched is answered
* here — so the window never receives a filesystem path it could be talked into
* opening.
*/
export class GameDtoMapper {
public toDto (game: Game, catalogBaseUrl: string): GameDto {
return {
name: game.name,
title: game.title,
platform: game.platform,
version: game.version,
mode: game.mode,
kind: game.kind,
description: game.description,
author: game.author,
imageUrl: this.resolveImageUrl(game, catalogBaseUrl),
installed: game.installed,
updateAvailable: game.updateAvailable,
installedVersion: game.installedVersion,
launchable: this.isLaunchable(game),
installable: game.installable,
unavailableReason: game.unavailableReason,
unavailableDetail: game.unavailableDetail
}
}
public toDtoList (games: readonly Game[], catalogBaseUrl: string): readonly GameDto[] {
return games.map((game: Game): GameDto => this.toDto(game, catalogBaseUrl))
}
private resolveImageUrl (game: Game, catalogBaseUrl: string): string | null {
if (game.imagePath === null) return null
if (ABSOLUTE_URL.test(game.imagePath)) return game.imagePath
return catalogBaseUrl.length > 0 ? `${catalogBaseUrl}${game.imagePath}` : null
}
private isLaunchable (game: Game): boolean {
if (!game.installed) return false
if (game.mode === 'web') return game.hostedUrl !== null
return game.menuEntryPath !== null || game.executablePath !== null
}
}
@@ -0,0 +1,12 @@
import type { InstalledStore } from '../../domain/models/InstalledStore'
import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto'
export class InstalledStoreDtoMapper {
public toDto (store: InstalledStore): InstalledStoreDto {
return { id: store.id, name: store.name, home: store.home, engine: store.engine }
}
public toDtoList (stores: readonly InstalledStore[]): readonly InstalledStoreDto[] {
return stores.map((store: InstalledStore): InstalledStoreDto => this.toDto(store))
}
}
@@ -0,0 +1,27 @@
import type { RegistryStore } from '../../domain/models/RegistryStore'
import { deriveStoreId } from '../../domain/models/StoreIdentity'
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
export class RegistryStoreDtoMapper {
public toDto (store: RegistryStore): RegistryStoreDto {
return {
name: store.name,
catalogUrl: store.catalogUrl,
storeRepositoryUrl: store.storeRepositoryUrl,
storeId: deriveStoreId(store)
}
}
public toDtoList (stores: readonly RegistryStore[]): readonly RegistryStoreDto[] {
return stores.map((store: RegistryStore): RegistryStoreDto => this.toDto(store))
}
/** The window hands a record straight back when asking for an install. */
public toModel (dto: RegistryStoreDto): RegistryStore {
return {
name: dto.name,
catalogUrl: dto.catalogUrl,
storeRepositoryUrl: dto.storeRepositoryUrl
}
}
}
@@ -0,0 +1,16 @@
import type { StorePaths } from '../../domain/models/StorePaths'
import type { StorePathsDto } from '../../shared/contracts/dto/StorePathsDto'
export class StorePathsDtoMapper {
public toDto (paths: StorePaths): StorePathsDto {
return {
operatingSystem: paths.operatingSystem,
architecture: paths.architecture,
storeFolder: paths.storeFolder,
menuGroup: paths.menuGroup,
catalogBaseUrl: paths.catalogBaseUrl,
storeName: paths.storeName,
storeId: paths.storeId
}
}
}
@@ -0,0 +1,44 @@
import type { InstalledStore } from '../../domain/models/InstalledStore'
import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment'
import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto'
import { TranslationCatalog } from '../../shared/i18n/TranslationCatalog'
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. There is one decision left in it — whether a store is set up yet — now that
* the engine ships with the application and cannot be missing or out of date.
*/
export class ApplicationStateService {
public constructor (
private readonly preferences: PreferencesService,
private readonly selection: StoreSelectionService,
private readonly provisioning: StoreProvisioningService,
private readonly environment: ApplicationEnvironment,
private readonly translations: TranslationCatalog = new TranslationCatalog(),
private readonly storeMapper: InstalledStoreDtoMapper = new InstalledStoreDtoMapper()
) {}
public readState (): AppStateDto {
const locale = this.preferences.readLocale()
const stores = this.selection.listStores()
const current: InstalledStore | null = this.selection.findCurrentStore()
return {
locale,
locales: this.translations.locales,
messages: this.translations.readBundle(locale),
navigationOpen: this.preferences.readNavigationOpen(),
currentStore: current === null ? null : this.storeMapper.toDto(current),
stores: this.storeMapper.toDtoList(stores),
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,61 @@
import { StoreMissingError } from '../../domain/errors/StoreMissingError'
import type { InstalledStore } from '../../domain/models/InstalledStore'
import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository'
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 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
}
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)
}
}
@@ -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
}
+39
View File
@@ -0,0 +1,39 @@
/**
* The three places a desktop store writes, resolved for this machine.
*
* `installRoot` holds the payloads and `menuDirectory` the launchers the user
* actually sees. `sources` records where each answer came from — a default, the
* config or an override — because "why is my menu entry there" is the first
* question anyone asks of a store that writes outside its own folder.
*/
export interface DesktopLayout {
readonly operatingSystem: string
readonly installRoot: string
readonly menuDirectory: string
readonly iconDirectory: string | null
readonly sources: Readonly<Record<string, string>>
}
/** Where each OS keeps installed programs and its application menu. */
export const OPERATING_SYSTEM_PATHS: Readonly<Record<string, Readonly<Record<string, string | null>>>> = {
linux: {
installRoot: '$XDG_DATA_HOME|~/.local/share',
menuDirectory: '$XDG_DATA_HOME|~/.local/share/applications',
iconDirectory: '$XDG_DATA_HOME|~/.local/share/icons'
},
darwin: {
installRoot: '~/Library/Application Support',
menuDirectory: '~/Applications',
iconDirectory: null
},
windows: {
installRoot: '$LOCALAPPDATA|~/AppData/Local',
menuDirectory: '$APPDATA|~/AppData/Roaming/Microsoft/Windows/Start Menu/Programs',
iconDirectory: null
}
}
/** A file name the OS will accept, from a human title. */
export function toSafeFileName (text: string): string {
return text.trim().replace(/[\\/:*?"<>|]/g, '_') || 'game'
}
+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
}
+44
View File
@@ -0,0 +1,44 @@
/** How a title runs: unpacked on this machine, or served as a web build. */
export type GameMode = 'app' | 'web'
/**
* Why a title cannot be installed here, as the engine codes it.
*
* `platformOff` is the store not carrying that platform at all — a C64 cartridge on a
* desktop — and the other three are about this machine or this catalog: no asset kind
* for the os and architecture, no release carrying it, or the adapter refusing it.
*/
export type UnavailableReason = 'platformOff' | 'hostAsset' | 'noAsset' | 'vetoed'
/**
* A catalog entry, with what the store did about it on this machine.
*
* The launch targets live here and nowhere nearer the window: resolving what to
* open is the main process's job.
*/
export interface Game {
readonly name: string
readonly title: string
readonly platform: string
readonly version: string
readonly mode: GameMode
readonly kind: string
readonly description: string
readonly author: string
/** Relative to the catalog's base URL, as published. */
readonly imagePath: string | null
readonly installed: boolean
readonly updateAvailable: boolean
readonly installedVersion: string | null
readonly menuEntryPath: string | null
readonly executablePath: string | null
readonly hostedUrl: string | null
/**
* False for a title this machine cannot install. It is still listed: a catalog that
* hides what your machine cannot run leaves you wondering which of the two is small.
*/
readonly installable: boolean
readonly unavailableReason: UnavailableReason | null
/** The engine's sentence for it, for a tooltip or the log. */
readonly unavailableDetail: string | null
}
+65
View File
@@ -0,0 +1,65 @@
/**
* The machine the store is running on, and how a config value depends on it.
*
* A native binary only starts on the architecture it was built for, so the release
* picker has to know: an x86_64 build installs on a Raspberry Pi and then does
* nothing, which is worse than not offering it at all.
*/
export interface HostMachine {
readonly operatingSystem: string
readonly architecture: string
}
/**
* A config value that may differ per machine.
*
* A plain value is the same everywhere — that is how a `cartridge` is described,
* being data for an emulator. Where it differs, the value is a map keyed by host.
*/
export type HostSpecific<TValue> = TValue | Readonly<Record<string, TValue>>
/**
* Resolve a host-specific value; the most specific key wins.
*
* {"linux-aarch64": …, "aarch64": …, "linux": …, "*": …}
*
* With no matching key and no `*` the answer is `null`, and the caller reports the
* title as unavailable rather than installing something that cannot run.
*/
export function resolveForHost<TValue> (
value: HostSpecific<TValue> | null | undefined,
host: HostMachine
): TValue | null {
if (value === null || value === undefined) return null
if (!isHostMap(value)) return value
for (const key of hostKeys(host)) {
const found = value[key]
if (found !== undefined) return found
}
return null
}
export function hostKeys (host: HostMachine): readonly string[] {
return [
`${host.operatingSystem}-${host.architecture}`,
host.architecture,
host.operatingSystem,
'*'
]
}
export function describeHost (host: HostMachine): string {
return `${host.operatingSystem}/${host.architecture}`
}
/**
* A host map, as opposed to a value that happens to be an object.
*
* Only plain objects are maps: an array is a value here — `kind` may be a list of
* asset kinds in order of preference, and that is not keyed by anything.
*/
function isHostMap<TValue> (
value: HostSpecific<TValue>
): value is Readonly<Record<string, TValue>> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
+59
View File
@@ -0,0 +1,59 @@
import type { SelectedGame } from './SelectedGame'
/**
* One title as it exists on this machine: what was written, and where.
*
* This is the shape `state.json` carries, keyed `<scope>:<name>`. Every path in it
* is something the store put there and may therefore delete — which is why an
* uninstall reads the record rather than guessing at paths.
*/
export interface InstalledRecord extends SelectedGame {
/** The unpacked archive's directory; null for a hosted entry, which has none. */
readonly payload: string | null
readonly executable: string | null
/** `bundle` for a macOS `.app` the archive already contained, `exe` for a binary. */
readonly executableKind: string | null
readonly icon: string | null
/** The menu entry actually written, which is not always the one intended. */
readonly menuEntry: string | null
readonly url: string | null
}
/**
* The scope a state record belongs to.
*
* `scope` is what the engine writes today. `system` is what the Batocera store
* wrote before the shared core existed, and there the two were the same string —
* so an installed machine keeps working without a migration.
*/
export function recordScope (scope: string | null, system: string | null): string {
return scope ?? system ?? ''
}
/** Two scopes may both carry a game called `foo` — key on both. */
export function gameKey (record: { readonly name: string; readonly scope: string }): string {
return `${record.scope}:${record.name}`
}
/** Resolve user-typed `name` or `scope:name` arguments to state keys. */
export function matchStateKeys (
installed: ReadonlyMap<string, InstalledRecord>,
names: readonly string[]
): readonly string[] {
const wanted = new Set(names.map((name: string): string => name.toLowerCase()))
const keys: string[] = []
for (const [key, record] of installed) {
if (wanted.has(key.toLowerCase()) || wanted.has(record.name.toLowerCase())) keys.push(key)
}
return keys
}
/** Keep only the games the user named, by bare name or `scope:name`. */
export function limitToNames (
games: readonly SelectedGame[],
names: readonly string[]
): readonly SelectedGame[] {
const wanted = new Set(names.map((name: string): string => name.toLowerCase()))
return games.filter((game: SelectedGame): boolean =>
wanted.has(game.name.toLowerCase()) || wanted.has(gameKey(game).toLowerCase()))
}
+14
View File
@@ -0,0 +1,14 @@
/**
* A store set up on this machine.
*
* A home and a config, which is all a store is now that the engine is part of this
* application: the home holds the state and the catalog cache, and the config says
* what the store offers and where its games go.
*/
export interface InstalledStore {
readonly id: string
readonly name: string
readonly home: 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
}
+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
}
+57
View File
@@ -0,0 +1,57 @@
import type { UnavailableReason } from './Game'
/**
* A catalog entry the store can install here, with the release it chose.
*
* `scope` is how the host groups its library for a desktop that is the catalog
* platform. It is what `state.json` is keyed by (`<scope>:<name>`), so two scopes
* can carry a game of the same name without colliding.
*/
export interface SelectedGame {
readonly name: string
readonly scope: string
readonly platform: string
readonly kind: string
readonly version: string
readonly asset: string
/**
* The catalog-side path, kept because not every asset is a download: an `html`
* build is a hosted directory, and the entry is a link to it.
*/
readonly assetPath: string
readonly title: string
readonly description: string
readonly author: string
readonly imageUrl: string | null
readonly createdAt: string | null
/** `app` for a native archive, `web` for a hosted page. */
readonly mode: string
}
/**
* A title the store offers but this machine cannot install.
*
* A store that hides these is lying about its catalog by omission: "there is
* nothing for your machine" is an answer, and an absent entry is not. Titles the
* store *chooses* not to offer the wrong status, an `only`/`exclude` list are
* not here, because that is editorial rather than a limitation of the machine.
*/
export interface UnavailableEntry {
readonly name: string
readonly title: string
readonly platform: string
readonly description: string
readonly author: string
readonly imageUrl: string | null
readonly version: string
readonly reason: UnavailableReason
/** The sentence behind the code, for a tooltip or the log. */
readonly detail: string
}
/** What a survey of the catalog found: installable, why not, and what was skipped. */
export interface CatalogSurvey {
readonly games: readonly SelectedGame[]
readonly skipped: readonly string[]
readonly unavailable: readonly UnavailableEntry[]
}
+141
View File
@@ -0,0 +1,141 @@
import type { HostSpecific } from './HostMachine'
/**
* How one store behaves: what it offers, where things land, how it talks to the API.
*
* This is the shape of a store's `config.json` after it has been merged onto the
* defaults below. On disk the file is snake_case, which is the format the store
* repositories publish; `StoreConfigurationReader` is the only place that knows it.
*/
export interface StoreConfiguration {
readonly store: StoreDescriptor
readonly paths: PathsConfiguration
readonly install: InstallConfiguration
readonly catalog: CatalogConfiguration
/** Which catalog platforms this store offers at all, by platform name. */
readonly platforms: Readonly<Record<string, PlatformConfiguration>>
readonly behavior: BehaviorConfiguration
}
export interface StoreDescriptor {
/** Short slug: names the store home, the log prefix and the launcher files. */
readonly id: string
/** Human-readable: the Start-menu folder on Windows, and what the user sees. */
readonly name: string
readonly baseUrl: string
readonly api: ApiConfiguration
}
export interface ApiConfiguration {
readonly catalog: string
readonly download: string
}
export interface PathsConfiguration {
/** All null means "work it out from the OS"; set one to pin it. */
readonly installRoot: string | null
readonly menuDirectory: string | null
readonly iconDirectory: string | null
/**
* Our own folder inside the install root: the prune boundary, and what keeps two
* stores on one machine out of each other's files.
*/
readonly subfolder: string
}
export interface AssetSpecification {
/** One kind, or several in order of preference. */
readonly kind: HostSpecific<string | readonly string[]> | null
readonly extension: HostSpecific<string> | null
}
export interface InstallConfiguration {
/** Tried in this order; the first that has an asset wins. */
readonly modes: readonly string[]
readonly specifications: Readonly<Record<string, AssetSpecification>>
}
export interface CatalogConfiguration {
readonly statuses: readonly string[]
readonly ownerId: number | null
readonly only: readonly string[]
readonly exclude: readonly string[]
}
export interface PlatformConfiguration {
readonly enabled: boolean
}
export interface BehaviorConfiguration {
readonly prune: boolean
/** Seconds. */
readonly timeout: number
readonly insecure: boolean
}
export const APP_MODE = 'app'
export const WEB_MODE = 'web'
/**
* What a store gets when its config says nothing.
*
* These were the desktop engine's own defaults, and they stay the defaults: a store
* that publishes no `config.json` installs on the strength of this table alone, so
* what a store actually has to supply is identity a slug, a name and a catalog.
*/
export const DEFAULT_STORE_CONFIGURATION: StoreConfiguration = {
store: {
id: 'warp',
name: 'WarpEngine Store',
baseUrl: 'https://example.org',
api: { catalog: '/api/software', download: '/api/download' }
},
paths: {
installRoot: null,
menuDirectory: null,
iconDirectory: null,
subfolder: 'warp'
},
install: {
modes: [APP_MODE, WEB_MODE],
specifications: {
// Which asset to unpack, per host. The most specific key wins; a list is
// tried in order of preference. On Apple Silicon `mac_universal` comes first
// and `mac_x64` last, because that one needs Rosetta.
[APP_MODE]: {
kind: {
'linux-x86_64': 'linux_x64',
'linux-aarch64': 'linux_arm64',
'linux-armhf': 'linux_armhf',
'linux-x86': 'linux_x86',
'windows-x86_64': ['win_x64', 'win_x86'],
'windows-x86': 'win_x86',
'darwin-aarch64': ['mac_universal', 'mac_arm64', 'mac_x64'],
'darwin-x86_64': ['mac_universal', 'mac_x64']
},
extension: '.zip'
},
// A browser build is hosted, not downloaded: there is no archive for it, so
// the entry opens the published page. It needs the network to play.
[WEB_MODE]: { kind: 'html', extension: '' }
}
},
catalog: {
statuses: ['released', 'archived'],
ownerId: null,
only: [],
exclude: []
},
platforms: {
ebitengine: { enabled: true },
godot: { enabled: true },
love: { enabled: true },
bevy: { enabled: true },
tic80: { enabled: true },
phaser: { enabled: true },
// A cartridge is data for an emulator: nothing a desktop menu can launch. The
// Batocera and RetroArch stores are where those belong.
c64: { enabled: false }
},
behavior: { prune: true, timeout: 60, insecure: false }
}
+25
View File
@@ -0,0 +1,25 @@
/**
* A store engine this client knows how to drive.
*
* There is one today: the desktop engine, which is the code in
* `infrastructure/engine`. The table stays because a second host RetroArch
* playlists rather than menu entries would be a second entry and a second
* `StoreCatalogGateway`, not a second code path through the application.
*
* `homeSuffix` is load-bearing rather than cosmetic: it is how an existing store
* home is recognised, so it has to keep saying `-desktop`.
*/
export interface StoreEngine {
readonly id: string
/** The installer names a store home `<store id><homeSuffix>`. */
readonly homeSuffix: string
readonly launcherSuffix: string
}
export const DESKTOP_STORE_ENGINE: StoreEngine = {
id: 'desktop',
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
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Which WarpEngine a catalog is served by, and whether this client knows it.
*
* Every WarpEngine API response carries the engine's version in a header, set before
* the action runs so that even an error response has it. That is what lets a client
* branch on the engine's age without a round trip to ask and this file is where the
* branching starts.
*/
/** `WarpEngine::VERSION_HEADER` on the server side. */
export const WARP_ENGINE_VERSION_HEADER = 'warpengine-version'
/**
* The engine versions this client is written against, oldest first.
*
* Minor precision, because that is the granularity the engine changes its API at: a
* patch release fixes something behind the same shapes. Adding an entry here is a
* compile error until `selectCatalogDialect` says which dialect it gets, which is the
* point a new engine version should not be able to arrive silently.
*/
export const SUPPORTED_WARP_ENGINE_VERSIONS = ['0.2', '0.3', '0.4'] as const
export type SupportedWarpEngineVersion = typeof SUPPORTED_WARP_ENGINE_VERSIONS[number]
/**
* Why the version this client will use is not simply the one the server named.
*
* - `exact` the header named a version in the supported list;
* - `absent` no header at all. An engine older than 0.4.0 does not send one, so
* this means "old", not "broken", and the oldest dialect is the honest
* reading of it;
* - `older` a version below everything here: same treatment, but it said so;
* - `newer` a version above everything here. The newest dialect is tried anyway,
* because listing nothing is worse than listing what still parses, but
* this is the case worth putting in the log.
*/
export type WarpEngineVersionMatch = 'exact' | 'absent' | 'older' | 'newer'
export interface WarpEngineVersion {
/** As the header spelled it, or null when there was none. */
readonly text: string | null
/** The supported version whose dialect will be used. Never null: one always applies. */
readonly resolved: SupportedWarpEngineVersion
readonly match: WarpEngineVersionMatch
readonly supported: boolean
}
const OLDEST: SupportedWarpEngineVersion = SUPPORTED_WARP_ENGINE_VERSIONS[0]
const NEWEST: SupportedWarpEngineVersion =
SUPPORTED_WARP_ENGINE_VERSIONS[SUPPORTED_WARP_ENGINE_VERSIONS.length - 1] ?? OLDEST
/**
* Read the header into a decision.
*
* An unparseable value is treated as an absent one: a header that does not look like a
* version tells us nothing about the engine, and guessing from a malformed string is
* worse than admitting we do not know.
*/
export function readWarpEngineVersion (headerValue: string | null): WarpEngineVersion {
if (headerValue === null || headerValue.trim().length === 0) {
return { text: null, resolved: OLDEST, match: 'absent', supported: false }
}
const text = headerValue.trim()
const numbers = parseVersion(text)
if (numbers === null) {
return { text, resolved: OLDEST, match: 'absent', supported: false }
}
const key = `${String(numbers[0])}.${String(numbers[1])}`
const exact = SUPPORTED_WARP_ENGINE_VERSIONS
.find((candidate: SupportedWarpEngineVersion): boolean => candidate === key)
if (exact !== undefined) {
return { text, resolved: exact, match: 'exact', supported: true }
}
const newer = compareVersions(numbers, parseVersion(NEWEST) ?? [0, 0]) > 0
return newer
? { text, resolved: NEWEST, match: 'newer', supported: false }
: { text, resolved: OLDEST, match: 'older', supported: false }
}
/** One sentence for the log, which is where an unsupported engine has to show up. */
export function describeWarpEngineVersion (version: WarpEngineVersion): string {
const supported = SUPPORTED_WARP_ENGINE_VERSIONS.join(', ')
switch (version.match) {
case 'exact':
return `WarpEngine ${version.text ?? ''}`
case 'absent':
return 'the catalog sent no WarpEngine-Version header — reading it as ' +
`${OLDEST}, which is what an engine older than 0.4.0 is`
case 'older':
return `WarpEngine ${version.text ?? ''} is older than anything this client knows ` +
`(${supported}) — reading it as ${OLDEST}`
case 'newer':
return `WarpEngine ${version.text ?? ''} is newer than this client knows ` +
`(${supported}) — reading it as ${NEWEST}, so some titles may be missed`
}
}
function parseVersion (text: string): readonly [number, number] | null {
const match = /(\d+)\.(\d+)/.exec(text)
if (match === null) return null
return [Number(match[1]), Number(match[2])]
}
function compareVersions (left: readonly [number, number], right: readonly [number, number]): number {
if (left[0] !== right[0]) return left[0] - right[0]
return left[1] - right[1]
}
@@ -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
}
+18
View File
@@ -0,0 +1,18 @@
import type { CatalogListing } from '../models/CatalogListing'
import type { EngineProgressListener } from '../models/EngineProgress'
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 engine
* behind it runs in this process, but nothing above this line knows that either
* the port is what let it stop being a child process without a change up here.
*/
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>
}
+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[]>
}
+168
View File
@@ -0,0 +1,168 @@
import { inflateRawSync } from 'node:zlib'
import fs from 'node:fs'
import path from 'node:path'
/**
* A zip reader over `node:zlib`, because Node has no zip and this client has no
* runtime dependencies.
*
* Only what the store actually needs to read: the archives come from our own
* release pipeline, so `stored` and `deflate` are the only compression methods
* that occur and there is no encryption to support. What is *not* optional is the
* unix mode the archive records the executable bit and without it nothing we
* install can start, so every entry is read from the central directory, which is
* the only place that carries it.
*
* Symlinks are written as regular files holding their target path. That is also
* what Python's `ZipFile.extractall` does, so an archive that installed before
* installs the same way now.
*/
const END_OF_CENTRAL_DIRECTORY = 0x06054b50
const CENTRAL_FILE_HEADER = 0x02014b50
const LOCAL_FILE_HEADER = 0x04034b50
const END_OF_CENTRAL_DIRECTORY_SIZE = 22
const CENTRAL_FILE_HEADER_SIZE = 46
const LOCAL_FILE_HEADER_SIZE = 30
/** A zip comment is a u16 length, so the record cannot start further back than this. */
const MAX_COMMENT_SIZE = 0xffff
const STORED = 0
const DEFLATED = 8
/** The u16/u32 sentinels a zip64 archive puts in the fields it has outgrown. */
const ZIP64_U16 = 0xffff
const ZIP64_U32 = 0xffffffff
const DEFAULT_FILE_MODE = 0o644
const DEFAULT_DIRECTORY_MODE = 0o755
const EXECUTABLE_BITS = 0o111
export interface ZipEntry {
readonly fileName: string
readonly directory: boolean
readonly compressionMethod: number
readonly compressedSize: number
readonly uncompressedSize: number
/** From the external attributes' high word; 0 when the archive carries no unix mode. */
readonly unixMode: number
readonly localHeaderOffset: number
}
export class ZipArchive {
private constructor (
private readonly buffer: Buffer,
public readonly entries: readonly ZipEntry[]
) {}
public static open (archivePath: string): ZipArchive {
const buffer = fs.readFileSync(archivePath)
return new ZipArchive(buffer, readCentralDirectory(buffer, archivePath))
}
/**
* Unpack everything into `destination`, restoring the executable bit.
*
* Every entry path is resolved and checked against the destination before it is
* written: a zip may name `../` and this store writes into the user's own data
* directory.
*/
public extractAll (destination: string): void {
const root = path.resolve(destination)
fs.mkdirSync(root, { recursive: true })
for (const entry of this.entries) {
const target = path.resolve(root, entry.fileName)
if (target !== root && !target.startsWith(root + path.sep)) {
throw new Error(`${entry.fileName} would be written outside ${root}`)
}
if (entry.directory) {
fs.mkdirSync(target, { recursive: true, mode: DEFAULT_DIRECTORY_MODE })
continue
}
fs.mkdirSync(path.dirname(target), { recursive: true })
fs.writeFileSync(target, this.readEntry(entry), { mode: this.fileMode(entry) })
}
}
/** One entry's bytes, decompressed. */
public readEntry (entry: ZipEntry): Buffer {
const signature = this.buffer.readUInt32LE(entry.localHeaderOffset)
if (signature !== LOCAL_FILE_HEADER) {
throw new Error(`${entry.fileName}: no local header at ${String(entry.localHeaderOffset)}`)
}
// The local header's own sizes may be zero when a data descriptor follows, so
// the lengths come from the central directory and only the two variable-length
// fields are read here.
const nameLength = this.buffer.readUInt16LE(entry.localHeaderOffset + 26)
const extraLength = this.buffer.readUInt16LE(entry.localHeaderOffset + 28)
const start = entry.localHeaderOffset + LOCAL_FILE_HEADER_SIZE + nameLength + extraLength
const raw = this.buffer.subarray(start, start + entry.compressedSize)
if (entry.compressionMethod === STORED) return Buffer.from(raw)
if (entry.compressionMethod === DEFLATED) return inflateRawSync(raw)
throw new Error(`${entry.fileName}: unsupported compression method ${String(entry.compressionMethod)}`)
}
/**
* The mode to write a file with.
*
* A zip made on Windows carries no unix mode at all, and there the archive
* simply cannot tell us 0644 is the safe answer, and `find_program` looks for
* `.exe` on that host anyway rather than for the executable bit.
*/
private fileMode (entry: ZipEntry): number {
if (entry.unixMode === 0) return DEFAULT_FILE_MODE
const permissions = entry.unixMode & 0o7777
if (permissions === 0) return DEFAULT_FILE_MODE
return (permissions & EXECUTABLE_BITS) === 0 ? permissions : permissions | EXECUTABLE_BITS
}
}
function readCentralDirectory (buffer: Buffer, archivePath: string): readonly ZipEntry[] {
const end = findEndOfCentralDirectory(buffer, archivePath)
const entryCount = buffer.readUInt16LE(end + 10)
const directoryOffset = buffer.readUInt32LE(end + 16)
if (entryCount === ZIP64_U16 || directoryOffset === ZIP64_U32) {
throw new Error(`${archivePath} is a zip64 archive, which this reader does not support`)
}
const entries: ZipEntry[] = []
let cursor = directoryOffset
for (let index = 0; index < entryCount; index += 1) {
if (cursor + CENTRAL_FILE_HEADER_SIZE > buffer.length) {
throw new Error(`${archivePath}: the central directory ends mid-entry`)
}
if (buffer.readUInt32LE(cursor) !== CENTRAL_FILE_HEADER) {
throw new Error(`${archivePath}: no central directory entry at ${String(cursor)}`)
}
const nameLength = buffer.readUInt16LE(cursor + 28)
const extraLength = buffer.readUInt16LE(cursor + 30)
const commentLength = buffer.readUInt16LE(cursor + 32)
const fileName = buffer.toString('utf8', cursor + CENTRAL_FILE_HEADER_SIZE, cursor + CENTRAL_FILE_HEADER_SIZE + nameLength)
entries.push({
fileName,
directory: fileName.endsWith('/'),
compressionMethod: buffer.readUInt16LE(cursor + 10),
compressedSize: buffer.readUInt32LE(cursor + 20),
uncompressedSize: buffer.readUInt32LE(cursor + 24),
unixMode: buffer.readUInt32LE(cursor + 38) >>> 16,
localHeaderOffset: buffer.readUInt32LE(cursor + 42)
})
cursor += CENTRAL_FILE_HEADER_SIZE + nameLength + extraLength + commentLength
}
return entries
}
/** The record is at the end, behind a comment of unknown length, so scan backwards. */
function findEndOfCentralDirectory (buffer: Buffer, archivePath: string): number {
const earliest = Math.max(0, buffer.length - END_OF_CENTRAL_DIRECTORY_SIZE - MAX_COMMENT_SIZE)
for (let offset = buffer.length - END_OF_CENTRAL_DIRECTORY_SIZE; offset >= earliest; offset -= 1) {
if (buffer.readUInt32LE(offset) === END_OF_CENTRAL_DIRECTORY) return offset
}
throw new Error(`${archivePath} is not a zip archive`)
}
@@ -0,0 +1,49 @@
import fs from 'node:fs'
import path from 'node:path'
import { asRecord, readRecord, readString } from '../json/JsonRecord'
/**
* What was decided when this package was built.
*
* The registry address is the one thing about a particular site left in the client, and
* a build for a different site should not need a different source tree. So it is a field
* in `package.json`, which `electron-builder` can overwrite at packaging time:
*
* make dist STORES_API=https://staging.example.org/api/stores
*
* Read from the package.json that ships inside the app, so a packaged build answers with
* what it was built with. A runtime `STORES_API` still wins over it that is for trying
* something out, this is for shipping it.
*/
export class BuildConfiguration {
private cached: Readonly<Record<string, unknown>> | null = null
public readRegistryUrl (): string | null {
const section = readRecord(this.read(), 'warpEngine')
if (section === null) return null
const url = readString(section, 'registryUrl').trim()
return url.length > 0 ? url : null
}
private read (): Readonly<Record<string, unknown>> {
if (this.cached !== null) return this.cached
// build/infrastructure/config → the package root, packaged or not.
const candidates = [
path.join(__dirname, '..', '..', '..', 'package.json'),
path.join(__dirname, '..', '..', 'package.json')
]
for (const candidate of candidates) {
try {
const parsed = asRecord(JSON.parse(fs.readFileSync(candidate, 'utf8')))
if (parsed !== null) {
this.cached = parsed
return parsed
}
} catch {
// Try the next one; a missing package.json is only fatal if none is found.
}
}
this.cached = {}
return this.cached
}
}
@@ -0,0 +1,20 @@
import path from 'node:path'
import type { App } from 'electron'
import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment'
/** The host application, as the services see it. Keeps `electron` out of them. */
export class ElectronApplicationEnvironment implements ApplicationEnvironment {
public constructor (private readonly app: App) {}
public readVersion (): string {
return this.app.getVersion()
}
public readSystemLocale (): string {
return this.app.getLocale()
}
public resolveUserDataPath (fileName: string): string {
return path.join(this.app.getPath('userData'), fileName)
}
}
@@ -0,0 +1,58 @@
import { spawn } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import type { Shell } from 'electron'
import type { Game } from '../../domain/models/Game'
import type { GameLauncher } from '../../domain/ports/GameLauncher'
/**
* Launching what was installed.
*
* A hosted title is a URL, so it goes to the browser. A native one is whatever the
* store recorded: on macOS the app bundle through `open`, elsewhere the executable
* from its own directory the same working directory the menu entry uses, because
* games load their assets relative to it.
*/
export class ElectronGameLauncher implements GameLauncher {
public constructor (private readonly shell: Shell) {}
public async launchGame (game: Game): Promise<boolean> {
if (game.mode === 'web' && game.hostedUrl !== null) {
await this.shell.openExternal(game.hostedUrl)
return true
}
const target = game.menuEntryPath ?? game.executablePath
if (target === null || !fs.existsSync(target)) return false
if (process.platform === 'darwin' && target.endsWith('.app')) {
this.spawnDetached('open', [target], path.dirname(target))
return true
}
if (process.platform === 'win32' || target.endsWith('.desktop')) {
const failure = await this.shell.openPath(target)
if (failure === '') return true
}
const executable = game.executablePath ?? target
this.spawnDetached(executable, [], path.dirname(executable))
return true
}
public async openFolder (directory: string): Promise<boolean> {
if (directory.length === 0) return false
const failure = await this.shell.openPath(directory)
return failure === ''
}
public async openUrl (url: string): Promise<boolean> {
if (!url.startsWith('https://')) return false
await this.shell.openExternal(url)
return true
}
private spawnDetached (command: string, commandArguments: readonly string[], cwd: string): void {
spawn(command, [...commandArguments], { cwd, detached: true, stdio: 'ignore' }).unref()
}
}
+121
View File
@@ -0,0 +1,121 @@
import type { StoreConfiguration } from '../../domain/models/StoreConfiguration'
import {
WARP_ENGINE_VERSION_HEADER, describeWarpEngineVersion, readWarpEngineVersion,
type WarpEngineVersion
} from '../../domain/models/WarpEngineVersion'
import { describe, type StoreFileSystem } from '../files/StoreFileSystem'
import { StoreHttpClient } from '../http/StoreHttpClient'
import { asRecord } from '../json/JsonRecord'
const CLIENT_VERSION = '2.0.0'
const IMAGE_EXTENSIONS: Readonly<Record<string, string>> = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'image/gif': '.gif'
}
export interface DownloadedImage {
readonly body: Buffer
readonly extension: string
}
/** A catalog, and which engine served it. */
export interface FetchedCatalog {
readonly catalog: unknown
readonly engineVersion: WarpEngineVersion
}
/**
* The catalog API, as this store talks to it.
*
* Every URL the store fetches is built here, and the catalog is cached next to the
* config so a sync survives an outage the machine that lost its network still has
* a library, and being told what is installed matters more than being current.
*/
export class CatalogClient {
private readonly http: StoreHttpClient
public constructor (
private readonly configuration: StoreConfiguration,
private readonly files: StoreFileSystem,
private readonly cachePath: string,
private readonly log: (line: string) => void
) {
this.http = new StoreHttpClient({
userAgent: `warp-engine-client/${CLIENT_VERSION} (${configuration.store.id})`,
timeout: configuration.behavior.timeout,
insecure: configuration.behavior.insecure
})
}
public apiUrl (endpoint: 'catalog' | 'download', parameters?: Readonly<Record<string, string>>): string {
const { baseUrl, api } = this.configuration.store
const url = `${baseUrl}/${api[endpoint].replace(/^\/+/, '')}`
if (parameters === undefined) return url
return `${url}?${new URLSearchParams(parameters).toString()}`
}
/** `GET /api/download` rather than `/file/`, so downloads are counted. */
public downloadUrl (asset: string): string {
return this.apiUrl('download', { path: asset })
}
/**
* The catalog, and the version of the engine that served it.
*
* The version comes from the response header, so it costs no extra request. A cache
* hit has no header the cache file holds the catalog exactly as the engine sent it,
* which is the format the shell engine wrote and worth keeping and then the version
* is reported as absent, which resolves to the oldest dialect this client knows.
*/
public async fetchCatalog (): Promise<FetchedCatalog> {
const ownerId = this.configuration.catalog.ownerId
const url = this.apiUrl('catalog', ownerId === null ? undefined : { owner_id: String(ownerId) })
try {
const { body, headers } = await this.http.readBytes(url)
const catalog = asRecord(JSON.parse(body.toString('utf8')))
if (catalog === null) throw new Error('the catalog is not a JSON object')
this.files.writeJson(this.cachePath, catalog)
const engineVersion = readWarpEngineVersion(headers[WARP_ENGINE_VERSION_HEADER] ?? null)
const sentence = describeWarpEngineVersion(engineVersion)
if (engineVersion.supported) this.log(sentence)
else this.log(`warning: ${sentence}`)
return { catalog, engineVersion }
} catch (error: unknown) {
const cached = asRecord(this.files.readJson(this.cachePath))
if (cached === null) throw new Error(`cannot fetch the catalog from ${url}: ${describe(error)}`)
this.log(`warning: catalog fetch failed (${describe(error)}) — using the cached copy`)
return { catalog: cached, engineVersion: readWarpEngineVersion(null) }
}
}
public async downloadAsset (asset: string, destination: string): Promise<number> {
return await this.http.download(this.downloadUrl(asset), destination)
}
/**
* Box art, or null if it cannot be had.
*
* Missing box art is not a reason to fail an install: the menu entry gets the
* generic icon and the game still starts.
*/
public async downloadImage (imageUrl: string, name: string): Promise<DownloadedImage | null> {
try {
const { body, contentType } = await this.http.readBytes(this.absoluteImageUrl(imageUrl))
const mediaType = contentType.split(';')[0]?.trim().toLowerCase() ?? ''
return { body, extension: IMAGE_EXTENSIONS[mediaType] ?? '.png' }
} catch (error: unknown) {
this.log(`warning: box art for ${name} failed: ${describe(error)}`)
return null
}
}
/** The catalog gives a server-relative `imageUrl`; absolute ones pass through. */
public absoluteImageUrl (imageUrl: string): string {
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) return imageUrl
return this.configuration.store.baseUrl + imageUrl
}
}
@@ -0,0 +1,191 @@
import { describeHost, resolveForHost, type HostMachine } from '../../domain/models/HostMachine'
import type { UnavailableReason } from '../../domain/models/Game'
import { gameKey } from '../../domain/models/InstalledRecord'
import type {
CatalogSurvey, SelectedGame, UnavailableEntry
} from '../../domain/models/SelectedGame'
import type { AssetSpecification, StoreConfiguration } from '../../domain/models/StoreConfiguration'
import type { CatalogEntry, CatalogSoftware } from './dialects/CatalogDialect'
import { pickRelease } from './ReleasePicker'
/**
* The catalog, turned into what this machine can and cannot install.
*
* Two decisions layered on each other. The inner one asks, per install mode, "which
* titles have *this* asset for this host"; the outer one asks it once per mode and
* merges the answers in the config's mode order, so a title with no native build for
* this machine is still installable as a hosted page. That fallback is what makes a
* desktop store the only one that can carry the whole catalog.
*
* A title counts as unavailable only when every mode failed it, and then the *first*
* mode's verdict is the one kept: `app` comes first, so a person is told "no native
* build for this machine" rather than the web mode's complaint about the same title.
*
* The entries arrive already typed, from whichever `CatalogDialect` the serving engine
* version selected nothing here knows what the catalog's JSON looks like.
*/
export class CatalogSurveyor {
public constructor (
private readonly configuration: StoreConfiguration,
private readonly log: (line: string) => void
) {}
public survey (entries: readonly CatalogEntry[], host: HostMachine): CatalogSurvey {
const chosen = new Map<string, SelectedGame>()
const unmet = new Map<string, UnavailableEntry>()
const reasonsByName = new Map<string, string[]>()
for (const mode of this.configuration.install.modes) {
const specification = this.configuration.install.specifications[mode]
if (specification === undefined) {
this.log(`warning: install mode '${mode}' has no asset spec — ignoring it`)
continue
}
const pass = this.surveyMode(entries, host, mode, specification)
for (const game of pass.games) {
const key = gameKey(game)
if (!chosen.has(key)) chosen.set(key, game)
}
for (const [name, reason] of pass.reasons) {
const collected = reasonsByName.get(name) ?? []
collected.push(`${mode}: ${reason}`)
reasonsByName.set(name, collected)
}
for (const entry of pass.unavailable) {
if (!unmet.has(entry.name)) unmet.set(entry.name, entry)
}
}
// A title that some later mode could serve is not skipped at all: nobody needs to
// hear that the native build was missing when the game installed anyway.
const installedNames = new Set([...chosen.values()].map((game: SelectedGame): string => game.name))
const skipped = [...reasonsByName.entries()]
.filter(([name]: readonly [string, readonly string[]]): boolean => !installedNames.has(name))
.map(([name, reasons]: readonly [string, readonly string[]]): string =>
`${name}: ${reasons.join('; ')}`)
return {
games: [...chosen.values()],
skipped,
unavailable: [...unmet.values()]
.filter((entry: UnavailableEntry): boolean => !installedNames.has(entry.name))
}
}
/** One pass over the catalog, asking for one mode's asset. */
private surveyMode (
entries: readonly CatalogEntry[],
host: HostMachine,
mode: string,
specification: AssetSpecification
): ModeSurvey {
const { statuses, only, exclude } = this.filters()
const games: SelectedGame[] = []
const reasons: [string, string][] = []
const unavailable: UnavailableEntry[] = []
for (const entry of entries) {
const software = entry.software
const name = software.name
// Editorial filters: a title excluded here is in none of the three lists, because
// that is the store's choice rather than a limit of the machine.
if (statuses.size > 0 && !statuses.has(software.status.toLowerCase())) continue
if (only.size > 0 && !only.has(name.toLowerCase())) continue
if (exclude.has(name.toLowerCase())) continue
const platform = this.configuration.platforms[software.platform]
if (platform?.enabled !== true) {
// Reported rather than hidden: to somebody looking at a catalog, a platform
// switched off reads as "not supported here".
unavailable.push(toUnavailable(entry, 'platformOff',
`${software.platform} is not carried by this store`))
continue
}
const wanted = readKinds(resolveForHost(specification.kind, host))
if (wanted.length === 0) {
const reason = `${software.platform} has no asset kind for ${describeHost(host)}`
reasons.push([name, reason])
unavailable.push(toUnavailable(entry, 'hostAsset', reason))
continue
}
const extension = resolveForHost(specification.extension, host) ?? ''
const release = pickRelease(entry.releaseCandidates, wanted, extension)
if (release === null) {
const reason = `no '${wanted.join('/')}' asset in any release`
reasons.push([name, reason])
unavailable.push(toUnavailable(entry, 'noAsset', reason))
continue
}
games.push({
name,
scope: software.platform,
platform: software.platform,
kind: release.kind,
version: release.version,
asset: release.assetName,
assetPath: release.assetPath,
title: software.title,
description: software.description,
author: software.author,
imageUrl: software.imageUrl,
createdAt: release.createdAt,
mode
})
}
return { games, reasons, unavailable }
}
private filters (): CatalogFilters {
const lower = (values: readonly string[]): ReadonlySet<string> =>
new Set(values.map((value: string): string => value.toLowerCase()))
return {
statuses: lower(this.configuration.catalog.statuses),
only: lower(this.configuration.catalog.only),
exclude: lower(this.configuration.catalog.exclude)
}
}
}
interface CatalogFilters {
readonly statuses: ReadonlySet<string>
readonly only: ReadonlySet<string>
readonly exclude: ReadonlySet<string>
}
interface ModeSurvey {
readonly games: readonly SelectedGame[]
/** `[name, reason]`, kept per name so several modes' complaints can be joined. */
readonly reasons: readonly (readonly [string, string])[]
readonly unavailable: readonly UnavailableEntry[]
}
/** One unavailable record, with enough for a client to draw a card. */
function toUnavailable (
entry: CatalogEntry,
reason: UnavailableReason,
detail: string
): UnavailableEntry {
const software: CatalogSoftware = entry.software
return {
name: software.name,
title: software.title,
platform: software.platform,
description: software.description,
author: software.author,
imageUrl: software.imageUrl,
version: entry.latestRelease?.version ?? '',
reason,
detail
}
}
function readKinds (value: string | readonly string[] | null): readonly string[] {
if (value === null) return []
const kinds = typeof value === 'string' ? [value] : value
return kinds.filter((kind: string): boolean => kind.length > 0)
}
@@ -0,0 +1,78 @@
import path from 'node:path'
import {
OPERATING_SYSTEM_PATHS, toSafeFileName, type DesktopLayout
} from '../../domain/models/DesktopLayout'
import type { InstalledRecord } from '../../domain/models/InstalledRecord'
import type { SelectedGame } from '../../domain/models/SelectedGame'
import type { StoreConfiguration } from '../../domain/models/StoreConfiguration'
import { expandHome, expandPathSpecification } from '../files/StoreFileSystem'
import type { HostMachineDetector } from './HostMachineDetector'
/**
* Where this store writes on this machine.
*
* The XDG and Windows environment variables are the correct answer when they are
* set, and a hardcoded path is only the fallback: a machine that moved its data
* directory should still get its own menu. A value pinned in the config beats both.
*/
export class DesktopLayoutResolver {
public constructor (
private readonly configuration: StoreConfiguration,
private readonly hosts: HostMachineDetector
) {}
public resolveLayout (): DesktopLayout {
const operatingSystem = this.hosts.findHost().operatingSystem
const defaults = OPERATING_SYSTEM_PATHS[operatingSystem]
if (defaults === undefined) {
throw new Error(
`no desktop layout is known for '${operatingSystem}' — set paths.install_root and paths.menu_dir`)
}
const sources: Record<string, string> = { os: operatingSystem }
const pick = (key: 'installRoot' | 'menuDirectory' | 'iconDirectory'): string | null => {
const configured = this.configuration.paths[key]
if (configured !== null && configured.length > 0) {
sources[key] = 'config'
return path.resolve(expandHome(configured))
}
sources[key] = 'default'
return expandPathSpecification(defaults[key] ?? null)
}
const installRoot = pick('installRoot')
const menuDirectory = pick('menuDirectory')
const iconDirectory = pick('iconDirectory')
if (installRoot === null || menuDirectory === null) {
throw new Error(`cannot resolve the install root or menu directory for '${operatingSystem}'`)
}
return { operatingSystem, installRoot, menuDirectory, iconDirectory, sources }
}
/** The only payload subtree this store may delete from. */
public ownedRoot (layout: DesktopLayout): string {
return path.join(layout.installRoot, this.configuration.paths.subfolder)
}
public payloadDirectory (layout: DesktopLayout, game: SelectedGame | InstalledRecord): string {
return path.join(this.ownedRoot(layout), game.name)
}
/**
* Box art path.
*
* Kept inside our own folder rather than the shared icon theme, so an uninstall
* never has to reach into it.
*/
public iconPath (layout: DesktopLayout, game: SelectedGame | InstalledRecord): string {
return path.join(this.ownedRoot(layout), 'icons', `${game.name}.png`)
}
/** Windows puts programs in a Start-menu folder; the others do not. */
public menuGroup (layout: DesktopLayout): string {
if (layout.operatingSystem === 'windows') {
return path.join(layout.menuDirectory, toSafeFileName(this.configuration.store.name))
}
return layout.menuDirectory
}
}
+216
View File
@@ -0,0 +1,216 @@
import fs from 'node:fs'
import path from 'node:path'
import type { DesktopLayout } from '../../domain/models/DesktopLayout'
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
import { gameKey, type InstalledRecord } from '../../domain/models/InstalledRecord'
import type { SelectedGame } from '../../domain/models/SelectedGame'
import { APP_MODE, type StoreConfiguration } from '../../domain/models/StoreConfiguration'
import { describe, type StoreFileSystem } from '../files/StoreFileSystem'
import type { CatalogClient } from './CatalogClient'
import type { DesktopLayoutResolver } from './DesktopLayoutResolver'
import type { LauncherWriter } from './launchers/LauncherWriter'
import type { PayloadInstaller } from './PayloadInstaller'
/**
* Installing and uninstalling one title, and the sweep over all of them.
*
* The rule the whole class turns on: an install writes three things a payload, an
* icon and a menu entry and the record of what was written is what an uninstall
* reads. Nothing is ever deleted by reconstructing a path from a name, because a
* path built from a name is a guess and this runs inside the user's home directory.
*/
export class GameInstaller {
public constructor (
private readonly configuration: StoreConfiguration,
private readonly layouts: DesktopLayoutResolver,
private readonly payloads: PayloadInstaller,
private readonly launchers: LauncherWriter,
private readonly catalog: CatalogClient,
private readonly files: StoreFileSystem,
private readonly log: (line: string) => void
) {}
/**
* Install every game in `games`, updating `installed` in place.
*
* Returns whether anything changed, which is what decides between "done" and
* "already up to date" a sync that rewrites nothing should say so.
*/
public async installAll (
layout: DesktopLayout,
games: readonly SelectedGame[],
installed: Map<string, InstalledRecord>,
prune: boolean,
progress: EngineProgressListener
): Promise<boolean> {
let changed = false
let failed = 0
progress.onEvent?.({ event: 'plan', count: games.length })
for (const game of games) {
const key = gameKey(game)
let previous = installed.get(key) ?? null
// A different asset or a different mode is not an update in place: the old
// payload and the old kind of menu entry both have to go first.
if (previous !== null && (previous.asset !== game.asset || previous.mode !== game.mode)) {
this.log(`${game.name}: ${previous.version} (${previous.mode}) -> ` +
`${game.version} (${game.mode}), removing the old install`)
this.removeGame(layout, previous)
previous = null
changed = true
}
progress.onEvent?.({ event: 'begin', name: game.name, title: game.title })
try {
const result = await this.installGame(layout, game, previous)
progress.onEvent?.({
event: 'installed', name: game.name, title: game.title, changed: result.changed
})
if (result.changed || !sameRecord(installed.get(key) ?? null, result.record)) changed = true
installed.set(key, result.record)
} catch (error: unknown) {
failed += 1
const reason = describe(error)
this.log(`warning: ${game.name} failed: ${reason}`)
progress.onEvent?.({ event: 'failed', name: game.name, error: reason })
}
}
if (prune) {
const keep = new Set(games.map((game: SelectedGame): string => gameKey(game)))
for (const [key, record] of [...installed]) {
if (keep.has(key)) continue
this.log(`pruning ${key} (no longer in the catalog or filtered out)`)
this.removeGame(layout, record)
progress.onEvent?.({ event: 'pruned', name: record.name })
installed.delete(key)
changed = true
}
}
progress.onEvent?.({ event: 'finished', installed: installed.size, failed })
return changed
}
/** Install one title in its chosen mode. */
public async installGame (
layout: DesktopLayout,
game: SelectedGame,
previous: InstalledRecord | null
): Promise<{ readonly record: InstalledRecord; readonly changed: boolean }> {
const payloadDirectory = this.layouts.payloadDirectory(layout, game)
let changed = false
let payload: string | null = null
let executable: string | null = null
let executableKind: string | null = null
let url: string | null = null
if (game.mode === APP_MODE) {
if (previous !== null && isStillInstalled(previous, game, payloadDirectory)) {
executable = previous.executable
executableKind = previous.executableKind
} else {
const size = await this.payloads.unpack(game, payloadDirectory)
changed = true
const program = this.payloads.findProgram(payloadDirectory, game.name, layout.operatingSystem)
if (program === null) {
fs.rmSync(payloadDirectory, { recursive: true, force: true })
throw new Error(`no program was found inside ${game.asset}`)
}
executable = program.executablePath
executableKind = program.kind
this.log(`installed ${game.name} ${game.version} (${String(size)} bytes) -> ${payloadDirectory}`)
}
payload = payloadDirectory
} else {
// Nothing to unpack: the browser build is hosted, so the entry is a link.
url = this.launchers.webUrl(game)
if (previous?.url !== url) {
changed = true
this.log(`added ${game.name} ${game.version} as a web entry -> ${url}`)
}
}
const withoutMenu: InstalledRecord = {
...game,
payload,
executable,
executableKind,
icon: await this.installIcon(layout, game),
menuEntry: null,
url
}
const menuEntry = this.launchers.writeLauncher(layout, withoutMenu)
if ((previous?.menuEntry ?? null) !== menuEntry) changed = true
return { record: { ...withoutMenu, menuEntry }, changed }
}
/** Delete one title's payload, icon and menu entry — and nothing else. */
public removeGame (layout: DesktopLayout, record: InstalledRecord): void {
const owned = this.layouts.ownedRoot(layout)
for (const target of [record.payload, record.icon]) {
if (target !== null) this.files.removeWithin(target, owned)
}
if (record.menuEntry !== null) {
this.files.removeWithin(record.menuEntry, layout.menuDirectory)
}
}
/** Remove everything this store installed, folders included. */
public purge (layout: DesktopLayout, installed: Map<string, InstalledRecord>): void {
for (const [key, record] of [...installed]) {
this.removeGame(layout, record)
installed.delete(key)
}
const owned = this.layouts.ownedRoot(layout)
this.files.pruneEmptyDirectories([path.join(owned, 'icons'), owned], layout.installRoot)
if (layout.operatingSystem === 'windows') {
this.files.pruneEmptyDirectories([this.layouts.menuGroup(layout)], layout.menuDirectory)
}
}
private async installIcon (layout: DesktopLayout, game: SelectedGame): Promise<string | null> {
if (game.imageUrl === null) return null
const iconPath = this.layouts.iconPath(layout, game)
if (hasContent(iconPath)) return iconPath
const image = await this.catalog.downloadImage(game.imageUrl, game.name)
if (image === null) return null
// The name says .png because that is what a desktop entry and `sips` expect; a
// non-PNG cover still displays on Linux, which sniffs the content.
this.files.writeAtomic(iconPath, image.body)
return iconPath
}
}
/**
* Whether the previous install is still there and still the right one.
*
* All four conditions matter: the same asset, a payload directory that exists, a
* recorded executable, and that executable still on disk. A user who deleted the
* folder by hand should get a reinstall rather than a menu entry that does nothing.
*/
function isStillInstalled (
previous: InstalledRecord,
game: SelectedGame,
payloadDirectory: string
): boolean {
return previous.asset === game.asset &&
fs.existsSync(payloadDirectory) &&
previous.executable !== null &&
fs.existsSync(previous.executable)
}
function hasContent (filePath: string): boolean {
try {
return fs.statSync(filePath).size > 0
} catch {
return false
}
}
function sameRecord (left: InstalledRecord | null, right: InstalledRecord): boolean {
return left !== null && JSON.stringify(left) === JSON.stringify(right)
}
@@ -0,0 +1,47 @@
import type { HostMachine } from '../../domain/models/HostMachine'
/**
* This machine, as the release picker needs to know it.
*
* Node already normalises what `uname -m` reports, but not to the names the catalog
* uses, and the catalog's names are the ones the asset kinds are keyed by.
*/
export class HostMachineDetector {
private cached: HostMachine | null = null
public findHost (): HostMachine {
this.cached ??= {
operatingSystem: readOperatingSystem(),
architecture: readArchitecture()
}
return this.cached
}
}
/**
* Node's names for the architectures the catalog has a name for.
*
* Anything not in the table passes through unchanged: it will match no asset kind,
* and the survey then reports the titles as unavailable on this machine which is
* the honest answer for a host nobody has built for.
*/
const ARCHITECTURE_NAMES: Readonly<Record<string, string>> = {
x64: 'x86_64',
arm64: 'aarch64',
arm: 'armhf',
ia32: 'x86'
}
const OPERATING_SYSTEM_NAMES: Readonly<Record<string, string>> = {
darwin: 'darwin',
win32: 'windows',
linux: 'linux'
}
function readArchitecture (): string {
return ARCHITECTURE_NAMES[process.arch] ?? process.arch
}
function readOperatingSystem (): string {
return OPERATING_SYSTEM_NAMES[process.platform] ?? process.platform
}
@@ -0,0 +1,267 @@
import path from 'node:path'
import type { CatalogListing } from '../../domain/models/CatalogListing'
import type { DesktopLayout } from '../../domain/models/DesktopLayout'
import type { EngineProgressListener } from '../../domain/models/EngineProgress'
import type { Game, GameMode } from '../../domain/models/Game'
import {
gameKey, limitToNames, matchStateKeys, type InstalledRecord
} from '../../domain/models/InstalledRecord'
import type { InstalledStore } from '../../domain/models/InstalledStore'
import type {
CatalogSurvey, SelectedGame, UnavailableEntry
} from '../../domain/models/SelectedGame'
import { APP_MODE, WEB_MODE, type StoreConfiguration } from '../../domain/models/StoreConfiguration'
import type { StorePaths } from '../../domain/models/StorePaths'
import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
import { StoreFileSystem } from '../files/StoreFileSystem'
import { CatalogClient, type FetchedCatalog } from './CatalogClient'
import { CatalogSurveyor } from './CatalogSurveyor'
import type { CatalogEntry } from './dialects/CatalogDialect'
import { selectCatalogDialect } from './dialects/CatalogDialectSelector'
import { DesktopLayoutResolver } from './DesktopLayoutResolver'
import { GameInstaller } from './GameInstaller'
import { HostMachineDetector } from './HostMachineDetector'
import { LauncherWriter } from './launchers/LauncherWriter'
import { PayloadInstaller } from './PayloadInstaller'
import { StoreConfigurationReader } from './StoreConfigurationReader'
import { StoreStateRepository } from './StoreStateRepository'
const STATE_FILE_NAME = 'state.json'
const CATALOG_CACHE_FILE_NAME = 'catalog.json'
/**
* The store engine, in process.
*
* This is the whole of what used to be `desktop_store.py` and `warpstore.py` driven
* as a child process: the catalog, the release choice, the host match, the unpacking,
* the menu entry and the state. Nothing is serialised to JSON lines and parsed back,
* so the progress events below are the typed events the window already expects, and
* a `Game` is built rather than read out of somebody else's field names.
*
* What has *not* changed is what lands on disk. `config.json` and `state.json` keep
* the shell engine's snake_case shape, so a machine whose library was installed by
* the CLI keeps it.
*/
export class NativeStoreCatalogGateway implements StoreCatalogGateway {
private readonly hosts = new HostMachineDetector()
public async listGames (
store: InstalledStore,
progress: EngineProgressListener = {}
): Promise<CatalogListing> {
const engine = this.openStore(store, progress)
const host = this.hosts.findHost()
engine.log(`host: ${host.operatingSystem}/${host.architecture}`)
const survey = engine.surveyor.survey(await this.readEntries(engine), host)
const installed = engine.state.readState()
// One list, both kinds: a client that hides what it cannot install leaves the
// visitor wondering whether the catalog is small or their machine is unusual.
const games = [
...survey.games.map((game: SelectedGame): Game => this.toGame(engine, game, installed)),
...survey.unavailable.map((entry: UnavailableEntry): Game => toUnavailableGame(entry))
].sort((left: Game, right: Game): number =>
left.title.toLowerCase().localeCompare(right.title.toLowerCase()))
return { games, skipped: survey.skipped, paths: this.toPaths(engine) }
}
/**
* Where this store's things go.
*
* Synchronous work behind an async port, which is deliberate: the port was shaped
* for a child process and stays shaped for one, so a future engine that has to go
* to the network for this needs no change above.
*/
public readPaths (
store: InstalledStore,
progress: EngineProgressListener = {}
): Promise<StorePaths> {
return Promise.resolve(this.toPaths(this.openStore(store, progress)))
}
public async syncGames (
store: InstalledStore,
names: readonly string[],
progress: EngineProgressListener = {}
): Promise<void> {
const engine = this.openStore(store, progress)
const survey: CatalogSurvey = engine.surveyor.survey(
await this.readEntries(engine), this.hosts.findHost())
for (const reason of survey.skipped) engine.log(`skipped ${reason}`)
const wanted = names.length > 0 ? limitToNames(survey.games, names) : survey.games
const installed = engine.state.readState()
// Pruning is for a full sync only: asked for two titles, the store must not take
// the absence of the rest as a reason to uninstall them.
const prune = engine.configuration.behavior.prune && names.length === 0
const changed = await engine.installer.installAll(
engine.layout, wanted, installed, prune, progress)
engine.state.writeState(installed)
if (!changed) {
engine.log('already up to date')
return
}
engine.launchers.refreshMenu(engine.layout)
engine.log(`done — the games are in ${engine.layouts.menuGroup(engine.layout)}`)
}
public removeGame (
store: InstalledStore,
name: string,
progress: EngineProgressListener = {}
): Promise<void> {
const engine = this.openStore(store, progress)
const installed = engine.state.readState()
const keys = matchStateKeys(installed, [name])
if (keys.length === 0) {
engine.log(`not installed: ${name}`)
return Promise.resolve()
}
for (const key of keys) {
const record = installed.get(key)
if (record === undefined) continue
engine.installer.removeGame(engine.layout, record)
progress.onEvent?.({ event: 'removed', name: record.name })
installed.delete(key)
}
engine.state.writeState(installed)
engine.launchers.refreshMenu(engine.layout)
return Promise.resolve()
}
/**
* Fetch the catalog and read it with the dialect its engine version calls for.
*
* This is the one place a WarpEngine version turns into behaviour: the header decides
* which dialect parses the response, and everything downstream sees typed entries.
*/
private async readEntries (engine: StoreEngineContext): Promise<readonly CatalogEntry[]> {
const fetched: FetchedCatalog = await engine.catalog.fetchCatalog()
return selectCatalogDialect(fetched.engineVersion.resolved).listEntries(fetched.catalog)
}
/**
* Assemble the engine for one store.
*
* Per call rather than cached: the config on disk is the authority and the user may
* have edited it, and a store's home is cheap to read.
*/
private openStore (store: InstalledStore, progress: EngineProgressListener): StoreEngineContext {
const log = (line: string): void => { progress.onLog?.(`[${store.id}-store] ${line}`) }
const files = new StoreFileSystem(`${store.id}-store`, log)
const configuration = new StoreConfigurationReader(files).readConfiguration(store.configPath)
const layouts = new DesktopLayoutResolver(configuration, this.hosts)
const layout = layouts.resolveLayout()
const catalog = new CatalogClient(
configuration, files, path.join(store.home, CATALOG_CACHE_FILE_NAME), log)
const launchers = new LauncherWriter(configuration, layouts, files, log)
return {
configuration,
layout,
layouts,
catalog,
launchers,
log,
surveyor: new CatalogSurveyor(configuration, log),
state: new StoreStateRepository(files, path.join(store.home, STATE_FILE_NAME), log),
installer: new GameInstaller(
configuration, layouts,
new PayloadInstaller(catalog, files, log),
launchers, catalog, files, log)
}
}
private toGame (
engine: StoreEngineContext,
game: SelectedGame,
installed: ReadonlyMap<string, InstalledRecord>
): Game {
const record = installed.get(gameKey(game)) ?? null
return {
name: game.name,
title: game.title,
platform: game.platform,
version: game.version,
mode: toMode(game.mode),
kind: game.kind,
description: game.description,
author: game.author,
imagePath: game.imageUrl,
installed: record !== null,
updateAvailable: record !== null && record.asset !== game.asset,
installedVersion: record?.version ?? null,
menuEntryPath: record?.menuEntry ?? null,
executablePath: record?.executable ?? null,
hostedUrl: game.mode === WEB_MODE ? engine.launchers.webUrl(game) : null,
installable: true,
unavailableReason: null,
unavailableDetail: null
}
}
private toPaths (engine: StoreEngineContext): StorePaths {
const host = this.hosts.findHost()
return {
operatingSystem: engine.layout.operatingSystem,
architecture: host.architecture,
installRoot: engine.layout.installRoot,
menuDirectory: engine.layout.menuDirectory,
storeFolder: engine.layouts.ownedRoot(engine.layout),
menuGroup: engine.layouts.menuGroup(engine.layout),
catalogBaseUrl: engine.configuration.store.baseUrl,
storeName: engine.configuration.store.name,
storeId: engine.configuration.store.id
}
}
}
/** Everything one store's operations need, assembled from its home. */
interface StoreEngineContext {
readonly configuration: StoreConfiguration
readonly layout: DesktopLayout
readonly layouts: DesktopLayoutResolver
readonly catalog: CatalogClient
readonly launchers: LauncherWriter
readonly surveyor: CatalogSurveyor
readonly state: StoreStateRepository
readonly installer: GameInstaller
readonly log: (line: string) => void
}
/**
* A title this machine cannot install, in the same shape as an installable one.
*
* The mode is `app` for want of a truer answer: a title with no build has no mode,
* and nothing reads this one because `installable` is false.
*/
function toUnavailableGame (entry: UnavailableEntry): Game {
return {
name: entry.name,
title: entry.title,
platform: entry.platform,
version: entry.version,
mode: APP_MODE,
kind: '',
description: entry.description,
author: entry.author,
imagePath: entry.imageUrl,
installed: false,
updateAvailable: false,
installedVersion: null,
menuEntryPath: null,
executablePath: null,
hostedUrl: null,
installable: false,
unavailableReason: entry.reason,
unavailableDetail: entry.detail
}
}
function toMode (mode: string): GameMode {
return mode === WEB_MODE ? WEB_MODE : APP_MODE
}
@@ -0,0 +1,159 @@
import fs from 'node:fs'
import path from 'node:path'
import type { SelectedGame } from '../../domain/models/SelectedGame'
import { ZipArchive } from '../archive/ZipArchive'
import type { StoreFileSystem } from '../files/StoreFileSystem'
import type { CatalogClient } from './CatalogClient'
/** Files that are never the program: data, libraries and documentation. */
const NEVER_A_PROGRAM: readonly string[] =
['.txt', '.md', '.json', '.so', '.dll', '.dylib', '.pck', '.dat']
export type ExecutableKind = 'bundle' | 'exe'
export interface FoundProgram {
readonly kind: ExecutableKind
readonly executablePath: string
}
/**
* Getting a native build onto the disk and finding what to launch in it.
*
* The archive is downloaded to a part file beside the payload and unpacked only once
* it is complete, and the payload directory is replaced rather than merged: a build
* that dropped a file between releases would otherwise keep the old one around and
* the game would load it.
*/
export class PayloadInstaller {
public constructor (
private readonly catalog: CatalogClient,
private readonly files: StoreFileSystem,
private readonly log: (line: string) => void
) {}
/** Download and unpack into `destination`. Returns bytes downloaded. */
public async unpack (game: SelectedGame, destination: string): Promise<number> {
const parent = path.dirname(destination)
fs.mkdirSync(parent, { recursive: true })
const archivePath = this.files.temporaryPath(parent, game.name, '.zip')
try {
const size = await this.catalog.downloadAsset(game.asset, archivePath)
fs.rmSync(destination, { recursive: true, force: true })
fs.mkdirSync(destination, { recursive: true })
ZipArchive.open(archivePath).extractAll(destination)
return size
} finally {
fs.rmSync(archivePath, { force: true })
}
}
/**
* The thing to launch inside an unpacked payload.
*
* A `bundle` is a macOS `.app` the archive already contained a LÖVE or Godot
* build ships one and then it *is* the launcher rather than something to wrap.
* Otherwise the answer is a single executable, and "single" is the whole
* difficulty: an archive holding one candidate is unambiguous, and where there are
* several the one named after the game wins. Anything else is reported as not
* found, because launching the wrong binary is worse than failing the install.
*/
public findProgram (root: string, name: string, operatingSystem: string): FoundProgram | null {
if (operatingSystem === 'darwin') {
const bundle = this.findBundle(root)
if (bundle !== null) return { kind: 'bundle', executablePath: bundle }
}
const executables: string[] = []
const namedAfterTheGame: string[] = []
this.walk(root, (filePath: string): void => {
const fileName = path.basename(filePath)
const lowered = fileName.toLowerCase()
if (NEVER_A_PROGRAM.some((extension: string): boolean => lowered.endsWith(extension))) return
if (operatingSystem === 'windows') {
if (lowered.endsWith('.exe')) executables.push(filePath)
} else if (isExecutable(filePath)) {
executables.push(filePath)
}
if (stem(fileName) === name) namedAfterTheGame.push(filePath)
})
for (const candidates of [executables, namedAfterTheGame]) {
if (candidates.length === 1 && candidates[0] !== undefined) {
return { kind: 'exe', executablePath: candidates[0] }
}
const exact = candidates.filter((candidate: string): boolean =>
stem(path.basename(candidate)) === name)
if (exact.length === 1 && exact[0] !== undefined) {
return { kind: 'exe', executablePath: exact[0] }
}
}
this.log(`found no single program to launch inside ${root}`)
return null
}
/** The shallowest `.app`, without descending into one we have already found. */
private findBundle (root: string): string | null {
let level = [root]
while (level.length > 0) {
const next: string[] = []
for (const directory of level) {
const bundles = readDirectories(directory)
.filter((entry: string): boolean => entry.endsWith('.app')).sort()
const first = bundles[0]
if (first !== undefined) return path.join(directory, first)
for (const entry of readDirectories(directory)) next.push(path.join(directory, entry))
}
level = next
}
return null
}
/** Every file under `root`, never entering a `.app` bundle. */
private walk (root: string, visit: (filePath: string) => void): void {
const pending = [root]
while (pending.length > 0) {
const directory = pending.pop()
if (directory === undefined) continue
let entries: fs.Dirent[]
try {
entries = fs.readdirSync(directory, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = path.join(directory, entry.name)
if (entry.isDirectory()) {
if (!entry.name.endsWith('.app')) pending.push(full)
} else if (entry.isFile()) {
visit(full)
}
}
}
}
}
function readDirectories (directory: string): readonly string[] {
try {
return fs.readdirSync(directory, { withFileTypes: true })
.filter((entry: fs.Dirent): boolean => entry.isDirectory())
.map((entry: fs.Dirent): string => entry.name)
} catch {
return []
}
}
function isExecutable (filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.X_OK)
return true
} catch {
return false
}
}
function stem (fileName: string): string {
return path.basename(fileName, path.extname(fileName))
}
@@ -0,0 +1,55 @@
import path from 'node:path'
import type { CatalogRelease } from './dialects/CatalogDialect'
export interface PickedRelease {
readonly version: string
readonly createdAt: string | null
readonly assetName: string
readonly kind: string
readonly assetPath: string
}
/** `/file/blessingofra-2.0.0.prg` -> `blessingofra-2.0.0.prg`. */
export function assetBasename (assetPath: string): string {
return path.posix.basename(assetPath.replace(/\/+$/, ''))
}
/**
* The newest non-dev release that carries an asset kind we can use.
*
* The candidates arrive newest-first from the dialect, which is where knowing that the
* API sorts them lives. Two rules decide the rest:
*
* **Release order wins over kind order.** The newest release that has *any* acceptable
* kind is taken, and within it the most preferred kind. That is what a desktop host
* wants: on Apple Silicon `mac_universal` beats `mac_x64`, which would need Rosetta,
* but not at the price of installing an older release.
*
* **A `dev-` build is never taken.** It is a moving target, and installing one would
* leave a menu entry pointing at an archive that is replaced without a version change.
*/
export function pickRelease (
candidates: readonly CatalogRelease[],
kinds: readonly string[],
extension: string
): PickedRelease | null {
for (const release of candidates) {
if (release.version.startsWith('dev-')) continue
for (const kind of kinds) {
for (const asset of release.assets) {
if (asset.kind !== kind) continue
const assetName = assetBasename(asset.path)
if (assetName.length === 0) continue
if (extension.length > 0 && !assetName.toLowerCase().endsWith(extension.toLowerCase())) continue
return {
version: release.version,
createdAt: release.createdAt,
assetName,
kind,
assetPath: asset.path
}
}
}
}
return null
}
@@ -0,0 +1,148 @@
import type { HostSpecific } from '../../domain/models/HostMachine'
import {
APP_MODE, DEFAULT_STORE_CONFIGURATION, WEB_MODE,
type AssetSpecification, type BehaviorConfiguration, type CatalogConfiguration,
type InstallConfiguration, type PathsConfiguration, type PlatformConfiguration,
type StoreConfiguration, type StoreDescriptor
} from '../../domain/models/StoreConfiguration'
import {
asRecord, readBoolean, readNumber, readOptionalString, readRecord, readString,
readStringArray, type JsonRecord
} from '../json/JsonRecord'
import type { StoreFileSystem } from '../files/StoreFileSystem'
/**
* A store's `config.json`, read onto the defaults.
*
* The file is **snake_case** and this is the only place that knows it: that is the
* format the store repositories publish, and it stays the format on disk so a store
* config written for the shell engine is still a valid config here. Rename a field
* there and this reader is the single file that follows.
*
* Reading replaces the deep merge the shell engine did: every field states its own
* default, so a config that omits a section gets the whole section rather than a
* half-populated one.
*/
export class StoreConfigurationReader {
public constructor (private readonly files: StoreFileSystem) {}
public readConfiguration (configPath: string): StoreConfiguration {
const record = asRecord(this.files.readJson(configPath)) ?? {}
return {
store: readStore(readRecord(record, 'store') ?? {}),
paths: readPaths(readRecord(record, 'paths') ?? {}),
install: readInstall(readRecord(record, 'install') ?? {}),
catalog: readCatalog(readRecord(record, 'catalog') ?? {}),
platforms: readPlatforms(readRecord(record, 'platforms')),
behavior: readBehavior(readRecord(record, 'behavior') ?? {})
}
}
}
function readStore (record: JsonRecord): StoreDescriptor {
const defaults = DEFAULT_STORE_CONFIGURATION.store
const api = readRecord(record, 'api') ?? {}
return {
id: readString(record, 'id', defaults.id),
name: readString(record, 'name', defaults.name),
// Trailing slashes are stripped once, here, so every URL built from it joins
// with exactly one separator.
baseUrl: readString(record, 'base_url', defaults.baseUrl).replace(/\/+$/, ''),
api: {
catalog: readString(api, 'catalog', defaults.api.catalog),
download: readString(api, 'download', defaults.api.download)
}
}
}
function readPaths (record: JsonRecord): PathsConfiguration {
const defaults = DEFAULT_STORE_CONFIGURATION.paths
return {
installRoot: readOptionalString(record, 'install_root'),
menuDirectory: readOptionalString(record, 'menu_dir'),
iconDirectory: readOptionalString(record, 'icon_dir'),
subfolder: readString(record, 'subfolder', defaults.subfolder)
}
}
function readInstall (record: JsonRecord): InstallConfiguration {
const defaults = DEFAULT_STORE_CONFIGURATION.install
const modes = readStringArray(record, 'modes')
const specifications: Record<string, AssetSpecification> = {}
for (const [key, value] of Object.entries(record)) {
if (key === 'modes') continue
const specification = asRecord(value)
if (specification === null) continue
specifications[key] = {
kind: readAssetKind(specification['kind']),
extension: readHostSpecificString(specification['ext'])
}
}
for (const mode of [APP_MODE, WEB_MODE]) {
specifications[mode] ??= defaults.specifications[mode] ?? { kind: null, extension: null }
}
return { modes: modes.length > 0 ? modes : defaults.modes, specifications }
}
function readCatalog (record: JsonRecord): CatalogConfiguration {
const defaults = DEFAULT_STORE_CONFIGURATION.catalog
const statuses = readStringArray(record, 'statuses')
const ownerId = record['owner_id']
return {
// An explicit empty list means "every status", so only a missing key falls back.
statuses: record['statuses'] === undefined ? defaults.statuses : statuses,
ownerId: typeof ownerId === 'number' && Number.isFinite(ownerId) ? ownerId : null,
only: readStringArray(record, 'only'),
exclude: readStringArray(record, 'exclude')
}
}
function readPlatforms (record: JsonRecord | null): Readonly<Record<string, PlatformConfiguration>> {
if (record === null) return DEFAULT_STORE_CONFIGURATION.platforms
const platforms: Record<string, PlatformConfiguration> = {}
for (const [name, value] of Object.entries(record)) {
const entry = asRecord(value)
platforms[name] = { enabled: entry === null ? true : readBoolean(entry, 'enabled', true) }
}
return platforms
}
function readBehavior (record: JsonRecord): BehaviorConfiguration {
const defaults = DEFAULT_STORE_CONFIGURATION.behavior
return {
prune: readBoolean(record, 'prune', defaults.prune),
timeout: readNumber(record, 'timeout', defaults.timeout),
insecure: readBoolean(record, 'insecure', defaults.insecure)
}
}
/** `"linux_x64"`, `["win_x64", "win_x86"]`, or either of those keyed by host. */
function readAssetKind (value: unknown): HostSpecific<string | readonly string[]> | null {
if (typeof value === 'string') return value
if (Array.isArray(value)) return readStrings(value)
const record = asRecord(value)
if (record === null) return null
const map: Record<string, string | readonly string[]> = {}
for (const [key, entry] of Object.entries(record)) {
if (typeof entry === 'string') map[key] = entry
else if (Array.isArray(entry)) map[key] = readStrings(entry)
}
return map
}
function readHostSpecificString (value: unknown): HostSpecific<string> | null {
if (typeof value === 'string') return value
const record = asRecord(value)
if (record === null) return null
const map: Record<string, string> = {}
for (const [key, entry] of Object.entries(record)) {
if (typeof entry === 'string') map[key] = entry
}
return map
}
function readStrings (values: readonly unknown[]): readonly string[] {
return values.filter((item: unknown): item is string => typeof item === 'string')
}
@@ -0,0 +1,114 @@
import {
gameKey, recordScope, type InstalledRecord
} from '../../domain/models/InstalledRecord'
import {
asRecord, readOptionalString, readString, type JsonRecord
} from '../json/JsonRecord'
import type { StoreFileSystem } from '../files/StoreFileSystem'
/** Bumped when the on-disk shape changes. v1 keyed `installed` by bare software name. */
const STATE_VERSION = 2
/**
* `state.json`: what this store put on this machine, and where.
*
* The file is **snake_case**, and deliberately so: it is the same file the shell
* store engine wrote, so a machine that installed games through the CLI keeps its
* library when the client takes over. This class is the only place that knows the
* on-disk field names everything above it sees `InstalledRecord`.
*
* A `version: 1` file, keyed by bare software name, is re-keyed on first read.
*/
export class StoreStateRepository {
public constructor (
private readonly files: StoreFileSystem,
private readonly statePath: string,
private readonly log: (line: string) => void
) {}
public readState (): Map<string, InstalledRecord> {
const state = asRecord(this.files.readJson(this.statePath))
if (state === null) return new Map<string, InstalledRecord>()
const installed = asRecord(state['installed'])
if (installed === null) return new Map<string, InstalledRecord>()
const version = state['version']
if (version === 1) return this.migrateFromVersionOne(installed)
if (version !== STATE_VERSION) return new Map<string, InstalledRecord>()
const records = new Map<string, InstalledRecord>()
for (const [key, value] of Object.entries(installed)) {
const record = asRecord(value)
if (record !== null) records.set(key, toRecord(record))
}
return records
}
public writeState (installed: ReadonlyMap<string, InstalledRecord>): void {
const serialised: Record<string, JsonRecord> = {}
for (const [key, record] of installed) serialised[key] = fromRecord(record)
this.files.writeJson(this.statePath, { version: STATE_VERSION, installed: serialised })
}
private migrateFromVersionOne (installed: JsonRecord): Map<string, InstalledRecord> {
const records = new Map<string, InstalledRecord>()
for (const value of Object.values(installed)) {
const raw = asRecord(value)
if (raw === null) continue
const record = toRecord(raw)
if (record.name.length > 0 && record.scope.length > 0) records.set(gameKey(record), record)
}
this.log(`migrated ${String(records.size)} state entries to the per-scope key format`)
return records
}
}
function toRecord (raw: JsonRecord): InstalledRecord {
return {
name: readString(raw, 'name'),
// `system` is what the Batocera store wrote before the shared core existed, and
// there the two were the same string — so an installed machine needs no migration.
scope: recordScope(readOptionalString(raw, 'scope'), readOptionalString(raw, 'system')),
platform: readString(raw, 'platform'),
kind: readString(raw, 'kind'),
version: readString(raw, 'version'),
asset: readString(raw, 'asset'),
assetPath: readString(raw, 'asset_path'),
title: readString(raw, 'title'),
description: readString(raw, 'desc'),
author: readString(raw, 'author'),
imageUrl: readOptionalString(raw, 'image_url'),
createdAt: readOptionalString(raw, 'created_at'),
mode: readString(raw, 'mode'),
payload: readOptionalString(raw, 'payload'),
executable: readOptionalString(raw, 'exe'),
executableKind: readOptionalString(raw, 'exe_kind'),
icon: readOptionalString(raw, 'icon'),
menuEntry: readOptionalString(raw, 'menu'),
url: readOptionalString(raw, 'url')
}
}
function fromRecord (record: InstalledRecord): JsonRecord {
return {
name: record.name,
scope: record.scope,
platform: record.platform,
kind: record.kind,
version: record.version,
asset: record.asset,
asset_path: record.assetPath,
title: record.title,
desc: record.description,
author: record.author,
image_url: record.imageUrl,
created_at: record.createdAt,
mode: record.mode,
payload: record.payload,
exe: record.executable,
exe_kind: record.executableKind,
icon: record.icon,
menu: record.menuEntry,
url: record.url
}
}
@@ -0,0 +1,56 @@
import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngineVersion'
/**
* How one WarpEngine version's catalog is shaped.
*
* The catalog is the one thing this client reads that it does not own the shape of, so
* it is the one thing an engine version can change under it. A dialect turns that
* foreign JSON into the typed records below, and everything downstream the survey,
* the release choice sees only those. When a future engine renames a field or nests a
* release differently, a new dialect is the whole change.
*/
export interface CatalogDialect {
readonly version: SupportedWarpEngineVersion
/** One entry per title in the catalog. */
listEntries: (catalog: unknown) => readonly CatalogEntry[]
}
export interface CatalogEntry {
readonly software: CatalogSoftware
/**
* The release the catalog itself calls newest-and-stable, or null when it names none.
*
* Kept separate from the candidates because it is also what an unavailable title's
* card shows a version from and a catalog with releases but no `latestRelease` has
* nothing to show there.
*/
readonly latestRelease: CatalogRelease | null
/**
* Every release worth trying, newest first and deduplicated.
*
* `latestRelease` comes first where there is one, then the rest, so that a game whose
* newest build is missing an asset still installs from an older one.
*/
readonly releaseCandidates: readonly CatalogRelease[]
}
export interface CatalogSoftware {
readonly name: string
readonly title: string
readonly platform: string
readonly status: string
readonly description: string
readonly author: string
readonly imageUrl: string | null
}
export interface CatalogRelease {
readonly version: string
readonly createdAt: string | null
readonly assets: readonly CatalogAsset[]
}
export interface CatalogAsset {
readonly kind: string
readonly path: string
}
@@ -0,0 +1,21 @@
import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngineVersion'
import type { CatalogDialect } from './CatalogDialect'
import { SoftwareListCatalogDialect } from './SoftwareListCatalogDialect'
/**
* Which dialect reads a catalog served by which engine version.
*
* The switch is exhaustive over `SUPPORTED_WARP_ENGINE_VERSIONS`, which is the whole
* mechanism: adding a version to that list stops compiling here until somebody decides
* what it reads like. Three versions share one dialect today because the catalog's
* shape has not changed across them and one class serving three versions is the
* honest way to say that, rather than three identical ones pretending otherwise.
*/
export function selectCatalogDialect (version: SupportedWarpEngineVersion): CatalogDialect {
switch (version) {
case '0.2':
case '0.3':
case '0.4':
return new SoftwareListCatalogDialect(version)
}
}
@@ -0,0 +1,112 @@
import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngineVersion'
import {
asRecord, readOptionalString, readRecord, readString, type JsonRecord
} from '../../json/JsonRecord'
import type {
CatalogAsset, CatalogDialect, CatalogEntry, CatalogRelease, CatalogSoftware
} from './CatalogDialect'
/**
* The catalog as every WarpEngine from 0.2 to 0.4 serves it.
*
* `{ softwares: [ { software: {…}, latestRelease: {…}, releases: [ { assets: [] } ] } ] }`,
* camelCase throughout. The three versions differ in what they *contain* 0.3 added
* the `linux_arm64` asset kinds, so an older engine simply has fewer kinds to offer,
* and that needs no code: an absent kind is a title reported as unavailable on this
* machine, which is already the honest answer.
*
* The version is carried rather than assumed, so a log line can name which dialect read
* a catalog even while one class serves several.
*/
export class SoftwareListCatalogDialect implements CatalogDialect {
public constructor (public readonly version: SupportedWarpEngineVersion) {}
public listEntries (catalog: unknown): readonly CatalogEntry[] {
const record = asRecord(catalog)
if (record === null) return []
const entries = record['softwares']
if (!Array.isArray(entries)) return []
const found: CatalogEntry[] = []
for (const item of entries) {
const entry = asRecord(item)
if (entry === null) continue
const software = this.readSoftware(entry)
if (software === null) continue
found.push({
software,
latestRelease: this.readLatestRelease(entry),
releaseCandidates: this.readCandidates(entry)
})
}
return found
}
/** A title with no name is not a title: nothing could be keyed by it. */
private readSoftware (entry: JsonRecord): CatalogSoftware | null {
const software = readRecord(entry, 'software')
if (software === null) return null
const name = readOptionalString(software, 'name')
if (name === null) return null
return {
name,
title: readOptionalString(software, 'title') ?? name,
platform: readString(software, 'platform'),
status: readString(software, 'status'),
description: readString(software, 'desc').trim(),
author: readString(software, 'author').trim(),
imageUrl: readOptionalString(software, 'imageUrl')
}
}
private readLatestRelease (entry: JsonRecord): CatalogRelease | null {
const latest = readRecord(entry, 'latestRelease')
return latest === null ? null : this.readRelease(latest)
}
/**
* The releases to try, newest first and without repeats.
*
* `releases` arrives newest-first from the API and `latestRelease` is usually its
* first element, so identity is settled on the release's own id where it has one.
*/
private readCandidates (entry: JsonRecord): readonly CatalogRelease[] {
const records: JsonRecord[] = []
const latest = readRecord(entry, 'latestRelease')
if (latest !== null) records.push(latest)
const releases = entry['releases']
if (Array.isArray(releases)) {
for (const item of releases) {
const record = asRecord(item)
if (record !== null) records.push(record)
}
}
const seen = new Set<string>()
const candidates: CatalogRelease[] = []
for (const record of records) {
const identity = JSON.stringify(record['id'] ?? null)
if (seen.has(identity)) continue
seen.add(identity)
candidates.push(this.readRelease(record))
}
return candidates
}
private readRelease (release: JsonRecord): CatalogRelease {
const assets: CatalogAsset[] = []
const listed = release['assets']
if (Array.isArray(listed)) {
for (const item of listed) {
const asset = asRecord(item)
if (asset === null) continue
assets.push({ kind: readString(asset, 'kind'), path: readString(asset, 'path') })
}
}
return {
version: readString(release, 'version'),
createdAt: readOptionalString(release, 'createdAt'),
assets
}
}
}
@@ -0,0 +1,45 @@
import fs from 'node:fs'
import path from 'node:path'
import type { InstalledRecord } from '../../../domain/models/InstalledRecord'
import { WEB_MODE } from '../../../domain/models/StoreConfiguration'
import type { StoreFileSystem } from '../../files/StoreFileSystem'
import { quoteForShell } from './ShellQuoting'
const LAUNCHER_MODE = 0o755
const COMMENT_LIMIT = 120
/**
* Linux: an XDG desktop entry.
*
* `Path=` is what gives the game its working directory a Godot or LÖVE build
* looks for its `.pck` next to the binary, and started from anywhere else it exits
* without a window and without a message.
*/
export class DesktopEntryWriter {
public constructor (
private readonly storeId: string,
private readonly files: StoreFileSystem
) {}
public write (record: InstalledRecord, entryPath: string, webUrl: string): string {
const lines: string[] = ['[Desktop Entry]', 'Type=Application', 'Version=1.0', `Name=${record.title}`]
if (record.description.length > 0) {
// One line only, and short: the menu shows it as a tooltip.
const firstLine = record.description.split('\n')[0] ?? ''
lines.push(`Comment=${firstLine.slice(0, COMMENT_LIMIT)}`)
}
if (record.mode === WEB_MODE) {
lines.push(`Exec=xdg-open ${quoteForShell(webUrl)}`)
} else if (record.executable !== null) {
lines.push(`Exec=${quoteForShell(record.executable)}`)
lines.push(`Path=${quoteForShell(path.dirname(record.executable))}`)
}
if (record.icon !== null) lines.push(`Icon=${record.icon}`)
lines.push('Terminal=false', 'Categories=Game;', `X-WarpStore=${this.storeId}`, '')
fs.mkdirSync(path.dirname(entryPath), { recursive: true })
this.files.writeAtomic(entryPath, Buffer.from(lines.join('\n'), 'utf8'), LAUNCHER_MODE)
return entryPath
}
}
@@ -0,0 +1,85 @@
import { spawnSync } from 'node:child_process'
import path from 'node:path'
import type { DesktopLayout } from '../../../domain/models/DesktopLayout'
import { toSafeFileName } from '../../../domain/models/DesktopLayout'
import type { InstalledRecord } from '../../../domain/models/InstalledRecord'
import type { SelectedGame } from '../../../domain/models/SelectedGame'
import type { StoreConfiguration } from '../../../domain/models/StoreConfiguration'
import type { StoreFileSystem } from '../../files/StoreFileSystem'
import type { DesktopLayoutResolver } from '../DesktopLayoutResolver'
import { DesktopEntryWriter } from './DesktopEntryWriter'
import { MacBundleWriter } from './MacBundleWriter'
import { WindowsShortcutWriter } from './WindowsShortcutWriter'
const REFRESH_TIMEOUT_MS = 30_000
/**
* The menu entry, in whichever form this machine's desktop understands.
*
* This is the whole point of a desktop store, and the one place where the three
* hosts genuinely differ rather than merely differing in paths.
*/
export class LauncherWriter {
private readonly desktopEntries: DesktopEntryWriter
private readonly macBundles: MacBundleWriter
private readonly windowsShortcuts: WindowsShortcutWriter
public constructor (
private readonly configuration: StoreConfiguration,
private readonly layouts: DesktopLayoutResolver,
files: StoreFileSystem,
private readonly log: (line: string) => void
) {
this.desktopEntries = new DesktopEntryWriter(configuration.store.id, files)
this.macBundles = new MacBundleWriter(configuration.store.id, files, log)
this.windowsShortcuts = new WindowsShortcutWriter(files, log)
}
/**
* The published page of a browser build.
*
* The catalog serves these as a directory rather than an archive, so the entry is
* a link to it which also means a web title needs the network.
*/
public webUrl (game: SelectedGame | InstalledRecord): string {
const assetPath = game.assetPath.length > 0 ? game.assetPath : `/file/${game.asset}`
return `${this.configuration.store.baseUrl}/${assetPath.replace(/^\/+|\/+$/g, '')}/`
}
public launcherPath (layout: DesktopLayout, game: SelectedGame | InstalledRecord): string {
const group = this.layouts.menuGroup(layout)
if (layout.operatingSystem === 'linux') {
return path.join(group, `${this.configuration.store.id}-${game.name}.desktop`)
}
if (layout.operatingSystem === 'darwin') {
return path.join(group, `${toSafeFileName(game.title)}.app`)
}
return path.join(group, `${toSafeFileName(game.title)}.lnk`)
}
/**
* Write the menu entry. Returns the path actually written.
*
* Not always the path we intended: on Windows a `.lnk` can fall back to a `.cmd`,
* and the state has to record what really exists or an uninstall would leave it
* behind.
*/
public writeLauncher (layout: DesktopLayout, record: InstalledRecord): string {
const entryPath = this.launcherPath(layout, record)
const url = this.webUrl(record)
if (layout.operatingSystem === 'linux') return this.desktopEntries.write(record, entryPath, url)
if (layout.operatingSystem === 'darwin') return this.macBundles.write(record, entryPath, url)
return this.windowsShortcuts.write(record, entryPath, url)
}
/** Ask the desktop to notice the change, where that is a thing we can do. */
public refreshMenu (layout: DesktopLayout): void {
if (layout.operatingSystem !== 'linux') return
try {
spawnSync('update-desktop-database', [layout.menuDirectory], { timeout: REFRESH_TIMEOUT_MS })
} catch {
// Not every Linux has it, and a menu that updates on next login is fine.
this.log('update-desktop-database is not available — the menu may need a re-login')
}
}
}
@@ -0,0 +1,148 @@
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import type { InstalledRecord } from '../../../domain/models/InstalledRecord'
import { WEB_MODE } from '../../../domain/models/StoreConfiguration'
import type { StoreFileSystem } from '../../files/StoreFileSystem'
import { escapeForXml, quoteForShell } from './ShellQuoting'
const RUNNER_MODE = 0o755
const PLIST_MODE = 0o644
const ICON_SIZE = '512'
const SIPS_TIMEOUT_MS = 60_000
const NAME_LIMIT = 255
/**
* macOS: link the archive's own bundle, or wrap a bare binary in one.
*
* A LÖVE or Godot build already ships a signed `.app`; copying it again would double
* the disk use and lose nothing but the icon, so it is symlinked instead. A TIC-80
* export is a bare executable, and for that a four-file bundle is what makes it
* double-clickable and Dock-able.
*/
export class MacBundleWriter {
public constructor (
private readonly storeId: string,
private readonly files: StoreFileSystem,
private readonly log: (line: string) => void
) {}
public write (record: InstalledRecord, bundlePath: string, webUrl: string): string {
replaceExisting(bundlePath)
if (record.mode !== WEB_MODE && record.executableKind === 'bundle' && record.executable !== null) {
fs.mkdirSync(path.dirname(bundlePath), { recursive: true })
fs.symlinkSync(record.executable, bundlePath)
return bundlePath
}
const contents = path.join(bundlePath, 'Contents')
const macOsDirectory = path.join(contents, 'MacOS')
const resources = path.join(contents, 'Resources')
fs.mkdirSync(macOsDirectory, { recursive: true })
fs.mkdirSync(resources, { recursive: true })
this.files.writeAtomic(
path.join(macOsDirectory, 'run'),
Buffer.from(this.runnerScript(record, webUrl), 'utf8'),
RUNNER_MODE
)
const iconWritten = this.writeIcon(record.icon, path.join(resources, 'icon.icns'))
this.files.writeAtomic(
path.join(contents, 'Info.plist'),
Buffer.from(this.infoPlist(record, iconWritten), 'utf8'),
PLIST_MODE
)
return bundlePath
}
private runnerScript (record: InstalledRecord, webUrl: string): string {
if (record.mode === WEB_MODE || record.executable === null) {
return `#!/bin/sh\nexec open ${quoteForShell(webUrl)}\n`
}
const directory = path.dirname(record.executable)
const program = `./${path.basename(record.executable)}`
return `#!/bin/sh\ncd ${quoteForShell(directory)} || exit 1\nexec ${quoteForShell(program)} "$@"\n`
}
/**
* The bundle's `Info.plist`.
*
* Keys are emitted in alphabetical order because that is what wrote these files
* before `plistlib` sorts a dict and a plist dict is unordered, so sorting costs
* nothing and makes a bundle regenerated by either engine the same file.
*/
private infoPlist (record: InstalledRecord, iconWritten: boolean): string {
const version = record.version.length > 0 ? record.version : '1.0'
const name = record.title.slice(0, NAME_LIMIT)
const entries: readonly (readonly [string, string])[] = [
['CFBundleName', `<string>${escapeForXml(name)}</string>`],
['CFBundleDisplayName', `<string>${escapeForXml(name)}</string>`],
['CFBundleExecutable', '<string>run</string>'],
['CFBundleIdentifier', `<string>org.${this.storeId}.store.${record.name}</string>`],
['CFBundleInfoDictionaryVersion', '<string>6.0</string>'],
['CFBundlePackageType', '<string>APPL</string>'],
['CFBundleShortVersionString', `<string>${escapeForXml(version)}</string>`],
['CFBundleVersion', `<string>${escapeForXml(version)}</string>`],
['NSHighResolutionCapable', '<true/>'],
...(iconWritten ? [['CFBundleIconFile', '<string>icon</string>'] as const] : [])
]
const body = [...entries]
.sort((left: readonly [string, string], right: readonly [string, string]): number =>
left[0] < right[0] ? -1 : 1)
.map(([key, value]: readonly [string, string]): string => `\t<key>${key}</key>\n\t${value}`)
.join('\n')
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
'<plist version="1.0">',
'<dict>',
body,
'</dict>',
'</plist>',
''
].join('\n')
}
/**
* Box art `.icns` with `sips`, which needs a square source first.
*
* Without this a generated bundle gets the generic application icon. `sips` is part
* of macOS, so there is nothing to install; if it fails we simply go without.
*/
private writeIcon (iconPath: string | null, destination: string): boolean {
if (iconPath === null || !fs.existsSync(iconPath)) return false
const square = `${destination}.square.png`
try {
const steps: readonly (readonly string[])[] = [
['-z', ICON_SIZE, ICON_SIZE, iconPath, '--out', square],
['-s', 'format', 'icns', square, '--out', destination]
]
for (const step of steps) {
const result = spawnSync('sips', [...step], { timeout: SIPS_TIMEOUT_MS })
if (result.status !== 0) {
this.log('sips could not convert the box art — the bundle gets the generic icon')
return false
}
}
return fs.existsSync(destination)
} catch {
return false
} finally {
fs.rmSync(square, { force: true })
}
}
}
/** A bundle is a directory, so replacing one is not a plain overwrite. */
function replaceExisting (bundlePath: string): void {
let stats: fs.Stats | null = null
try {
stats = fs.lstatSync(bundlePath)
} catch {
return
}
if (stats.isDirectory() && !stats.isSymbolicLink()) fs.rmSync(bundlePath, { recursive: true, force: true })
else fs.rmSync(bundlePath, { force: true })
}
@@ -0,0 +1,26 @@
/**
* Quoting for the two shells a desktop launcher goes through.
*
* A game title is user data that ends up inside an `Exec=` line and a `/bin/sh`
* script, and titles contain apostrophes. These are the same rules Python's
* `shlex.quote` and a PowerShell single-quoted string follow.
*/
const SAFE_UNQUOTED = /^[A-Za-z0-9_@%+=:,./-]+$/
export function quoteForShell (value: string): string {
if (value.length === 0) return "''"
if (SAFE_UNQUOTED.test(value)) return value
return `'${value.replace(/'/g, "'\"'\"'")}'`
}
export function quoteForPowerShell (value: string): string {
return `'${value.replace(/'/g, "''")}'`
}
export function escapeForXml (value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
@@ -0,0 +1,78 @@
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import type { InstalledRecord } from '../../../domain/models/InstalledRecord'
import { WEB_MODE } from '../../../domain/models/StoreConfiguration'
import type { StoreFileSystem } from '../../files/StoreFileSystem'
import { quoteForPowerShell } from './ShellQuoting'
const POWERSHELL_TIMEOUT_MS = 60_000
const DESCRIPTION_LIMIT = 250
/**
* Windows: a real `.lnk` through PowerShell, or a `.cmd` if that is missing.
*
* A `.lnk` is the only artifact that carries a working directory *and* shows up the
* way users expect, but it is a binary format with no writer in the standard library
* of any language here PowerShell's `WScript.Shell` is the one tool every Windows
* has. When even that is unavailable a `.cmd` still appears in the Start menu, which
* is worth more than a correct file nobody can see.
*/
export class WindowsShortcutWriter {
public constructor (
private readonly files: StoreFileSystem,
private readonly log: (line: string) => void
) {}
public write (record: InstalledRecord, shortcutPath: string, webUrl: string): string {
const target = record.mode === WEB_MODE ? webUrl : record.executable ?? ''
const workingDirectory = record.mode === WEB_MODE || record.executable === null
? ''
: path.dirname(record.executable)
fs.mkdirSync(path.dirname(shortcutPath), { recursive: true })
if (this.writeShortcut(record, shortcutPath, target, workingDirectory)) return shortcutPath
return this.writeCommandFile(record, shortcutPath, target, workingDirectory)
}
private writeShortcut (
record: InstalledRecord,
shortcutPath: string,
target: string,
workingDirectory: string
): boolean {
const script = [
`$s = (New-Object -ComObject WScript.Shell).CreateShortcut(${quoteForPowerShell(shortcutPath)});`,
`$s.TargetPath = ${quoteForPowerShell(target)};`,
workingDirectory.length > 0 ? `$s.WorkingDirectory = ${quoteForPowerShell(workingDirectory)};` : '',
`$s.Description = ${quoteForPowerShell(record.title.slice(0, DESCRIPTION_LIMIT))};`,
'$s.Save()'
].join('')
try {
const result = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
timeout: POWERSHELL_TIMEOUT_MS
})
if (result.status === 0 && fs.existsSync(shortcutPath)) return true
this.log(`powershell could not write ${shortcutPath}`)
} catch {
this.log('powershell is not available — falling back to a .cmd launcher')
}
return false
}
private writeCommandFile (
record: InstalledRecord,
shortcutPath: string,
target: string,
workingDirectory: string
): string {
const commandPath = `${shortcutPath.slice(0, shortcutPath.length - path.extname(shortcutPath).length)}.cmd`
const body = record.mode === WEB_MODE || workingDirectory.length === 0
? `@echo off\r\nstart "" "${target}"\r\n`
: `@echo off\r\ncd /d "${workingDirectory}"\r\nstart "" "${target}"\r\n`
this.files.writeAtomic(commandPath, Buffer.from(body, 'utf8'))
this.log(`wrote a .cmd launcher instead of a .lnk: ${commandPath}`)
return commandPath
}
}
+194
View File
@@ -0,0 +1,194 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
/**
* The mode an atomic write lands on when the caller names none.
*
* The shell engine's writes went through `mkstemp`, which creates at 0600, and its
* `state.json` and box art carry that mode on every machine this store has ever run
* on. Matching it keeps "the same files, in the same shape" literally true and for
* a state file that records what is installed under someone's home directory, the
* more private of the two defaults is the better one anyway.
*/
const PRIVATE_FILE_MODE = 0o600
/**
* The filesystem rules a store writes by.
*
* Three of them are safety rather than taste, and they are the reason this is one
* class instead of scattered `fs` calls:
*
* - **nothing is written in place.** A store interrupted mid-sync would otherwise
* leave a half-written menu entry, which is worse than an old one;
* - **every delete is guarded by `within`.** A store may only remove files from
* the subtree it owns, never from the user's own library;
* - **only empty directories are pruned.** One surprise file is enough to keep a
* directory.
*/
export class StoreFileSystem {
public constructor (
private readonly tag: string,
private readonly log: (line: string) => void
) {}
public readJson (filePath: string): unknown {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
} catch (error: unknown) {
if (isMissingFile(error)) return null
this.log(`warning: cannot read ${filePath}: ${describe(error)}`)
return null
}
}
public writeJson (filePath: string, data: unknown): void {
this.writeAtomic(filePath, Buffer.from(`${JSON.stringify(data, null, 2)}\n`, 'utf8'))
}
/** Write bytes via a temp file in the same directory, then rename over the target. */
public writeAtomic (filePath: string, blob: Buffer, mode: number = PRIVATE_FILE_MODE): string {
const directory = path.dirname(filePath)
fs.mkdirSync(directory, { recursive: true })
const temporary = path.join(directory, `.${this.tag}-${process.pid.toString(36)}-${counter()}.tmp`)
try {
fs.writeFileSync(temporary, blob, { mode })
// The mode is set again: `writeFileSync` applies the umask to it, and a launcher
// that is not executable is not a launcher.
fs.chmodSync(temporary, mode)
fs.renameSync(temporary, filePath)
} catch (error: unknown) {
fs.rmSync(temporary, { force: true })
throw error
}
return filePath
}
/**
* Remove a file, a symlink or a directory tree but only inside `root`.
*
* Returns whether anything went. A symlink is unlinked rather than followed: on
* macOS a menu entry may be a link to the archive's own `.app`, and removing the
* store's entry must not touch what it points at.
*/
public removeWithin (target: string, root: string): boolean {
if (!within(target, root)) {
this.log(`warning: refusing to delete ${target} (outside ${root})`)
return false
}
const stats = statOrNull(target)
if (stats === null) return false
if (stats.isDirectory() && !stats.isSymbolicLink()) {
fs.rmSync(target, { recursive: true, force: true })
} else {
fs.rmSync(target, { force: true })
}
return true
}
/**
* Remove those of `directories` that are now empty, deepest first.
*
* A store that has uninstalled everything should not leave its folders behind.
*/
public pruneEmptyDirectories (directories: readonly string[], root: string): void {
const ordered = [...new Set(directories.filter((entry: string): boolean => entry.length > 0)
.map((entry: string): string => path.resolve(entry)))]
.sort((left: string, right: string): number => depth(right) - depth(left))
for (const directory of ordered) {
if (!within(directory, root)) {
this.log(`warning: refusing to remove ${directory} (outside ${root})`)
continue
}
const stats = statOrNull(directory)
if (stats?.isDirectory() !== true) continue
if (fs.readdirSync(directory).length > 0) continue
try {
fs.rmdirSync(directory)
} catch {
// A directory that will not go is not a failure worth reporting: the next
// sync finds it again, and an uninstall has already done its real work.
}
}
}
public temporaryPath (directory: string, label: string, suffix: string): string {
return path.join(directory, `.${this.tag}-${label}${suffix}`)
}
}
/**
* True when `target` is `root` or sits inside it.
*
* Every delete a store performs is guarded by this.
*/
export function within (target: string, root: string): boolean {
if (target.length === 0 || root.length === 0) return false
const resolvedTarget = path.resolve(target)
const resolvedRoot = path.resolve(root)
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(resolvedRoot + path.sep)
}
/** `$XDG_DATA_HOME|~/.local/share/applications` or a plain `~`-path, resolved. */
export function expandPathSpecification (specification: string | null): string | null {
if (specification === null || specification.length === 0) return null
let remaining = specification
if (remaining.startsWith('$')) {
const [variable, fallback] = splitOnce(remaining.slice(1), '|')
// `$XDG_DATA_HOME|~/.local/share/applications` — the tail after the fallback's
// own prefix is what gets appended to the variable.
const [name, tail] = splitOnce(variable, '/')
const value = process.env[name]
if (value !== undefined && value.length > 0) {
const base = expandHome(value)
return path.resolve(tail.length > 0 ? path.join(base, tail) : base)
}
remaining = fallback
}
if (remaining.length === 0) return null
return path.resolve(expandVariables(expandHome(remaining)))
}
export function expandHome (value: string): string {
return value === '~' || value.startsWith(`~${path.sep}`) || value.startsWith('~/')
? path.join(os.homedir(), value.slice(2))
: value
}
function expandVariables (value: string): string {
return value.replace(/\$(\w+)|\$\{(\w+)\}/g, (match: string, bare: string | undefined, braced: string | undefined): string =>
process.env[bare ?? braced ?? ''] ?? match)
}
function splitOnce (value: string, separator: string): readonly [string, string] {
const index = value.indexOf(separator)
return index < 0 ? [value, ''] : [value.slice(0, index), value.slice(index + separator.length)]
}
function statOrNull (target: string): fs.Stats | null {
try {
return fs.lstatSync(target)
} catch {
return null
}
}
function depth (value: string): number {
return value.split(path.sep).length
}
function isMissingFile (error: unknown): boolean {
return typeof error === 'object' && error !== null &&
(error as { readonly code?: unknown }).code === 'ENOENT'
}
export function describe (error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
let sequence = 0
function counter (): string {
sequence += 1
return sequence.toString(36)
}
+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)
})
}
}
+130
View File
@@ -0,0 +1,130 @@
import fs from 'node:fs'
import http from 'node:http'
import https from 'node:https'
import path from 'node:path'
import { pipeline } from 'node:stream/promises'
import { HttpStatusError } from './HttpTextClient'
const MAX_REDIRECTS = 5
export interface HttpResponseBody {
readonly body: Buffer
readonly contentType: string
/** Lower-cased names, as Node delivers them. The engine version arrives in one. */
readonly headers: Readonly<Record<string, string>>
}
export interface StoreHttpOptions {
readonly userAgent: string
/** Seconds, as the store config states it. */
readonly timeout: number
readonly insecure: boolean
}
/**
* The store's own HTTP: bytes rather than text, and a streaming download.
*
* Separate from `HttpTextClient` because the two have different jobs that one
* fetches a config file and wants a string, this one fetches a catalog and a
* multi-megabyte archive and must not hold the archive in memory. It also honours
* the store config's `timeout` and `insecure`, which are per-store settings rather
* than properties of this application.
*/
export class StoreHttpClient {
public constructor (private readonly options: StoreHttpOptions) {}
public async readBytes (url: string): Promise<HttpResponseBody> {
return await this.request(url, MAX_REDIRECTS, async (
response: http.IncomingMessage
): Promise<HttpResponseBody> => {
const chunks: Buffer[] = []
for await (const chunk of response) chunks.push(Buffer.from(chunk as Buffer))
return {
body: Buffer.concat(chunks),
contentType: response.headers['content-type'] ?? '',
headers: readHeaders(response)
}
})
}
/**
* Stream `url` into `destination` atomically. Returns bytes written.
*
* A part file next to the target, then a rename: a download interrupted halfway
* must not look like a complete archive to the next sync.
*/
public async download (url: string, destination: string): Promise<number> {
const directory = path.dirname(destination)
fs.mkdirSync(directory, { recursive: true })
const partial = `${destination}.part`
let written = 0
try {
await this.request(url, MAX_REDIRECTS, async (response: http.IncomingMessage): Promise<void> => {
response.on('data', (chunk: Buffer): void => { written += chunk.length })
await pipeline(response, fs.createWriteStream(partial))
})
if (written === 0) throw new Error(`${url} returned an empty response`)
fs.renameSync(partial, destination)
} catch (error: unknown) {
fs.rmSync(partial, { force: true })
throw error
}
return written
}
private async request<TResult> (
url: string,
redirectsLeft: number,
consume: (response: http.IncomingMessage) => Promise<TResult>
): Promise<TResult> {
const response = await this.open(url)
const status = response.statusCode ?? 0
const location = response.headers.location
if (status >= 300 && status < 400 && location !== undefined) {
response.resume()
if (redirectsLeft <= 0) throw new Error(`too many redirects for ${url}`)
return await this.request(new URL(location, url).toString(), redirectsLeft - 1, consume)
}
if (status !== 200) {
response.resume()
throw new HttpStatusError(url, status)
}
return await consume(response)
}
private async open (url: string): Promise<http.IncomingMessage> {
return new Promise<http.IncomingMessage>((
resolve: (response: http.IncomingMessage) => void,
reject: (error: Error) => void
): void => {
const secure = !url.startsWith('http://')
const client = secure ? https : http
const request = client.get(url, {
headers: { 'User-Agent': this.options.userAgent },
...(secure && this.options.insecure ? { rejectUnauthorized: false } : {})
}, resolve)
request.setTimeout(Math.max(1, this.options.timeout) * 1000, (): void => {
request.destroy(new Error(`${url} timed out`))
})
request.on('error', reject)
})
}
}
/**
* The response headers as plain strings.
*
* Node gives a repeated header as an array; the ones this client reads are single-valued,
* and joining is a truer answer than picking the first.
*/
function readHeaders (response: http.IncomingMessage): Readonly<Record<string, string>> {
const headers: Record<string, string> = {}
for (const [name, value] of Object.entries(response.headers)) {
if (value === undefined) continue
headers[name.toLowerCase()] = Array.isArray(value) ? value.join(', ') : value
}
return headers
}
+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,106 @@
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 they were put.
*
* The roots are searched in the shell installers' own order those homes are still
* valid stores 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 []
}
}
/**
* A store home, recognised by its config and its directory name.
*
* The config is the only file that has to be there. It used to be the config *and*
* the engine script, and dropping that condition is what lets a home provisioned by
* an older client keep working after its scripts are cleared out.
*/
private readStoreAt (home: string, directoryName: string): InstalledStore | null {
const configPath = path.join(home, CONFIG_FILE_NAME)
if (!fs.existsSync(configPath)) return null
for (const engine of STORE_ENGINES) {
if (!directoryName.endsWith(engine.homeSuffix)) continue
const id = directoryName.slice(0, directoryName.length - engine.homeSuffix.length)
return { id, name: this.readStoreName(configPath, id), home, 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 assembling an engine 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,62 @@
import { RegistryUnavailableError } from '../../domain/errors/RegistryUnavailableError'
import type { RegistryStore } from '../../domain/models/RegistryStore'
import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository'
import { asRecord, readString, type JsonRecord } from '../json/JsonRecord'
import { BuildConfiguration } from '../config/BuildConfiguration'
import type { HttpTextClient } from '../http/HttpTextClient'
const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores'
/**
* The registry: `GET /api/stores` on the site.
*
* The one address this client knows, and it is decided in three places, most specific
* first: a runtime `STORES_API` (for trying something out), the `warpEngine.registryUrl`
* field a build was packaged with (for shipping a client for another site), and finally
* the address of ours.
*
* A record needs a name and a catalog URL; those two make a store. The repository
* is optional and arrives as null when absent a store configured by nothing but
* this record installs on the engine's defaults. Records missing either of the two
* required fields are dropped rather than half-used.
*/
export class HttpStoreRegistryRepository implements StoreRegistryRepository {
public readonly sourceUrl: string
public constructor (
private readonly httpClient: HttpTextClient,
sourceUrl?: string,
buildConfiguration: BuildConfiguration = new BuildConfiguration()
) {
const fromEnvironment = process.env['STORES_API']
this.sourceUrl = sourceUrl
?? (fromEnvironment !== undefined && fromEnvironment.length > 0 ? fromEnvironment : null)
?? buildConfiguration.readRegistryUrl()
?? DEFAULT_REGISTRY_URL
}
public async listStores (): Promise<readonly RegistryStore[]> {
const body = await this.httpClient.readText(this.sourceUrl)
const parsed: unknown = JSON.parse(body)
if (!Array.isArray(parsed)) {
throw new RegistryUnavailableError(this.sourceUrl, 'the answer was not a list of stores')
}
return parsed
.map((row: unknown): JsonRecord | null => asRecord(row))
.filter((row: JsonRecord | null): row is JsonRecord => row !== null)
.map((row: JsonRecord): RegistryStore => {
// Both spellings, because a registry is someone else's API: ours answers
// camelCase, and a hand-rolled one may not.
const repository = (
readString(row, 'storeRepositoryUrl') || readString(row, 'store_repository_url')
).trim()
return {
name: readString(row, 'name').trim(),
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim(),
storeRepositoryUrl: repository.length > 0 ? repository : null
}
})
.filter((store: RegistryStore): boolean =>
store.name.length > 0 && store.catalogUrl.length > 0)
}
}
@@ -0,0 +1,56 @@
import fs from 'node:fs'
import path from 'node:path'
import type { Preferences } from '../../domain/models/Preferences'
import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment'
import type { PreferencesRepository } from '../../domain/ports/PreferencesRepository'
import { LOCALES, type Locale } from '../../shared/i18n/MessageBundle'
import { asRecord, readBoolean, readOptionalString } from '../json/JsonRecord'
const PREFERENCES_FILE_NAME = 'prefs.json'
/**
* Preferences in one small JSON file next to the application's own data.
*
* A lost preference is not worth an error dialog, so both directions swallow their
* failures the defaults are always usable.
*/
export class JsonFilePreferencesRepository implements PreferencesRepository {
public constructor (private readonly environment: ApplicationEnvironment) {}
public read (): Preferences {
try {
const parsed = asRecord(JSON.parse(fs.readFileSync(this.filePath(), 'utf8')))
if (parsed === null) return {}
const locale = readOptionalString(parsed, 'locale')
const storeHome = readOptionalString(parsed, 'storeHome')
const preferences: {
locale?: Locale
navigationOpen?: boolean
storeHome?: string
} = {}
const known = LOCALES.find((candidate: Locale): boolean => candidate === locale)
if (known !== undefined) preferences.locale = known
if (storeHome !== null) preferences.storeHome = storeHome
if (typeof parsed['navigationOpen'] === 'boolean') {
preferences.navigationOpen = readBoolean(parsed, 'navigationOpen', true)
}
return preferences
} catch {
return {}
}
}
public write (preferences: Preferences): void {
try {
const target = this.filePath()
fs.mkdirSync(path.dirname(target), { recursive: true })
fs.writeFileSync(target, `${JSON.stringify(preferences, null, 2)}\n`)
} catch {
// Not worth interrupting the session over.
}
}
private filePath (): string {
return this.environment.resolveUserDataPath(PREFERENCES_FILE_NAME)
}
}
@@ -0,0 +1,151 @@
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 DEFAULT_FORGE_BASE = 'https://git.teletypegames.org'
const DEFAULT_BRANCH = 'master'
/** What an install used to leave in a store home, back when the engine was a script. */
const RETIRED_ENGINE_FILES: readonly string[] = ['desktop_store.py', 'warpstore.py']
/**
* Setting up a store where there is none.
*
* Since the engine moved into this application there is nothing to download but the
* store's own configuration, so an install is one HTTP call and one file. The store
* home stays where it was and keeps its name, because the state and the catalog cache
* beside that config are what make an existing library recognisable.
*/
export class NativeStoreEngineInstaller 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 })
const config = await this.readStoreConfig(store, progress)
const configPath = path.join(home, CONFIG_FILE_NAME)
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
this.removeRetiredEngine(home, progress)
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,
configPath,
engine: DESKTOP_STORE_ENGINE.id
}
}
/**
* Clear out the scripts an older client downloaded here.
*
* A store home provisioned by 1.5.0 or by the shell installer holds two Python
* files that nothing reads any more. They are harmless, but a directory that still
* looks like it holds the engine invites someone to run it against a state file
* this application is also writing.
*/
private removeRetiredEngine (home: string, progress: EngineProgressListener): void {
for (const fileName of RETIRED_ENGINE_FILES) {
const filePath = path.join(home, fileName)
if (!fs.existsSync(filePath)) continue
fs.rmSync(filePath, { force: true })
progress.onLog?.(`removed the retired ${fileName}`)
}
}
/**
* 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}`
}
}
+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 Client'
/**
* The application's lifecycle.
*
* Thin on purpose: it owns the window and the process events, and hands everything
* else to the container. The self-test mode is part of the lifecycle because it has
* to bypass two of its rules see below.
*/
export class ElectronApplication {
private readonly selfTest: boolean
private readonly container: ServiceContainer
private readonly windowFactory: MainWindowFactory
private window: BrowserWindow | null = null
public constructor (
private readonly app: App,
private readonly ipc: IpcMain,
shell: Shell,
argv: readonly string[] = process.argv
) {
this.selfTest = argv.includes(SELFTEST_FLAG)
this.container = new ServiceContainer(app, shell)
this.windowFactory = new MainWindowFactory((message: string, level: number): void => {
if (level >= 2 || this.selfTest) console.log(`[renderer] ${message}`)
})
}
public start (): void {
// A test run must never be swallowed by a copy the user already has open: it
// gets its own user-data directory and skips the single-instance lock. Without
// this the second process exits silently with status 0, which reads as a pass.
if (this.selfTest) {
this.app.setPath('userData', path.join(this.app.getPath('temp'), SELFTEST_USER_DATA_DIRECTORY))
} else if (!this.app.requestSingleInstanceLock()) {
this.app.quit()
return
}
this.container.registerIpc(this.ipc)
this.app.on('second-instance', (): void => { this.focusWindow() })
this.app.on('activate', (): void => {
if (BrowserWindow.getAllWindows().length === 0) this.openWindow()
})
this.app.on('window-all-closed', (): void => {
if (process.platform !== 'darwin') this.app.quit()
})
process.on('unhandledRejection', (reason: unknown): void => {
dialog.showErrorBox(PRODUCT_NAME, reason instanceof Error ? reason.message : String(reason))
})
void this.app.whenReady().then((): void => { this.openWindow() })
}
private openWindow (): void {
const window = this.windowFactory.createWindow()
this.window = window
this.container.streams.attachWindow(window)
window.on('closed', (): void => {
this.container.streams.detachWindow()
this.window = null
})
if (this.selfTest) this.scheduleSelfTest(window)
}
private scheduleSelfTest (window: BrowserWindow): void {
const runner = new SelfTestRunner(window)
window.webContents.once('did-finish-load', (): void => {
// The first listing has to finish before there is anything to look at.
setTimeout((): void => {
runner.run().then(
(passed: boolean): void => { this.app.exit(passed ? 0 : 1) },
(error: unknown): void => {
console.log(`SELFTEST ERROR ${error instanceof Error ? error.message : String(error)}`)
this.app.exit(1)
}
)
}, runner.settleDelayMs)
})
}
private focusWindow (): void {
const window = this.window
if (window === null) return
if (window.isMinimized()) window.restore()
window.focus()
}
}
+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 Client'
}
/**
* The one window.
*
* Locked down deliberately: context isolation on, node integration off, sandbox on,
* and the page carries a CSP of its own. Nothing here should ever navigate away or
* open a second window a link the user clicks goes to their browser instead.
*/
export class MainWindowFactory {
public constructor (private readonly onRendererMessage: (message: string, level: number) => void) {}
public createWindow (): BrowserWindow {
const window = new BrowserWindow({
...WINDOW_OPTIONS,
webPreferences: {
preload: path.join(__dirname, '..', 'preload', 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true
}
})
void window.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))
this.forwardRendererDiagnostics(window)
this.denyNavigation(window)
return window
}
/** A renderer error is invisible from the main process otherwise. */
private forwardRendererDiagnostics (window: BrowserWindow): void {
window.webContents.on('console-message', (details): void => {
const level = details.level === 'error' ? 3 : details.level === 'warning' ? 2 : 1
this.onRendererMessage(details.message, level)
})
window.webContents.on('render-process-gone', (_event, details): void => {
this.onRendererMessage(`gone: ${details.reason}`, 3)
})
}
private denyNavigation (window: BrowserWindow): void {
window.webContents.setWindowOpenHandler(({ url }: { url: string }): { action: 'deny' } => {
if (url.startsWith('https://')) void shell.openExternal(url)
return { action: 'deny' }
})
window.webContents.on('will-navigate', (event, url: string): void => {
if (url === window.webContents.getURL()) return
event.preventDefault()
if (url.startsWith('https://')) void shell.openExternal(url)
})
}
}
+74
View File
@@ -0,0 +1,74 @@
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 { NativeStoreCatalogGateway } from '../../infrastructure/engine/NativeStoreCatalogGateway'
import { HttpTextClient } from '../../infrastructure/http/HttpTextClient'
import { FileSystemInstalledStoreRepository } from '../../infrastructure/repositories/FileSystemInstalledStoreRepository'
import { NativeStoreEngineInstaller } from '../../infrastructure/repositories/NativeStoreEngineInstaller'
import { HttpStoreRegistryRepository } from '../../infrastructure/repositories/HttpStoreRegistryRepository'
import { JsonFilePreferencesRepository } from '../../infrastructure/repositories/JsonFilePreferencesRepository'
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 stores = new FileSystemInstalledStoreRepository()
const catalogGateway = new NativeStoreCatalogGateway()
const registry = new HttpStoreRegistryRepository(httpClient)
const installer = new NativeStoreEngineInstaller(httpClient)
const preferencesRepository = new JsonFilePreferencesRepository(environment)
const preferences = new PreferencesService(preferencesRepository, environment)
this.selection = new StoreSelectionService(stores, 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, 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)
}
}
+191
View File
@@ -0,0 +1,191 @@
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[]
/** `<accessible name>:<glyph count>` per icon-only control in the footer. */
readonly iconControls: 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.
// Both footer icons must be present, named and drawn: refresh and the language
// picker are the only way to reach those two actions now that neither has a label.
const iconsNamed = report.iconControls.length === 2 &&
report.iconControls.every((control: string): boolean => /^.+:1$/.test(control))
const rendered = report.locales.length > 1 && iconsNamed && (
(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),
// The two icon-only controls: a glyph with no accessible name is a button nobody
// can identify, and the failure is silent because the icon still draws.
iconControls: [...document.querySelectorAll('.side-tools .icon-btn')]
.map((control) => {
const named = control.getAttribute('aria-label') || control.getAttribute('title') ||
(control.querySelector('[aria-label]') || {}).ariaLabel || ''
return named + ':' + control.querySelectorAll('svg.icon').length
})
})`))) ?? {}
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'),
iconControls: readStringArray(record, 'iconControls')
}
}
/**
* 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)
}
}
}
+66
View File
@@ -0,0 +1,66 @@
import { InstalledStoreDtoMapper } from '../../application/mappers/InstalledStoreDtoMapper'
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()
) {}
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 {
return { store: this.storeMapper.toDto(this.selection.selectStore(home)) }
}
}

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