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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:52:59 +02:00
29 changed files with 655 additions and 188 deletions
+73
View File
@@ -0,0 +1,73 @@
# The pipeline lives in the repository rather than in the update server's
# `/build/config` extension. That extension serves game-platform pipelines, which
# build a cartridge and publish it into the site's catalog; this one builds a desktop
# application and publishes it to a Gitea release. Different product, different target.
#
# What CI can and cannot do here: Linux and Windows packages are built in containers —
# Windows through Wine — while the **macOS package stays a local build**, because
# Apple's toolchain and its signing exist only on a Mac. A release therefore gets its
# Linux and Windows assets from this pipeline and its macOS assets from `make release`.
when:
- event: [push, manual]
branch: master
- event: tag
variables:
# The official electron-builder images: Node with the packaging tools, and the same
# image plus Wine, which is what lets a Windows installer be built on Linux.
- &node_image 'electronuserland/builder:22'
- &wine_image 'electronuserland/builder:22-wine'
steps:
- name: check
image: *node_image
commands:
- node --version
- npm ci
- npm run typecheck
- npm run lint
# The window test wants a display and a store on the machine; that check belongs
# where there is one. The bridge check is worth running here: it exercises the
# registry and the message bundles.
- |
if command -v python3 >/dev/null 2>&1; then
npm run smoke
else
echo "no python3 in the image — the smoke test needs it, skipping"
fi
# A quarter of a gigabyte of packages is not worth building on every push, so the
# two builds run when a release is being cut — or when asked for by hand.
- name: linux
image: *node_image
commands:
- npm run dist:linux
- scripts/ci-verify-packages.sh '*.AppImage' '*.deb'
when:
- event: [tag, manual]
- name: windows
image: *wine_image
commands:
- npm run dist:win
- scripts/ci-verify-packages.sh '*.exe'
when:
- event: [tag, manual]
# Only on a tag, and only what this pipeline built: the macOS assets are uploaded
# from the Mac that can sign them.
- name: release
image: alpine
environment:
# Needed. Woodpecker does hand steps a forge credential — a manual build printed
# one — but a build started by the tag webhook does not get it: the first tag build
# died here with no credential at all. So the token is a repository secret, and the
# script still falls back to the forge credential when it is there.
GITEA_TOKEN:
from_secret: gitea_token
commands:
- apk add --no-cache curl jq
# No globs on the command line: the package names have spaces in them.
- scripts/ci-upload.sh
when:
- event: tag
+115 -27
View File
@@ -1,9 +1,10 @@
# warp-engine-desktop-gui — a window for the desktop store
# warp-engine-client — the WarpEngine Store app
A graphical client for
[`warp-engine-desktop-store`](https://git.teletypegames.org/stores/warp-engine-desktop-store):
the catalog as a grid of cards, one click to install a title into your own
application menu, one to play it, one to remove it. Linux, macOS and Windows.
The graphical client for a WarpEngine store — the app is called **WarpEngine
Store** — driving
[`warp-engine-desktop-store`](https://git.teletypegames.org/stores/warp-engine-desktop-store)
underneath: the catalog as a grid of cards, one click to install a title into your
own application menu, one to play it, one to remove it. Linux, macOS and Windows.
The CLI stays the product; this is its front door. Every action here runs
`desktop_store.py`, so there is one catalog logic, one state file and one delete
@@ -30,7 +31,7 @@ toolchain (TypeScript, ESLint, esbuild, electron-builder) installs with `make se
## 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.
@@ -63,28 +64,39 @@ and there is nothing to decide; several and the setup screen shows a picker.
```json
[
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org", "storeRepositoryUrl": null },
{
"name": "Teletype Games",
"catalogUrl": "https://teletypegames.org",
"storeRepositoryUrl": "https://git.teletypegames.org/stores/ttg-desktop-store"
"name": "Some Other Store",
"catalogUrl": "https://games.example.org",
"storeRepositoryUrl": "https://git.example.org/stores/other-desktop-store"
}
]
```
**A store needs no repository of its own.** A name and a catalog are enough: the
store engine's built-in defaults already cover the host-to-asset mapping, the
install modes, the platforms and the behaviour, so what is actually missing from
them is identity — a slug, a name and a catalog URL — and that is exactly what a
registry record carries. With `storeRepositoryUrl` null the client writes a
three-section config and the store installs.
From a record the client works out the rest:
- **`storeRepositoryUrl`** → the store's `config.json`, read from
`…/raw/branch/master/config.json`. That file is the authority on how the store
behaves: which platforms, which statuses, where things land.
- **the store id** — which names the store home and the folder games land in —
comes from the repository name when there is one (`ttg-desktop-store` becomes
`ttg`), otherwise from the catalog host (`teletypegames.org` becomes
`teletypegames`), otherwise from the display name. A `config.json` that sets its
own id keeps it.
- **`catalogUrl` and `name`** override the config's own `store.base_url` and
`store.name`. The registry says which catalog this store is *for*, so it wins.
- **the store id** — which names the store home and the folder games land in —
comes from the repository name: `ttg-desktop-store` becomes `ttg`. A
`config.json` that sets its own id keeps it.
- **`storeRepositoryUrl`**, when given → the store's `config.json`, read from
`…/raw/branch/master/config.json`. That file stays the authority on how the store
behaves: which platforms, which statuses, where things land. A repository
**without** a `config.json` is treated as no repository at all.
A repository **without** a `config.json` still works. The engine merges whatever
it is handed onto its own defaults, so the client writes a three-field config and
the store behaves like the default one pointed at that catalog.
What the defaults produce, for a record with no repository: the games land in a
folder named after the store id, and released, archived **and demo** titles are
listed — a catalog that publishes a demo means it to be played.
The registry address is the single thing about a particular site left in the
client, and `STORES_API` overrides it:
@@ -107,8 +119,8 @@ Everything that is not a title lives in the **side menu** on the left, and the
catalog into different folders show their folder instead of their id, because
the id would not tell them apart. **Add a store…** brings up the registry
picker, the same one the first run offers.
- **Actions** holds **Install all**, which fetches everything the catalog offers
for this machine, and **Refresh**, which re-reads the catalog.
- **Actions** holds **Refresh**, which re-reads the catalog. Titles are installed
one at a time from their own cards; there is no install-everything button.
- **Categories** narrows the grid, one category at a time, with the count next to
each: *Everything*, *Installed*, *Updates*, *Not installed*, then a row per
**platform** (`godot`, `tic80`, `love`, …) and per **kind** (native or hosted).
@@ -116,6 +128,9 @@ Everything that is not a title lives in the **side menu** on the left, and the
titles is not listed, and a category that disappears under you falls back to
*Everything* rather than leaving an empty grid. There is no genre in a
WarpEngine catalog, so these are the categories there are.
- **Log** opens the store's own output — its words, verbatim — together with the two
folders everything lands in. Off screen until asked for: the window has no footer,
because a permanent bar of absolute paths is not what a store is for.
- **Language** follows the system and can be switched; **English and Hungarian**.
In the grid, a card's button is **Install**, **Update**, or **Play** / **Open**
@@ -124,9 +139,6 @@ once it is there. **Remove** takes a title back out. Each card says whether it i
build the catalog serves rather than packages, so its entry opens a page and needs
the network.
The **Log** drawer at the bottom carries the store's own output verbatim, and next
to it are buttons that open the two folders everything lands in.
Every card carries a band of box art the same height — the first letter of the
title when the catalog has no image — so titles and buttons line up across a row.
Until this was photographed, the grid was quietly broken: the rows split the
@@ -167,16 +179,77 @@ 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
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
```
The tag comes from `package.json`, so `npm version patch` is the only place a
version is set. The release is created if it is not there yet, and an attachment
whose name is already on it is **replaced** rather than refused — so a rebuild and
a second `make publish` lands rather than erroring.
This is the **macOS half** of a release; the Linux and Windows packages come from the
pipeline when the tag is pushed (see above). The tag comes from `package.json`, so
`npm version patch` is the only place a version is set. The release is created if it is
not there yet — either half can go first — and an attachment whose name is already on it
is **replaced** rather than refused, so a rebuild and a second `make publish` lands
rather than erroring.
Release notes come from `RELEASE_NOTES.md` when the file is present, otherwise the
release gets a one-line note. The repository is read from `origin`, so a fork
@@ -258,6 +331,21 @@ there.
## Verified, and not
The pipeline's commands were run in the same containers it uses, before the pipeline was
committed: `electronuserland/builder:22` installs, type-checks, lints, passes the smoke
test (registry reached, store skipped as it should be on a machine that has none) and
produces the AppImage (128 MB) and the deb (100 MB). The Wine step could not be
rehearsed here — see above — and the size check that guards it was tested against both
outcomes: it rejects the 162 KB stub the failed Wine build left and accepts the two real
Linux packages.
A store with no repository was installed end to end from a local registry serving
one record with `storeRepositoryUrl: null`: the id came out as `teletypegames`, the
engine and the shared core downloaded, the written config had the three sections,
engine 1.1.0 accepted it, and it listed the same ten titles the configured store
does — then a hosted title synced into a sandbox and its menu entry appeared. The
setup gate was also photographed on a machine with no store at all.
The 1.3.0 refactor was measured rather than trusted: `make check` is clean — no type
errors, no lint findings, both test suites green — the window was photographed before
and after and the two are the same picture, and the packaged 1.3.0 bundle was run from
+69 -42
View File
@@ -1,42 +1,71 @@
# WarpEngine Store 1.3.0
# WarpEngine Store 1.4.0
**TypeScript, in layers.** The client was one `main.js`, one `preload.js`, three files
in `lib/` and one renderer script. It is now a typed application with the dependency
rule pointing 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 the
hosts (`main`, `preload`, `renderer`) sit on top. **[STRUCTURE.md](STRUCTURE.md)** is
the map: every layer, every pattern in use, and the naming rules, written to be read
before adding anything.
**A store no longer needs a repository of its own.** Until now every store in the
registry pointed at a repository holding its `config.json`, and the client read that
file to know what to install. It turns out almost nothing in there was necessary: the
store engine's built-in defaults already cover the host-to-asset mapping, the install
modes, the platforms and the behaviour. What defaults cannot know is *identity* — a
slug, a name and a catalog URL — and that is exactly what a registry record carries.
Nothing about the window changed. Same side menu, same categories, same switcher, same
two languages — this release is the inside of the app.
So `storeRepositoryUrl` is now optional. A record with a name and a catalog URL is a
complete store: the client derives the slug from the catalog host
(`teletypegames.org``teletypegames`), writes a small config and installs. Given a
repository it still reads it, and that file remains the authority on how the store
behaves — which platforms it offers, which statuses it shows, where things land. A
repository without a `config.json` is treated as no repository at all.
Two properties came out of the move, and both are worth having:
With the defaults, games land in a folder named after the store id and released,
archived **and demo** titles are listed: a catalog that publishes a demo means it to
be played.
- **The catalog can be driven with no window and no Electron at all.** `make smoke`
assembles the same services against the same ports in a plain Node process. It was a
script that reimplemented the bridge before; now it is a second composition root.
- **The window never receives a filesystem path.** A title crosses the bridge without
one, and launching is asked for *by name* — the main process resolves what that means
from the store's own state. Nothing in the renderer can be talked into opening a path.
The site's registry endpoint changed to match — `storeRepositoryUrl` answers `null`
when there is none — and adding a store is now genuinely one database row with two
fields filled in.
**A strict linter, and types everywhere.** `strict` plus
`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `noImplicitOverride`,
`noPropertyAccessFromIndexSignature` and friends; typescript-eslint's
`strictTypeChecked` and `stylisticTypeChecked` sets; explicit return types, parameter
types and property types required even where inference would manage; exhaustive
switches; no `any`, no `!`, no casts on foreign data — engine output and the registry
go through readers that turn `unknown` into typed values. The naming patterns are
enforced by `naming-convention` rather than trusted.
**The "Install all" button is gone.** Titles are installed one at a time from their
own cards.
Two things the types now catch that a person used to: a translation with a missing key
does not compile, and a channel the preload does not implement does not compile.
**No footer.** The window carried a bar at the bottom at all times — a toggle and a
line of absolute paths — for something most sessions never need. The log is still
there, with the two folder buttons in it, but it lives behind a quiet switch at the
bottom of the side menu and takes no room until it is opened. The grid gets the height
back.
**New make targets:** `make build`, `make typecheck`, `make lint`, `make lint-fix` and
`make check` — the gate, which runs the type-check, the linter and both test suites in
that order, cheapest failure first. Every script that runs the app builds first, so a
stale bundle cannot be tested.
**The repository is now `warp-engine-client`.** The app has always been called
WarpEngine Store; `warp-engine-desktop-gui` described the role rather than the
product, and the host-specific engines keep their own shape
(`warp-engine-desktop-store`, `-retroarch-store`, `-batocera-store`). Gitea keeps a
redirect from the old path, and the releases and tags moved with the repository, so
existing links and clones still resolve.
### Three things a screenshot found
Photographing the setup screen — which no automated count had ever looked at — turned
up three faults that every check had passed:
- the store badge in the bar rendered as an empty pill when no store was open;
- the gate's store picker showed as an empty dropdown stub, because an explicit
`display` in the stylesheet beats the browser's own `[hidden]` rule;
- the gate went up while the *"No installable titles in the catalog"* line stayed on
screen underneath it.
The last one was a design fault, not a typo: whether the gate is up was an imperative
call on a view rather than state, so the gate and the grid could disagree. The setup
screen is now a field in the state store, and that one field decides which of the two
is drawn. The window test's gate assertion was wrong too — it demanded a store picker,
which only appears when the registry offers more than one store, so a perfectly good
window failed it.
### Linux and Windows packages now come from CI
A `vX.Y.Z` tag now starts the pipeline, which builds the AppImage, the deb, the NSIS
installer and the portable exe — Windows through Wine — **creates this release** and
attaches all four. macOS stays a local build, because Apple's toolchain and its signing
exist only on a Mac, so `make release` from a Mac pushes that package onto the same
release afterwards. Publishing uses a `gitea_token` repository secret in Woodpecker.
The Windows installer is not signed: Windows will warn about an unknown publisher until
there is a certificate.
### Opening it on macOS
@@ -46,18 +75,16 @@ Ad-hoc signed, **not notarised**, so macOS asks first:
xattr -dr com.apple.quarantine "/Applications/WarpEngine Store.app"
```
*Open Anyway* under **System Settings ▸ Privacy & Security** works as well.
### What is attached
**macOS arm64 only**, the machine this was built and verified on. Windows and Linux
packages need a build on those platforms (`make dist-win` / `make dist-linux`).
The macOS arm64 package, built and verified here, plus whatever the pipeline attaches
for Linux (AppImage, deb) and Windows (installer, portable).
### Verified
`make check` is clean: no type errors, no lint findings, the smoke test green against
the real store and against a sandbox one, and the window test green with one store and
with two — where it clicks the store that is not open and checks that the bar, the grid
and the categories follow. The window was photographed before and after the refactor
and the two are the same picture. The packaged app was run from the built bundle, not
from a dev launch.
A repository-less store was installed end to end against a local registry serving one
record with `storeRepositoryUrl: null`: the id came out as `teletypegames`, the engine
and the shared core downloaded, the written config had three sections, engine 1.1.0
accepted it, it listed the same ten titles the configured store does, and a hosted
title synced into a sandbox with its menu entry written. `make check` is clean, and
the setup gate was photographed on a machine with no store at all.
+5
View File
@@ -161,6 +161,11 @@ after what it changes and notifies afterwards; `RendererApplication` re-renders
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
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "warp-engine-desktop-gui",
"name": "warp-engine-client",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "warp-engine-desktop-gui",
"name": "warp-engine-client",
"version": "1.2.0",
"license": "MIT",
"devDependencies": {
+3 -3
View File
@@ -1,11 +1,11 @@
{
"name": "warp-engine-desktop-gui",
"name": "warp-engine-client",
"productName": "WarpEngine Store",
"version": "1.3.0",
"version": "1.4.0",
"description": "Graphical client for a WarpEngine desktop store: install the catalog into your own application menu.",
"license": "MIT",
"author": "Teletype Games <games@teletype.hu>",
"homepage": "https://git.teletypegames.org/stores/warp-engine-desktop-gui",
"homepage": "https://git.teletypegames.org/stores/warp-engine-client",
"main": "build/main/main.js",
"engines": {
"node": ">=22"
+114
View File
@@ -0,0 +1,114 @@
#!/bin/sh
# Attach built packages to the Gitea release for this tag.
#
# The local publisher (scripts/release.sh) drives `tea`, which is logged in
# interactively on a workstation. CI has no such session: it has a token and curl. The
# two are deliberately separate scripts rather than one with two ways to authenticate —
# each is short enough to read in full.
#
# scripts/ci-upload.sh every package in dist/
# scripts/ci-upload.sh dist/one.deb just these
#
# Authenticates with the `gitea_token` secret when there is one, and otherwise with the
# credential Woodpecker gives every step for cloning — so a release needs no secret.
#
# Creates the release when the tag has none, with RELEASE_NOTES.md as its body. That is
# the flow: a `vX.Y.Z` tag starts this pipeline, which publishes the release with the
# Linux and Windows packages in it, and the macOS package is pushed on top afterwards by
# `make release` from a Mac.
set -eu
FORGE="${FORGE_API:-https://git.teletypegames.org/api/v1}"
REPO="${REPO:-${CI_REPO:-}}"
TAG="${TAG:-${CI_COMMIT_TAG:-}}"
DIST="${DIST:-dist}"
NOTES="${NOTES:-RELEASE_NOTES.md}"
say() { echo "[ci-upload] $*"; }
die() { echo "[ci-upload] error: $*" >&2; exit 1; }
# Who to be. A `gitea_token` secret wins when there is one; otherwise the credential
# Woodpecker already hands every step for cloning is used, which is an access token of
# the repository's owner — so publishing needs no secret of its own. Gitea accepts a
# personal access token as `token …` and an OAuth one as `Bearer …`, and which of the two
# this is depends on how Woodpecker was set up, so the scheme is probed once rather than
# assumed.
TOKEN="${GITEA_TOKEN:-${CI_NETRC_PASSWORD:-}}"
[ -n "$TOKEN" ] || die "no credential: set GITEA_TOKEN, or run this where Woodpecker provides CI_NETRC_PASSWORD"
[ -n "$REPO" ] || die "cannot work out the repository — set REPO=owner/name"
[ -n "$TAG" ] || die "cannot work out the tag — set TAG=v1.2.3"
AUTH=""
for scheme in token Bearer; do
if curl -fsS -H "Authorization: $scheme $TOKEN" "$FORGE/user" >/dev/null 2>&1; then
AUTH="Authorization: $scheme $TOKEN"
say "authenticated with the $scheme scheme"
break
fi
done
[ -n "$AUTH" ] || die "the credential was refused by $FORGE — it cannot read /user"
api() {
method="$1"; path="$2"; shift 2
curl -fsS -X "$method" -H "$AUTH" "$FORGE$path" "$@"
}
# Package names contain spaces — "WarpEngine Store Setup 1.4.0.exe" does — so the list
# lives one path per line in a file and is read with `while IFS= read -r`. A single
# variable looped over with $list splits on the space and uploads nothing.
LIST="$(mktemp)"
trap 'rm -f "$LIST"' EXIT
if [ "$#" -gt 0 ]; then
for given in "$@"; do printf '%s\n' "$given"; done > "$LIST"
else
# What this pipeline builds. The macOS packages are attached from the Mac that can
# sign them, so they are not listed here even when they happen to be present.
find "$DIST" -maxdepth 1 -type f \
\( -name '*.AppImage' -o -name '*.deb' -o -name '*.exe' \) 2>/dev/null | sort > "$LIST" || true
fi
[ -s "$LIST" ] || die "no Linux or Windows packages in $DIST"
say "$REPO $TAG"
# `curl -f` fails on the 404 a missing release answers, so the lookup is allowed to
# fail and judged by what came back rather than by its exit status.
release_id="$(curl -sS -H "$AUTH" "$FORGE/repos/$REPO/releases/tags/$TAG" | jq -r '.id // empty')"
if [ -z "$release_id" ]; then
say "no release for $TAG yet — creating it"
title="$(jq -r '(.productName // .name) + " " + (.version)' package.json)"
notes=''
[ -f "$NOTES" ] && notes="$(cat "$NOTES")"
# The body goes through jq rather than string concatenation: release notes are
# markdown with quotes and newlines in them.
payload="$(jq -n --arg tag "$TAG" --arg title "$title" --arg body "$notes" \
'{tag_name: $tag, name: $title, body: $body, draft: false, prerelease: false}')"
release_id="$(api POST "/repos/$REPO/releases" \
-H 'Content-Type: application/json' -d "$payload" | jq -r '.id // empty')"
[ -n "$release_id" ] || die "the release for $TAG could not be created"
else
say "the release already exists"
fi
while IFS= read -r asset; do
[ -n "$asset" ] || continue
[ -f "$asset" ] || die "no such file: $asset"
name="$(basename "$asset")"
encoded="$(printf '%s' "$name" | jq -sRr @uri)"
# Replace rather than refuse, so re-running a build lands.
existing="$(api GET "/repos/$REPO/releases/$release_id/assets" |
jq -r --arg name "$name" '.[] | select(.name == $name) | .id')"
for id in $existing; do
say "replacing $name"
api DELETE "/repos/$REPO/releases/$release_id/assets/$id" >/dev/null
done
say "uploading $name"
api POST "/repos/$REPO/releases/$release_id/assets?name=$encoded" \
-F "attachment=@$asset" >/dev/null
done < "$LIST"
say "done:"
api GET "/repos/$REPO/releases/$release_id" |
jq -r '.assets[] | " \(.name) \(.size / 1000000 | floor) MB"'
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# Fail on a package that is too small to be one.
#
# Written after a Wine build died halfway and left a 162 KB stub named like the real
# installer: `ls` was happy, the step passed, and the release would have carried a file
# that cannot be run. An Electron package is ~100 MB — anything under a tenth of that
# did not finish.
#
# scripts/ci-verify-packages.sh '*.AppImage' '*.deb'
set -eu
DIST="${DIST:-dist}"
MIN_BYTES="${MIN_BYTES:-10000000}"
die() { echo "[verify] error: $*" >&2; exit 1; }
[ "$#" -gt 0 ] || die "no patterns given"
for pattern in "$@"; do
found=0
# One path per line: package names contain spaces.
find "$DIST" -maxdepth 1 -type f -name "$pattern" | sort > /tmp/verify-list
while IFS= read -r file; do
[ -n "$file" ] || continue
found=1
size="$(wc -c < "$file" | tr -d ' ')"
if [ "$size" -lt "$MIN_BYTES" ]; then
die "$file is only $size bytes — the build did not finish"
fi
echo "[verify] $(basename "$file"): $size bytes"
done < /tmp/verify-list
[ "$found" -eq 1 ] || die "no $pattern in $DIST"
done
rm -f /tmp/verify-list
+6 -3
View File
@@ -1,11 +1,14 @@
/**
* A store the site's registry offers.
*
* Three fields, because that is what a record is: what it is called, which
* catalog it serves, and where its configuration lives.
* 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
readonly storeRepositoryUrl: string | null
}
+32 -8
View File
@@ -1,15 +1,39 @@
import type { RegistryStore } from './RegistryStore'
/**
* A store id from its repository name: `ttg-desktop-store` becomes `ttg`.
* A store id, from whatever the registry gave us.
*
* The id names the store home and the folder games land in, so it has to be short
* and filesystem-safe. The repository name is the best source available before
* anything is downloaded; the store's own config.json overrides it once it is.
* 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 lastSegment = store.storeRepositoryUrl.replace(/\/+$/, '').split('/').pop() ?? ''
const base = lastSegment.replace(/-(desktop-)?store$/, '') || store.name
const slug = base.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')
return slug || 'store'
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, '')
}
+1 -1
View File
@@ -3,7 +3,7 @@ import https from 'node:https'
const REQUEST_TIMEOUT_MS = 60_000
const MAX_REDIRECTS = 5
const USER_AGENT = 'warp-engine-desktop-gui'
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 {
@@ -76,30 +76,26 @@ export class HttpStoreEngineInstaller implements StoreEngineInstaller {
/**
* The store's configuration.
*
* Its repository is the authority on how the store behaves — which platforms,
* which statuses, where things land. A repository without a config.json still
* works: the engine merges whatever it is given onto its own defaults, so a
* three-field config is a complete one. The registry wins on identity and on
* which catalog to read.
* 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)
let config: Record<string, unknown>
try {
progress.onLog?.(`reading the store config from ${store.storeRepositoryUrl}`)
const body = await this.httpClient.readText(this.configUrl(store.storeRepositoryUrl))
config = { ...(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')
config = {
paths: { subfolder: storeId },
catalog: { statuses: ['released', 'archived', 'demo'] }
}
}
const config = await this.readPublishedConfig(store, storeId, progress)
const existing = asRecord(config['store']) ?? {}
config['store'] = {
@@ -111,6 +107,43 @@ export class HttpStoreEngineInstaller implements StoreEngineInstaller {
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}`
}
@@ -10,8 +10,12 @@ const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores'
* The registry: `GET /api/stores` on the site.
*
* The one address this client knows, and even that is overridable — `STORES_API`
* points it at another site or at a local endpoint. Records missing any of the
* three fields are dropped rather than half-used.
* points it at another site or at a local endpoint.
*
* A record needs a name and a catalog URL; those two make a store. The repository
* is optional and arrives as null when absent — a store configured by nothing but
* this record installs on the engine's defaults. Records missing either of the two
* required fields are dropped rather than half-used.
*/
export class HttpStoreRegistryRepository implements StoreRegistryRepository {
public readonly sourceUrl: string
@@ -32,16 +36,19 @@ export class HttpStoreRegistryRepository implements StoreRegistryRepository {
return parsed
.map((row: unknown): JsonRecord | null => asRecord(row))
.filter((row: JsonRecord | null): row is JsonRecord => row !== null)
.map((row: JsonRecord): RegistryStore => ({
name: readString(row, 'name').trim(),
.map((row: JsonRecord): RegistryStore => {
// Both spellings, because a registry is someone else's API: ours answers
// camelCase, and a hand-rolled one may not.
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim(),
storeRepositoryUrl: (
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 && store.storeRepositoryUrl.length > 0)
store.name.length > 0 && store.catalogUrl.length > 0)
}
}
+4 -1
View File
@@ -65,10 +65,13 @@ export class SelfTestRunner {
if (this.shotPath !== null) await this.captureShot(this.shotPath)
// A gate passes on having something to do, not on having a picker: the picker
// only appears when the registry offers more than one store, and one store is
// the ordinary case. Requiring choices here failed a perfectly good window.
const rendered = report.locales.length > 1 && (
(report.cards > 0 && !report.gateVisible && report.stores.length > 0 &&
report.categories.length > 0 && report.activeCategory !== null) ||
(report.gateVisible && report.gateChoices.length > 0 && report.gateAction.length > 0))
(report.gateVisible && report.gateAction.length > 0))
const switchedWell = switched === null || (
switched.storeId.length > 0 && switched.storeId !== report.storeId &&
switched.cards > 0 && switched.categories > 0)
+5 -3
View File
@@ -27,14 +27,16 @@ export function requireStringArray (value: unknown, name: string): readonly stri
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'),
storeRepositoryUrl: readString(record, 'storeRepositoryUrl'),
// 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 || store.storeRepositoryUrl.length === 0) {
throw new TypeError('a store record needs a name, a catalog URL and a repository URL')
if (store.name.length === 0 || store.catalogUrl.length === 0) {
throw new TypeError('a store record needs a name and a catalog URL')
}
return store
}
+12 -5
View File
@@ -41,7 +41,7 @@ export class RendererApplication {
})
this.gate = new GateView((url: string): void => { void this.bridge.openUrl(url) })
this.catalog = new CatalogController(this.bridge, this.store, this.log)
this.stores = new StoreController(this.bridge, this.store, this.gate, this.log, this.catalog)
this.stores = new StoreController(this.bridge, this.store, this.log, this.catalog)
this.preferences = new PreferencesController(this.bridge, this.store)
this.streams = new EngineStreamController(this.bridge, this.store, this.log)
@@ -56,7 +56,6 @@ export class RendererApplication {
this.sideMenu = new SideMenuView({
onSelectStore: (home: string): void => { void this.stores.selectStore(home) },
onAddStore: (): void => { void this.stores.offerStores() },
onSyncAll: (): void => { void this.catalog.syncGames([]) },
onRefresh: (): void => { void this.catalog.refresh() },
onSelectCategory: (filter: CategoryFilter): void => { this.store.applyFilter(filter) },
onSelectLocale: (locale: string): void => { void this.preferences.selectLocale(locale) }
@@ -83,7 +82,7 @@ export class RendererApplication {
this.stores.showOutdatedEngineGate()
return
}
this.gate.hide()
this.store.applyGate(null)
await this.catalog.refresh()
}
@@ -100,7 +99,15 @@ export class RendererApplication {
this.topBar.render(state)
this.sideMenu.render(state)
this.log.render(state)
if (this.gate.visible) this.grid.hide()
else this.grid.render(state)
// The gate and the grid are alternatives, decided by one field, so they cannot
// both be on screen — which is what happened while this was two imperative calls.
if (state.gate === null) {
this.gate.hide()
this.grid.render(state)
} else {
this.gate.show(state.gate, state.messages)
this.grid.hide()
}
}
}
+12 -14
View File
@@ -1,7 +1,6 @@
import type { BridgeApi } from '../../shared/contracts/BridgeApi'
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
import type { AppStore } from '../state/AppStore'
import type { GateView } from '../views/GateView'
import type { LogDrawerView } from '../views/LogDrawerView'
import type { CatalogController } from './CatalogController'
@@ -16,7 +15,6 @@ export class StoreController {
public constructor (
private readonly bridge: BridgeApi,
private readonly store: AppStore,
private readonly gate: GateView,
private readonly log: LogDrawerView,
private readonly catalog: CatalogController
) {}
@@ -31,7 +29,7 @@ export class StoreController {
this.showOutdatedEngineGate()
return
}
this.gate.hide()
this.store.applyGate(null)
await this.catalog.refresh()
} catch (error: unknown) {
this.log.appendLine(`${messages.switchFailed}: ${error instanceof Error ? error.message : String(error)}`)
@@ -48,23 +46,23 @@ export class StoreController {
const result = await this.bridge.listRegistryStores()
if (result.error !== null) {
this.gate.show({
this.store.applyGate({
title: messages.registryFailed,
body: `${result.sourceUrl}\n\n${result.error}`,
action: { label: messages.registryRetry, perform: (): void => { void this.offerStores() } }
}, messages)
})
return
}
if (result.stores.length === 0) {
this.gate.show({
this.store.applyGate({
title: messages.setupTitle,
body: `${messages.registryEmpty}\n\n${result.sourceUrl}`
}, messages)
})
return
}
this.gate.show({
this.store.applyGate({
title: messages.setupTitle,
body: `${messages.setupBody}\n\n${state.defaultStoreRoot}`,
action: {
@@ -74,26 +72,26 @@ export class StoreController {
}
},
choices: result.stores
}, messages)
})
}
public showOutdatedEngineGate (): void {
const state = this.store.readState()
const engineText = state.engine === null ? '' : state.engine.text
this.gate.show({
this.store.applyGate({
title: state.messages.oldEngineTitle,
body: `${state.messages.oldEngineBody}\n\n${engineText}${state.minimumEngineVersion}`,
action: { label: state.messages.oldEngineAction, perform: (): void => { void this.offerStores() } }
}, state.messages)
})
}
public showMissingPythonGate (): void {
const messages = this.store.readState().messages
this.gate.show({
this.store.applyGate({
title: messages.noPythonTitle,
body: messages.noPythonBody,
link: { label: messages.pythonLink, url: 'https://www.python.org/downloads/' }
}, messages)
})
}
private async installStore (chosen: RegistryStoreDto): Promise<void> {
@@ -102,7 +100,7 @@ export class StoreController {
try {
await this.bridge.installStore(chosen)
this.store.applyAppState(await this.bridge.readState())
this.gate.hide()
this.store.applyGate(null)
await this.catalog.refresh()
await this.catalog.syncGames([])
} catch (error: unknown) {
+5 -5
View File
@@ -36,7 +36,6 @@
<section class="side-block">
<h2 class="side-head" id="head-actions"></h2>
<button id="sync-all" class="btn btn-primary btn-wide" disabled></button>
<button id="refresh" class="btn btn-wide" disabled></button>
</section>
@@ -46,6 +45,7 @@
</section>
<section class="side-block side-foot">
<button id="log-toggle" class="side-quiet" aria-expanded="false" aria-controls="log-panel"></button>
<label class="side-lang">
<span id="head-lang"></span>
<select id="locale" class="select" aria-label="Language"></select>
@@ -72,11 +72,11 @@
<section id="empty" class="empty" hidden></section>
<footer class="log">
<button id="log-toggle" class="log-toggle" aria-expanded="false"></button>
<div class="log-lines" id="log-lines" hidden></div>
<!-- Only on screen while the log is open; the window has no permanent footer. -->
<section class="log-panel" id="log-panel" hidden>
<div class="log-lines" id="log-lines"></div>
<div class="log-paths" id="log-paths"></div>
</footer>
</section>
</div>
</div>
+10 -1
View File
@@ -6,6 +6,7 @@ import type { StorePathsDto } from '../../shared/contracts/dto/StorePathsDto'
import { ENGLISH_MESSAGES } from '../../shared/i18n/EnglishMessages'
import type { Locale, MessageBundle } from '../../shared/i18n/MessageBundle'
import { ALL_CATEGORIES, type CategoryFilter } from './CategoryFilter'
import type { GatePresentation } from './GatePresentation'
/** How far a running sync has got, for the counter in the bar. */
export interface SyncProgress {
@@ -32,6 +33,8 @@ export interface AppState {
readonly filter: CategoryFilter
readonly busy: boolean
readonly progress: SyncProgress | null
/** Non-null while the setup screen is up, which is also what hides the grid. */
readonly gate: GatePresentation | null
}
const INITIAL_STATE: AppState = {
@@ -50,7 +53,8 @@ const INITIAL_STATE: AppState = {
paths: null,
filter: ALL_CATEGORIES,
busy: false,
progress: null
progress: null,
gate: null
}
export type AppStateListener = (state: AppState) => void
@@ -112,6 +116,11 @@ export class AppStore {
this.notify()
}
public applyGate (gate: GatePresentation | null): void {
this.state = { ...this.state, gate }
this.notify()
}
public applyFilter (filter: CategoryFilter): void {
this.state = { ...this.state, filter }
this.notify()
+27
View File
@@ -0,0 +1,27 @@
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
/** A button on the gate, and what choosing it does. */
export interface GateAction {
readonly label: string
readonly perform: (chosen: RegistryStoreDto | null) => void
}
export interface GateLink {
readonly label: string
readonly url: string
}
/**
* What the gate is showing.
*
* Part of the state rather than a call on a view: whether the gate is up decides
* whether the grid is drawn, and the two disagreed when this was imperative — the
* gate went up and the empty-catalog line stayed underneath it.
*/
export interface GatePresentation {
readonly title: string
readonly body: string
readonly action?: GateAction
readonly link?: GateLink
readonly choices?: readonly RegistryStoreDto[]
}
+21 -6
View File
@@ -14,6 +14,11 @@
* { box-sizing: border-box; }
/* An explicit `display` beats the browser's own [hidden] rule, and most of the
regions here have one — the gate's store picker showed as an empty stub because
of exactly that. This makes `hidden` mean hidden everywhere. */
[hidden] { display: none !important; }
body {
margin: 0;
background: var(--bg);
@@ -60,7 +65,11 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
.side-block { display: flex; flex-direction: column; gap: 6px; flex: none; }
.side-cats { flex: 1; min-height: 0; }
.side-foot { padding-top: 12px; border-top: 1px solid var(--line); }
.side-foot {
padding-top: 12px;
border-top: 1px solid var(--line);
gap: 8px;
}
.side-head {
font-size: 11px;
font-weight: 700;
@@ -279,23 +288,29 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
}
.actions { display: flex; gap: 8px; margin-top: auto; }
/* --- log ---------------------------------------------------------------- */
.log {
/* --- log ----------------------------------------------------------------
No permanent footer: the panel is in the flow only while it is open, and the
switch for it sits in the side menu with everything else that is not a title. */
.log-panel {
flex: none;
background: var(--panel);
border-top: 1px solid var(--line);
padding: 8px 18px 10px;
}
.log-toggle {
.side-quiet {
font: inherit;
font-size: 12px;
font-weight: 600;
color: var(--ink-dim);
background: none;
border: 0;
padding: 0 0 4px;
border-radius: 6px;
padding: 4px 6px;
margin-left: -6px;
text-align: left;
cursor: pointer;
}
.side-quiet:hover { color: var(--ink); background: var(--panel-2); }
.side-quiet.is-active { color: var(--accent); }
.log-lines {
max-height: 150px;
overflow-y: auto;
+1 -23
View File
@@ -1,25 +1,7 @@
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
import { createElement, requireElement, setHidden, setText } from '../dom/Dom'
import type { MessageBundle } from '../../shared/i18n/MessageBundle'
/** A button on the gate, and what choosing it does. */
export interface GateAction {
readonly label: string
readonly perform: (chosen: RegistryStoreDto | null) => void
}
export interface GateLink {
readonly label: string
readonly url: string
}
export interface GatePresentation {
readonly title: string
readonly body: string
readonly action?: GateAction
readonly link?: GateLink
readonly choices?: readonly RegistryStoreDto[]
}
import type { GateLink, GatePresentation } from '../state/GatePresentation'
/**
* The screen shown instead of the grid when there is nothing to drive: no Python, no
@@ -50,10 +32,6 @@ export class GateView {
setHidden(this.section, true)
}
public get visible (): boolean {
return !this.section.hidden
}
/** Only shown when the registry offers more than one store; with a single one there is nothing to decide. */
private renderChoices (choices: readonly RegistryStoreDto[], messages: MessageBundle): void {
setHidden(this.choice, choices.length < 2)
+25 -6
View File
@@ -1,4 +1,4 @@
import { createElement, requireElement, setText } from '../dom/Dom'
import { createElement, requireElement, setHidden, setText } from '../dom/Dom'
import type { AppState } from '../state/AppStore'
const MAX_LOG_LINES = 400
@@ -7,17 +7,23 @@ export interface LogDrawerViewCallbacks {
readonly onOpenFolder: (directory: string) => void
}
/** The store's own output, verbatim, and the folders everything lands in. */
/**
* The store's own output, verbatim, and the folders everything lands in.
*
* Off screen until asked for. The window used to carry a footer with a toggle and a
* line of absolute paths at all times; both were clutter next to the one thing the
* window is for, which is the titles. The switch lives in the side menu with the rest
* of what is not a title, and the panel appears above the grid only while it is on.
*/
export class LogDrawerView {
private readonly toggle = requireElement('log-toggle', HTMLButtonElement)
private readonly panel = requireElement('log-panel', HTMLElement)
private readonly lines = requireElement('log-lines', HTMLElement)
private readonly pathsBox = requireElement('log-paths', HTMLElement)
private open = false
public constructor (private readonly callbacks: LogDrawerViewCallbacks) {
this.toggle.addEventListener('click', (): void => {
this.lines.hidden = !this.lines.hidden
this.toggle.setAttribute('aria-expanded', String(!this.lines.hidden))
})
this.toggle.addEventListener('click', (): void => { this.setOpen(!this.open) })
}
public render (state: AppState): void {
@@ -40,6 +46,19 @@ export class LogDrawerView {
}
}
private setOpen (open: boolean): void {
this.open = open
setHidden(this.panel, !open)
this.toggle.setAttribute('aria-expanded', String(open))
this.toggle.classList.toggle('is-active', open)
if (open) this.lines.scrollTop = this.lines.scrollHeight
}
/**
* A line arriving while the panel is shut does not open it: the store logs on every
* refresh, and a window that unfolded a panel by itself would be worse than one that
* kept quiet. The lines are kept, so opening it later shows what happened.
*/
public appendLine (line: string): void {
this.lines.appendChild(createElement('div', 'log-line', line))
while (this.lines.childElementCount > MAX_LOG_LINES) {
-5
View File
@@ -9,7 +9,6 @@ import type { AppState } from '../state/AppStore'
export interface SideMenuViewCallbacks {
readonly onSelectStore: (home: string) => void
readonly onAddStore: () => void
readonly onSyncAll: () => void
readonly onRefresh: () => void
readonly onSelectCategory: (filter: CategoryFilter) => void
readonly onSelectLocale: (locale: string) => void
@@ -27,14 +26,12 @@ export class SideMenuView {
private readonly languageHead = requireElement('head-lang', HTMLElement)
private readonly storeList = requireElement('store-list', HTMLElement)
private readonly addStore = requireElement('add-store', HTMLButtonElement)
private readonly syncAll = requireElement('sync-all', HTMLButtonElement)
private readonly refresh = requireElement('refresh', HTMLButtonElement)
private readonly categories = requireElement('cats', HTMLElement)
private readonly locale = requireElement('locale', HTMLSelectElement)
public constructor (private readonly callbacks: SideMenuViewCallbacks) {
this.addStore.addEventListener('click', callbacks.onAddStore)
this.syncAll.addEventListener('click', callbacks.onSyncAll)
this.refresh.addEventListener('click', callbacks.onRefresh)
this.locale.addEventListener('change', (): void => { callbacks.onSelectLocale(this.locale.value) })
}
@@ -45,7 +42,6 @@ export class SideMenuView {
setText(this.categoriesHead, state.messages.categories)
setText(this.languageHead, state.messages.language)
setText(this.addStore, state.messages.addStore)
setText(this.syncAll, state.messages.syncAll)
setText(this.refresh, state.messages.refresh)
this.renderStores(state)
@@ -116,7 +112,6 @@ export class SideMenuView {
*/
private renderEnabled (state: AppState): void {
const hasStore = state.currentStore !== null
this.syncAll.disabled = state.busy || !hasStore
this.refresh.disabled = state.busy || !hasStore
this.addStore.disabled = state.busy
for (const row of this.storeList.querySelectorAll('button')) row.disabled = state.busy
+2
View File
@@ -18,6 +18,8 @@ export class TopBarView {
public render (state: AppState): void {
setText(this.appName, state.messages.appName)
// The badge is a bordered pill: empty, it renders as a stub next to the title.
setHidden(this.storeId, state.currentStore === null)
setText(this.storeId, state.currentStore === null ? '' : state.currentStore.id)
this.navToggle.title = state.messages.menu
this.navToggle.setAttribute('aria-label', state.messages.menu)
+7 -3
View File
@@ -40,7 +40,7 @@ class SmokeTest {
private readonly gameMapper = new GameDtoMapper()
public async run (): Promise<number> {
console.log('warp-engine-desktop-gui smoke test')
console.log('warp-engine-client smoke test')
if (!this.checkPython()) return 1
this.checkMessages()
@@ -89,10 +89,14 @@ class SmokeTest {
}
/**
* A store repository without a config.json still installs — the engine merges what
* it is given onto its defaults — so an absent file is reported, not failed.
* A store needs no repository, and a repository needs no config.json: either way
* the engine's defaults carry it. So both absences are reported, not failed.
*/
private async checkStoreConfig (store: RegistryStore): Promise<void> {
if (store.storeRepositoryUrl === null) {
this.reportOk(' config', 'no repository — the engine defaults would be used')
return
}
const url = `${store.storeRepositoryUrl.replace(/\/+$/, '')}/raw/branch/master/config.json`
try {
const config: unknown = JSON.parse(await this.httpClient.readText(url))
+3 -2
View File
@@ -2,7 +2,8 @@
export interface RegistryStoreDto {
readonly name: string
readonly catalogUrl: string
readonly storeRepositoryUrl: string
/** Derived from the repository name, so the picker can show what it will become. */
/** Null when the store has no repository of its own; the engine's defaults are then used. */
readonly storeRepositoryUrl: string | null
/** Derived from the repository, the catalog host or the name — what the store will be called on disk. */
readonly storeId: string
}
-1
View File
@@ -6,7 +6,6 @@
*/
export const ENGLISH_MESSAGES = {
appName: 'WarpEngine Store',
syncAll: 'Install all',
refresh: 'Refresh',
install: 'Install',
update: 'Update',
-1
View File
@@ -6,7 +6,6 @@ import type { MessageBundle } from './MessageBundle'
*/
export const HUNGARIAN_MESSAGES: MessageBundle = {
appName: 'WarpEngine Store',
syncAll: 'Mind telepítése',
refresh: 'Frissítés',
install: 'Telepítés',
update: 'Frissítés',