From 526c67b06941f10e9ac53522573fa902fd50816e Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Tue, 18 Aug 2026 16:52:59 +0200 Subject: [PATCH] Registry-only stores, a quieter window, and a CI that builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .woodpecker.yaml | 69 +++++++++++ README.md | 112 ++++++++++++++---- RELEASE_NOTES.md | 108 ++++++++++------- STRUCTURE.md | 5 + package-lock.json | 4 +- package.json | 6 +- scripts/ci-upload.sh | 73 ++++++++++++ scripts/ci-verify-packages.sh | 35 ++++++ src/domain/models/RegistryStore.ts | 9 +- src/domain/models/StoreIdentity.ts | 40 +++++-- src/infrastructure/http/HttpTextClient.ts | 2 +- .../repositories/HttpStoreEngineInstaller.ts | 69 ++++++++--- .../HttpStoreRegistryRepository.ts | 23 ++-- src/main/diagnostics/SelfTestRunner.ts | 5 +- src/main/ipc/IpcArguments.ts | 8 +- src/renderer/RendererApplication.ts | 17 ++- src/renderer/controllers/StoreController.ts | 26 ++-- src/renderer/index.html | 10 +- src/renderer/state/AppStore.ts | 11 +- src/renderer/state/GatePresentation.ts | 27 +++++ src/renderer/style.css | 27 ++++- src/renderer/views/GateView.ts | 24 +--- src/renderer/views/LogDrawerView.ts | 31 ++++- src/renderer/views/SideMenuView.ts | 5 - src/renderer/views/TopBarView.ts | 2 + src/scripts/SmokeTest.ts | 10 +- src/shared/contracts/dto/RegistryStoreDto.ts | 5 +- src/shared/i18n/EnglishMessages.ts | 1 - src/shared/i18n/HungarianMessages.ts | 1 - 29 files changed, 581 insertions(+), 184 deletions(-) create mode 100644 .woodpecker.yaml create mode 100755 scripts/ci-upload.sh create mode 100755 scripts/ci-verify-packages.sh create mode 100644 src/renderer/state/GatePresentation.ts diff --git a/.woodpecker.yaml b/.woodpecker.yaml new file mode 100644 index 0000000..b324ff0 --- /dev/null +++ b/.woodpecker.yaml @@ -0,0 +1,69 @@ +# 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: + 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 diff --git a/README.md b/README.md index 8324806..1a801d9 100644 --- a/README.md +++ b/README.md @@ -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,6 +179,45 @@ 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: attaches what this pipeline built | + +**macOS stays a local build.** Apple's toolchain and its signing only exist on a Mac, +so a full release is `make release` here for the macOS package plus this pipeline for +the other two. The window test is local too: it needs a display and a store on the +machine. + +The `release` step needs a **`gitea_token`** repository secret in Woodpecker, with +write access to this repository — CI has a token where a workstation has a `tea` +login, which is why `scripts/ci-upload.sh` exists alongside `scripts/release.sh` +instead of one script with two ways to authenticate. + +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 @@ -258,6 +309,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 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 73b9b65..1eaa8aa 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,42 +1,68 @@ -# 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 + +`.woodpecker.yaml` builds the AppImage, the deb, the NSIS installer and the portable +exe — Windows through Wine — and on a tag attaches them to this release. macOS stays a +local build, because Apple's toolchain and its signing exist only on a Mac, so a full +release is one local `make release` plus the pipeline. The Windows installer is not +signed: Windows will warn about an unknown publisher until there is a certificate. ### Opening it on macOS @@ -46,18 +72,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. diff --git a/STRUCTURE.md b/STRUCTURE.md index 3df3e39..3074bea 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 482aad8..1e5a04f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index f1db9d5..15cc99a 100644 --- a/package.json +++ b/package.json @@ -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 ", - "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" diff --git a/scripts/ci-upload.sh b/scripts/ci-upload.sh new file mode 100755 index 0000000..f81f285 --- /dev/null +++ b/scripts/ci-upload.sh @@ -0,0 +1,73 @@ +#!/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. +# +# GITEA_TOKEN=… scripts/ci-upload.sh every package in dist/ +# GITEA_TOKEN=… scripts/ci-upload.sh dist/one.deb just these +# +# Assumes the release for the tag exists. It does: the tag is cut on the machine that +# builds and publishes the macOS package, and that is what creates the release. +set -eu + +FORGE="${FORGE_API:-https://git.teletypegames.org/api/v1}" +REPO="${REPO:-${CI_REPO:-}}" +TAG="${TAG:-${CI_COMMIT_TAG:-}}" +DIST="${DIST:-dist}" + +say() { echo "[ci-upload] $*"; } +die() { echo "[ci-upload] error: $*" >&2; exit 1; } + +[ -n "${GITEA_TOKEN:-}" ] || die "GITEA_TOKEN is not set — add the gitea_token secret to the repository" +[ -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" + +api() { + method="$1"; path="$2"; shift 2 + curl -fsS -X "$method" -H "Authorization: token $GITEA_TOKEN" "$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" +release_id="$(api GET "/repos/$REPO/releases/tags/$TAG" | jq -r '.id // empty')" +[ -n "$release_id" ] || die "no release for $TAG — cut the release first, then re-run this build" + +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"' diff --git a/scripts/ci-verify-packages.sh b/scripts/ci-verify-packages.sh new file mode 100755 index 0000000..21eceb8 --- /dev/null +++ b/scripts/ci-verify-packages.sh @@ -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 diff --git a/src/domain/models/RegistryStore.ts b/src/domain/models/RegistryStore.ts index e00b19d..924400b 100644 --- a/src/domain/models/RegistryStore.ts +++ b/src/domain/models/RegistryStore.ts @@ -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 } diff --git a/src/domain/models/StoreIdentity.ts b/src/domain/models/StoreIdentity.ts index 076364b..b548c3c 100644 --- a/src/domain/models/StoreIdentity.ts +++ b/src/domain/models/StoreIdentity.ts @@ -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, '') } diff --git a/src/infrastructure/http/HttpTextClient.ts b/src/infrastructure/http/HttpTextClient.ts index e388c8e..16193bd 100644 --- a/src/infrastructure/http/HttpTextClient.ts +++ b/src/infrastructure/http/HttpTextClient.ts @@ -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 { diff --git a/src/infrastructure/repositories/HttpStoreEngineInstaller.ts b/src/infrastructure/repositories/HttpStoreEngineInstaller.ts index d227734..0539e3a 100644 --- a/src/infrastructure/repositories/HttpStoreEngineInstaller.ts +++ b/src/infrastructure/repositories/HttpStoreEngineInstaller.ts @@ -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> { const storeId = deriveStoreId(store) - let config: Record - 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> { + 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 { + 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}` } diff --git a/src/infrastructure/repositories/HttpStoreRegistryRepository.ts b/src/infrastructure/repositories/HttpStoreRegistryRepository.ts index 23d96fe..9c3cb52 100644 --- a/src/infrastructure/repositories/HttpStoreRegistryRepository.ts +++ b/src/infrastructure/repositories/HttpStoreRegistryRepository.ts @@ -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) } } diff --git a/src/main/diagnostics/SelfTestRunner.ts b/src/main/diagnostics/SelfTestRunner.ts index 24e59d5..da8fce0 100644 --- a/src/main/diagnostics/SelfTestRunner.ts +++ b/src/main/diagnostics/SelfTestRunner.ts @@ -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) diff --git a/src/main/ipc/IpcArguments.ts b/src/main/ipc/IpcArguments.ts index 3302ce5..466e861 100644 --- a/src/main/ipc/IpcArguments.ts +++ b/src/main/ipc/IpcArguments.ts @@ -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 } diff --git a/src/renderer/RendererApplication.ts b/src/renderer/RendererApplication.ts index fff5a46..54b6bac 100644 --- a/src/renderer/RendererApplication.ts +++ b/src/renderer/RendererApplication.ts @@ -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() + } } } diff --git a/src/renderer/controllers/StoreController.ts b/src/renderer/controllers/StoreController.ts index 92f246c..b24e8f2 100644 --- a/src/renderer/controllers/StoreController.ts +++ b/src/renderer/controllers/StoreController.ts @@ -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 { @@ -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) { diff --git a/src/renderer/index.html b/src/renderer/index.html index 5e06a68..acf0f7d 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -36,7 +36,6 @@

-
@@ -46,6 +45,7 @@
+
diff --git a/src/renderer/state/AppStore.ts b/src/renderer/state/AppStore.ts index 24ff04b..9f9b48a 100644 --- a/src/renderer/state/AppStore.ts +++ b/src/renderer/state/AppStore.ts @@ -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() diff --git a/src/renderer/state/GatePresentation.ts b/src/renderer/state/GatePresentation.ts new file mode 100644 index 0000000..c25d42e --- /dev/null +++ b/src/renderer/state/GatePresentation.ts @@ -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[] +} diff --git a/src/renderer/style.css b/src/renderer/style.css index d9768cd..b22c8d4 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -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; diff --git a/src/renderer/views/GateView.ts b/src/renderer/views/GateView.ts index b2ee8b3..4a86928 100644 --- a/src/renderer/views/GateView.ts +++ b/src/renderer/views/GateView.ts @@ -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) diff --git a/src/renderer/views/LogDrawerView.ts b/src/renderer/views/LogDrawerView.ts index 3ea75ad..3cd4db2 100644 --- a/src/renderer/views/LogDrawerView.ts +++ b/src/renderer/views/LogDrawerView.ts @@ -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) { diff --git a/src/renderer/views/SideMenuView.ts b/src/renderer/views/SideMenuView.ts index cb0ced8..ba1a37e 100644 --- a/src/renderer/views/SideMenuView.ts +++ b/src/renderer/views/SideMenuView.ts @@ -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 diff --git a/src/renderer/views/TopBarView.ts b/src/renderer/views/TopBarView.ts index f7d9dc5..fef23e7 100644 --- a/src/renderer/views/TopBarView.ts +++ b/src/renderer/views/TopBarView.ts @@ -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) diff --git a/src/scripts/SmokeTest.ts b/src/scripts/SmokeTest.ts index 21cbef8..29fbe57 100644 --- a/src/scripts/SmokeTest.ts +++ b/src/scripts/SmokeTest.ts @@ -40,7 +40,7 @@ class SmokeTest { private readonly gameMapper = new GameDtoMapper() public async run (): Promise { - 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 { + 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)) diff --git a/src/shared/contracts/dto/RegistryStoreDto.ts b/src/shared/contracts/dto/RegistryStoreDto.ts index 4887535..febb811 100644 --- a/src/shared/contracts/dto/RegistryStoreDto.ts +++ b/src/shared/contracts/dto/RegistryStoreDto.ts @@ -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 } diff --git a/src/shared/i18n/EnglishMessages.ts b/src/shared/i18n/EnglishMessages.ts index abb292a..d1bb43d 100644 --- a/src/shared/i18n/EnglishMessages.ts +++ b/src/shared/i18n/EnglishMessages.ts @@ -6,7 +6,6 @@ */ export const ENGLISH_MESSAGES = { appName: 'WarpEngine Store', - syncAll: 'Install all', refresh: 'Refresh', install: 'Install', update: 'Update', diff --git a/src/shared/i18n/HungarianMessages.ts b/src/shared/i18n/HungarianMessages.ts index be90ac5..08d9cce 100644 --- a/src/shared/i18n/HungarianMessages.ts +++ b/src/shared/i18n/HungarianMessages.ts @@ -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',