Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd5361e222 | ||
|
|
0f5da38a27 | ||
|
|
cb8a28b156 | ||
|
|
85c6d33b05 | ||
|
|
e635a032ea |
@@ -0,0 +1,90 @@
|
|||||||
|
# WarpEngine Store GUI — the front door to the npm scripts.
|
||||||
|
#
|
||||||
|
# Everything here is a thin wrapper: the app is an Electron project, so npm still
|
||||||
|
# does the work. The Makefile exists so the useful sequences have names, and so
|
||||||
|
# `make release` is one command rather than a build followed by remembered tea
|
||||||
|
# invocations.
|
||||||
|
#
|
||||||
|
# make list the targets
|
||||||
|
# make setup install the dependencies
|
||||||
|
# make dist package for this machine
|
||||||
|
# make release package and publish to Gitea
|
||||||
|
#
|
||||||
|
# Publishing assumes `tea` is installed and logged in — the devarea repo has
|
||||||
|
# `make tea` for that.
|
||||||
|
|
||||||
|
SHELL := /bin/sh
|
||||||
|
SCRIPTS := scripts
|
||||||
|
NODE_MIN := 22
|
||||||
|
|
||||||
|
# The version is package.json's, so the release tag never drifts from the app.
|
||||||
|
VERSION := $(shell python3 -c 'import json; print(json.load(open("package.json"))["version"])')
|
||||||
|
TAG ?= v$(VERSION)
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
.PHONY: help setup node-check start smoke uitest test dist dist-mac dist-win dist-linux \
|
||||||
|
release publish clean distclean version
|
||||||
|
|
||||||
|
help: ## List available targets
|
||||||
|
@echo "WarpEngine Store GUI $(VERSION) — usage: make <target>"
|
||||||
|
@echo
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \
|
||||||
|
awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
|
||||||
|
@echo
|
||||||
|
@echo " Variables: TAG=$(TAG) TEA_LOGIN=ttg REPO=<owner/name> NOTES=RELEASE_NOTES.md"
|
||||||
|
|
||||||
|
node-check: ## Check the Node version Electron's installer needs
|
||||||
|
@node -e 'const [maj] = process.versions.node.split("."); \
|
||||||
|
if (Number(maj) < $(NODE_MIN)) { \
|
||||||
|
console.error("Node $(NODE_MIN)+ is needed to install Electron (found " + process.versions.node + \
|
||||||
|
"): its installer is ESM-only. The packaged app carries its own runtime."); \
|
||||||
|
process.exit(1); \
|
||||||
|
} else { console.log("node " + process.versions.node + " ok"); }'
|
||||||
|
|
||||||
|
setup: node-check ## Install the dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
start: ## Run the app against whatever store is installed
|
||||||
|
npm start
|
||||||
|
|
||||||
|
smoke: ## Drive the store bridge with no window at all
|
||||||
|
npm run smoke
|
||||||
|
|
||||||
|
uitest: ## Load the window once and report what rendered
|
||||||
|
npm run uitest
|
||||||
|
|
||||||
|
test: smoke uitest ## Both checks
|
||||||
|
|
||||||
|
dist: node-check ## Package for this machine
|
||||||
|
npm run dist
|
||||||
|
|
||||||
|
dist-mac: node-check ## Package for macOS (ad-hoc signed, see the README)
|
||||||
|
npm run dist:mac
|
||||||
|
|
||||||
|
dist-win: node-check ## Package for Windows
|
||||||
|
npm run dist:win
|
||||||
|
|
||||||
|
dist-linux: node-check ## Package for Linux
|
||||||
|
npm run dist:linux
|
||||||
|
|
||||||
|
publish: ## Upload the packages already in dist/ to the Gitea release
|
||||||
|
@TAG=$(TAG) $(SCRIPTS)/release.sh
|
||||||
|
|
||||||
|
# clean first: dist/ keeps earlier builds, and a release should be made of exactly
|
||||||
|
# what this version produced.
|
||||||
|
release: clean dist publish ## Package for this machine and publish it
|
||||||
|
|
||||||
|
clean: ## Remove the built packages
|
||||||
|
rm -rf dist
|
||||||
|
|
||||||
|
distclean: clean ## Remove the packages and the dependencies
|
||||||
|
rm -rf node_modules
|
||||||
|
|
||||||
|
version: ## Show the versions involved
|
||||||
|
@echo "app $(VERSION) (tag $(TAG))"
|
||||||
|
@printf "node "; node --version 2>/dev/null || echo "missing"
|
||||||
|
@printf "npm "; npm --version 2>/dev/null || echo "missing"
|
||||||
|
@printf "electron "; node -p "require('./package.json').devDependencies.electron" 2>/dev/null || echo "missing"
|
||||||
|
@printf "tea "; tea --version 2>/dev/null | head -1 || echo "missing — devarea: make tea"
|
||||||
|
@printf "python3 "; python3 --version 2>/dev/null || echo "missing"
|
||||||
@@ -13,6 +13,10 @@ It is also **the Windows install path**. The store's own installer is
|
|||||||
`curl … | sh`, which Windows does not have; this app downloads the store engine
|
`curl … | sh`, which Windows does not have; this app downloads the store engine
|
||||||
itself, into the same folder the shell installer would use.
|
itself, into the same folder the shell installer would use.
|
||||||
|
|
||||||
|
Which store it installs is not baked in: the client asks a registry — `GET
|
||||||
|
/api/stores` on the site — and each record says what the store is called, which
|
||||||
|
catalog it serves and where its configuration lives.
|
||||||
|
|
||||||
## What it needs
|
## What it needs
|
||||||
|
|
||||||
- **Python 3** on the machine, because the store is a Python program. The app
|
- **Python 3** on the machine, because the store is a Python program. The app
|
||||||
@@ -27,40 +31,165 @@ Grab the package for your machine from the
|
|||||||
and open it. On first run, if there is no store on the machine yet, the window
|
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.
|
offers to download one — that is the whole setup.
|
||||||
|
|
||||||
The macOS build is **not signed or notarised**, so the first open needs
|
### Opening it on macOS
|
||||||
*right-click ▸ Open* (or *System Settings ▸ Privacy & Security*). Nothing the
|
|
||||||
store itself downloads is affected: those files are fetched by Python, which does
|
The build is ad-hoc signed but **not notarised**, so macOS asks before running a
|
||||||
not set the quarantine flag.
|
copy that came from a browser. The reliable way through:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
xattr -dr com.apple.quarantine "/Applications/WarpEngine Store.app"
|
||||||
|
```
|
||||||
|
|
||||||
|
If macOS offers *Open Anyway* under **System Settings ▸ Privacy & Security** after
|
||||||
|
a blocked attempt, that works as well. Notarisation is the only thing that removes
|
||||||
|
the step entirely, and it needs a paid Apple Developer ID.
|
||||||
|
|
||||||
|
Nothing the store itself downloads is affected: Python fetches those files, and
|
||||||
|
Python does not set the quarantine flag.
|
||||||
|
|
||||||
|
**v1.0.0 could not be opened at all** — it reported *"is damaged"*. The bundle had
|
||||||
|
never been signed; only its main executable carried the linker's ad-hoc signature,
|
||||||
|
so there was no resource seal and Gatekeeper refused it outright rather than
|
||||||
|
asking. `scripts/after-pack.js` signs the bundle during the build now, and the
|
||||||
|
result verifies as `valid on disk`.
|
||||||
|
|
||||||
|
## Which store it installs
|
||||||
|
|
||||||
|
On first run the client fetches the registry and offers what it finds. One store
|
||||||
|
and there is nothing to decide; several and the setup screen shows a picker.
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Teletype Games",
|
||||||
|
"catalogUrl": "https://teletypegames.org",
|
||||||
|
"storeRepositoryUrl": "https://git.teletypegames.org/stores/ttg-desktop-store"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
- **`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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The registry address is the single thing about a particular site left in the
|
||||||
|
client, and `STORES_API` overrides it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
STORES_API=http://127.0.0.1:8731/stores npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Adding a store is therefore a database row on the site — see its ActiveAdmin
|
||||||
|
panel — and not a release of this app.
|
||||||
|
|
||||||
## Use
|
## Use
|
||||||
|
|
||||||
- **Install all** fetches everything the catalog offers for this machine.
|
Everything that is not a title lives in the **side menu** on the left, and the
|
||||||
- A card's button is **Install**, **Update**, or **Play** / **Open** once it is
|
`☰` button in the bar folds it away — the state is remembered between runs.
|
||||||
there. **Remove** takes a title back out.
|
|
||||||
- Each card says whether it is **native** — unpacked and run locally, works
|
- **Stores** lists every store on this machine, the open one marked. Clicking
|
||||||
offline — or **hosted**: a browser build the catalog serves rather than
|
another switches to it: the grid, the categories and the folders all follow, and
|
||||||
packages, so its entry opens a page and needs the network.
|
the client reopens on that store next time. Two stores installed from the same
|
||||||
- The **Log** drawer at the bottom carries the store's own output verbatim, and
|
catalog into different folders show their folder instead of their id, because
|
||||||
next to it are buttons that open the two folders everything lands in.
|
the id would not tell them apart. **Add a store…** brings up the registry
|
||||||
- The language follows the system and can be switched; **English and Hungarian**.
|
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.
|
||||||
|
- **Categories** narrows the grid, one category at a time, with the count next to
|
||||||
|
each: *Everything*, *Installed*, *Updates*, *Not installed*, then a row per
|
||||||
|
**platform** (`godot`, `tic80`, `love`, …) and per **kind** (native or hosted).
|
||||||
|
The axes are built from what the catalog actually contains — a platform with no
|
||||||
|
titles is not listed, and a category that disappears under you falls back to
|
||||||
|
*Everything* rather than leaving an empty grid. There is no genre in a
|
||||||
|
WarpEngine catalog, so these are the categories there are.
|
||||||
|
- **Language** follows the system and can be switched; **English and Hungarian**.
|
||||||
|
|
||||||
|
In the grid, a card's button is **Install**, **Update**, or **Play** / **Open**
|
||||||
|
once it is there. **Remove** takes a title back out. Each card says whether it is
|
||||||
|
**native** — unpacked and run locally, works offline — or **hosted**: a browser
|
||||||
|
build the catalog serves rather than packages, so its entry opens a page and needs
|
||||||
|
the network.
|
||||||
|
|
||||||
|
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
|
||||||
|
window's height evenly instead of following their content, which collapsed the art
|
||||||
|
to nothing and clipped the buttons out of sight.
|
||||||
|
|
||||||
|
While the store is working, only the things that would start a second call are
|
||||||
|
disabled: the menu, the log drawer and the category filters keep working, because
|
||||||
|
they change what is on screen and nothing on disk.
|
||||||
|
|
||||||
Anything installed from the window is a normal menu entry, so it also shows up in
|
Anything installed from the window is a normal menu entry, so it also shows up in
|
||||||
your launcher, Dock or Start menu — the app does not have to be running to play.
|
your launcher, Dock or Start menu — the app does not have to be running to play.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
`make` is the front door; it wraps the npm scripts so the useful sequences have
|
||||||
|
names. `make` on its own lists everything.
|
||||||
|
|
||||||
|
| Target | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `make setup` | install the dependencies (checks the Node version first) |
|
||||||
|
| `make start` | run the app against whatever store is installed |
|
||||||
|
| `make smoke` | drive the store bridge with no window at all |
|
||||||
|
| `make uitest` | load the window once and report what rendered |
|
||||||
|
| `SELFTEST_SHOT=shot.png npm run uitest` | the same, and the window photographs itself into that file |
|
||||||
|
| `make test` | both checks |
|
||||||
|
| `make dist` | package for this machine (`dist-mac`, `dist-win`, `dist-linux` to pick) |
|
||||||
|
| `make publish` | upload the packages already in `dist/` to the Gitea release |
|
||||||
|
| `make release` | **package and publish in one go** |
|
||||||
|
| `make clean` | remove the built packages (`distclean` also drops `node_modules`) |
|
||||||
|
| `make version` | the versions involved, including whether `tea` is there |
|
||||||
|
|
||||||
|
The npm scripts still work directly (`npm start`, `npm run dist:mac`) — the
|
||||||
|
Makefile adds no logic of its own beyond the release step.
|
||||||
|
|
||||||
|
### Publishing a release
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install
|
make release
|
||||||
npm start # the window, against whatever store is installed
|
```
|
||||||
npm run smoke # the bridge only: no window, no Electron
|
|
||||||
npm run uitest # loads the window once and reports what rendered
|
The tag comes from `package.json`, so `npm version patch` is the only place a
|
||||||
npm run dist:mac # or dist:win / dist:linux
|
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.
|
||||||
|
|
||||||
|
Release notes come from `RELEASE_NOTES.md` when the file is present, otherwise the
|
||||||
|
release gets a one-line note. The repository is read from `origin`, so a fork
|
||||||
|
publishes to the fork.
|
||||||
|
|
||||||
|
It needs `tea` installed and logged in — the devarea repo has `make tea` for that.
|
||||||
|
Overridable: `TAG`, `REPO`, `TEA_LOGIN`, `NOTES`, `DIST`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make publish TAG=v1.0.2 # a tag other than package.json's
|
||||||
|
scripts/release.sh dist/one-file.dmg # just one package
|
||||||
```
|
```
|
||||||
|
|
||||||
**Node 22 or newer is needed to install**, not to run: Electron's own installer
|
**Node 22 or newer is needed to install**, not to run: Electron's own installer
|
||||||
is ESM-only, and older Node cannot `require()` it. The packaged app carries its
|
is ESM-only, and older Node cannot `require()` it. The packaged app carries its
|
||||||
own runtime.
|
own runtime.
|
||||||
|
|
||||||
|
`npm run uitest` runs with its own user-data directory and without the
|
||||||
|
single-instance lock. Otherwise a copy the user already has open swallows the test
|
||||||
|
process, which exits 0 and reads as a pass.
|
||||||
|
|
||||||
Both test scripts accept a sandbox store instead of the real one, which is how
|
Both test scripts accept a sandbox store instead of the real one, which is how
|
||||||
this repository is tested without touching a working installation:
|
this repository is tested without touching a working installation:
|
||||||
|
|
||||||
@@ -76,9 +205,12 @@ SMOKE_HOME=/tmp/sandbox-root/ttg-desktop npm run smoke
|
|||||||
| `main.js` | the window, the IPC, and the one-call-at-a-time guard |
|
| `main.js` | the window, the IPC, and the one-call-at-a-time guard |
|
||||||
| `preload.js` | the entire surface the renderer gets — no Node reaches it |
|
| `preload.js` | the entire surface the renderer gets — no Node reaches it |
|
||||||
| `lib/store.js` | finds the store and Python, runs the CLI, parses its JSON |
|
| `lib/store.js` | finds the store and Python, runs the CLI, parses its JSON |
|
||||||
| `lib/bootstrap.js` | downloads the engine, the shared core and a config |
|
| `lib/bootstrap.js` | reads the registry, then downloads the engine, the core and a config |
|
||||||
| `lib/i18n.js` | the two string tables |
|
| `lib/i18n.js` | the two string tables |
|
||||||
| `renderer/` | plain HTML, CSS and JS — no framework, no build step |
|
| `renderer/` | plain HTML, CSS and JS — no framework, no build step |
|
||||||
|
| `Makefile` | the named sequences; no logic of its own beyond the release |
|
||||||
|
| `scripts/release.sh` | creates the Gitea release and replaces its attachments |
|
||||||
|
| `scripts/after-pack.js` | ad-hoc signs the macOS bundle during packaging |
|
||||||
|
|
||||||
`contextIsolation` is on, `nodeIntegration` off, `sandbox` on, and the page
|
`contextIsolation` is on, `nodeIntegration` off, `sandbox` on, and the page
|
||||||
carries a CSP that allows only its own script and stylesheet plus images over
|
carries a CSP that allows only its own script and stylesheet plus images over
|
||||||
@@ -95,10 +227,35 @@ too — that is why the indirection is there.
|
|||||||
|
|
||||||
## Verified, and not
|
## Verified, and not
|
||||||
|
|
||||||
Exercised on macOS (arm64): the store is discovered, the catalog lists, a sync
|
Exercised on macOS (arm64), with the packaged app from the release rather than a
|
||||||
installs, the window renders the installed state, and `npm run uitest` passes with
|
dev run: the store is discovered, the catalog lists, a sync installs, the window
|
||||||
the grid rendered and both languages in the picker. The bootstrap download was run
|
renders the installed state, and `npm run uitest` passes with the grid rendered
|
||||||
into an empty directory and the resulting store answered the bridge.
|
and both languages in the picker. The bootstrap download was run into an empty
|
||||||
|
directory and the resulting store answered the bridge.
|
||||||
|
|
||||||
|
The grid was checked by looking at it, not only by counting nodes: `SELFTEST_SHOT`
|
||||||
|
has the window capture itself, which is how the collapsed rows were found — the DOM
|
||||||
|
had ten cards and twenty buttons all along, and every count passed while the page
|
||||||
|
showed neither art nor buttons. A screenshot from outside the app is not available
|
||||||
|
here, so the window takes its own.
|
||||||
|
|
||||||
|
The side menu was measured with two stores in one root — a sandbox copy alongside
|
||||||
|
the real install — and `npm run uitest` clicks the store that is not open and
|
||||||
|
checks that the bar, the grid and the categories follow. With a single store the
|
||||||
|
switch is skipped, which is what the normal run reports.
|
||||||
|
|
||||||
|
The registry path was exercised against a local endpoint serving the same payload
|
||||||
|
the site returns, with two records: one store whose repository has a `config.json`
|
||||||
|
and one without. Both installed, and the engine listed all ten titles with the
|
||||||
|
synthesised config. `npm run uitest` was run twice — with a store present it shows
|
||||||
|
the grid, with none it shows the setup gate and its picker carries both names —
|
||||||
|
and once more with the registry unreachable, which produces the retry gate.
|
||||||
|
|
||||||
|
The signing was measured rather than assumed, by setting the quarantine flag on a
|
||||||
|
copy unzipped from the release artifact: `codesign --verify --deep --strict` is
|
||||||
|
clean, and `syspolicy_check` reports only the expected *"adhoc signed"* warning.
|
||||||
|
A quarantined copy is still stopped until it is approved — that part is Gatekeeper
|
||||||
|
policy, not a fault in the package.
|
||||||
|
|
||||||
**Not tried on Linux or Windows.** The paths and the launch behaviour are written
|
**Not tried on Linux or Windows.** The paths and the launch behaviour are written
|
||||||
for them, and the store CLI itself has the same gap — `.desktop` and `.lnk`
|
for them, and the store CLI itself has the same gap — `.desktop` and `.lnk`
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# WarpEngine Store 1.2.0
|
||||||
|
|
||||||
|
**A side menu.** Everything that is not a title moved out of the bar into a menu on
|
||||||
|
the left that folds away with `☰`: the stores on this machine, the two actions, the
|
||||||
|
categories and the language. Open or closed is remembered between runs.
|
||||||
|
|
||||||
|
**Categories.** The grid narrows to *Installed*, *Updates* or *Not installed*, to a
|
||||||
|
platform (`godot`, `tic80`, `love`, …), or to native/hosted titles — one at a time,
|
||||||
|
each with its count. The axes are built from what the catalog actually contains, so
|
||||||
|
nothing empty is listed, and a category that disappears under you falls back to
|
||||||
|
*Everything* rather than leaving a blank grid.
|
||||||
|
|
||||||
|
**Switching stores.** With more than one store installed, clicking another in the
|
||||||
|
menu opens it: the grid, the categories and the folders follow, and the client
|
||||||
|
reopens on the store last used. Two stores installed from the same catalog into
|
||||||
|
different folders are told apart by their folder, since their id is identical.
|
||||||
|
|
||||||
|
While the store is working, the menu, the log drawer and the filters keep working —
|
||||||
|
only what would start a second call is disabled.
|
||||||
|
|
||||||
|
**The grid was broken, and now is not.** Its rows split the window's height evenly
|
||||||
|
rather than following their content, so every card came out 94px tall: the box art
|
||||||
|
collapsed to nothing and the action buttons were clipped away below the fold. The
|
||||||
|
DOM was intact the whole time — ten cards, twenty buttons — which is why every
|
||||||
|
automated count passed. Cards now carry a band of art of one height, with the
|
||||||
|
title's first letter where the catalog has no image.
|
||||||
|
|
||||||
|
Unchanged from 1.1.0: which stores exist is the site's answer (`GET /api/stores`),
|
||||||
|
not something baked into this app, and `STORES_API` overrides that address.
|
||||||
|
|
||||||
|
### Opening it on macOS
|
||||||
|
|
||||||
|
Ad-hoc signed, **not notarised**, so macOS asks first:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
xattr -dr com.apple.quarantine "/Applications/WarpEngine Store.app"
|
||||||
|
```
|
||||||
|
|
||||||
|
*Open Anyway* under **System Settings ▸ Privacy & Security** works as well.
|
||||||
|
Nothing the store itself downloads is affected — Python fetches those, and Python
|
||||||
|
does not set the quarantine flag.
|
||||||
|
|
||||||
|
### 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` / `dist-linux`).
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
|
||||||
|
`SELFTEST_SHOT=shot.png npm run uitest` has the window photograph itself, which is
|
||||||
|
how the collapsed rows were found and how the fix was confirmed — in English and in
|
||||||
|
Hungarian, with the menu open and closed.
|
||||||
|
|
||||||
|
`npm run uitest` loads the window and reports what rendered; with two stores in one
|
||||||
|
root — a sandbox copy beside the real install — it now also clicks the store that
|
||||||
|
is not open and checks that the bar, the grid and the categories follow. Both runs
|
||||||
|
pass, and `npm run smoke` drives the same bridge with no window at all: ten titles,
|
||||||
|
five native and five hosted, every installed one with something to launch.
|
||||||
Vendored
+110
-33
@@ -1,36 +1,50 @@
|
|||||||
'use strict'
|
'use strict'
|
||||||
// Setting up the store when there is none yet.
|
// Setting up a store when there is none yet.
|
||||||
//
|
//
|
||||||
// This is the reason the client exists on Windows at all: the shell installer is
|
// This is the reason the client exists on Windows at all: the shell installer is
|
||||||
// `curl … | sh`, which Windows does not have. The three files it would place are
|
// `curl … | sh`, which Windows does not have. The three files it would place are
|
||||||
// downloaded here instead, into the very same store home — so the CLI and the
|
// downloaded here instead, into the very same store home — so the CLI and the
|
||||||
// client stay one installation, and running install.sh afterwards only adds the
|
// client stay one installation, and running install.sh afterwards only adds the
|
||||||
// launcher script.
|
// launcher script.
|
||||||
|
//
|
||||||
|
// Which store, though, is not baked in. The client asks a registry — `GET
|
||||||
|
// /api/stores` on the site — and each record says what the store is called, which
|
||||||
|
// catalog it serves and where its configuration lives. A different site, or a
|
||||||
|
// second store on ours, needs no change here. The registry address is the one
|
||||||
|
// address the client does know, and even that is overridable.
|
||||||
|
|
||||||
const fs = require('node:fs')
|
const fs = require('node:fs')
|
||||||
const https = require('node:https')
|
const https = require('node:https')
|
||||||
|
const http = require('node:http')
|
||||||
const path = require('node:path')
|
const path = require('node:path')
|
||||||
|
|
||||||
const FORGE = 'https://git.teletypegames.org'
|
// Where the engine itself comes from. Not part of the registry: this is the
|
||||||
const SOURCES = {
|
// client's own machinery, the same for every store it can drive.
|
||||||
engine: `${FORGE}/stores/warp-engine-desktop-store/raw/branch/master/desktop_store.py`,
|
const FORGE = process.env.FORGE_BASE || 'https://git.teletypegames.org'
|
||||||
core: `${FORGE}/engines/warpstore/raw/branch/master/warpstore.py`,
|
const ENGINE_SOURCES = {
|
||||||
config: `${FORGE}/stores/ttg-desktop-store/raw/branch/master/config.json`
|
'desktop_store.py': `${FORGE}/stores/warp-engine-desktop-store/raw/branch/master/desktop_store.py`,
|
||||||
|
'warpstore.py': `${FORGE}/engines/warpstore/raw/branch/master/warpstore.py`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The registry. One address, and the only thing about a particular site left in
|
||||||
|
// the client.
|
||||||
|
const REGISTRY_URL = process.env.STORES_API || 'https://teletypegames.org/api/stores'
|
||||||
|
|
||||||
/** GET a URL as a string, following redirects — a moved repo answers 301. */
|
/** GET a URL as a string, following redirects — a moved repo answers 301. */
|
||||||
function fetchText (url, redirects = 5) {
|
function fetchText (url, redirects = 5) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = https.get(url, { headers: { 'User-Agent': 'warp-engine-desktop-gui' } }, (res) => {
|
const client = url.startsWith('http://') ? http : https
|
||||||
|
const request = client.get(url, { headers: { 'User-Agent': 'warp-engine-desktop-gui' } }, (res) => {
|
||||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
res.resume()
|
res.resume()
|
||||||
if (redirects <= 0) return reject(new Error(`too many redirects for ${url}`))
|
if (redirects <= 0) return reject(new Error(`too many redirects for ${url}`))
|
||||||
const next = new URL(res.headers.location, url).toString()
|
return fetchText(new URL(res.headers.location, url).toString(), redirects - 1).then(resolve, reject)
|
||||||
return fetchText(next, redirects - 1).then(resolve, reject)
|
|
||||||
}
|
}
|
||||||
if (res.statusCode !== 200) {
|
if (res.statusCode !== 200) {
|
||||||
res.resume()
|
res.resume()
|
||||||
return reject(new Error(`${url} answered ${res.statusCode}`))
|
const error = new Error(`${url} answered ${res.statusCode}`)
|
||||||
|
error.statusCode = res.statusCode
|
||||||
|
return reject(error)
|
||||||
}
|
}
|
||||||
let body = ''
|
let body = ''
|
||||||
res.setEncoding('utf8')
|
res.setEncoding('utf8')
|
||||||
@@ -43,40 +57,103 @@ function fetchText (url, redirects = 5) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download the engine, the shared core and the store's config into `home`.
|
* The stores this client can install, from the site's registry.
|
||||||
|
*
|
||||||
|
* Each record carries a name, the catalog it serves and the repository holding
|
||||||
|
* its config. Anything without those three is dropped rather than half-used.
|
||||||
|
*/
|
||||||
|
async function registry (url = REGISTRY_URL) {
|
||||||
|
const body = await fetchText(url)
|
||||||
|
const rows = JSON.parse(body)
|
||||||
|
if (!Array.isArray(rows)) throw new Error(`${url} did not answer with a list of stores`)
|
||||||
|
return rows
|
||||||
|
.map((row) => ({
|
||||||
|
name: String(row.name || '').trim(),
|
||||||
|
catalogUrl: String(row.catalogUrl || row.catalog_url || '').trim(),
|
||||||
|
storeRepositoryUrl: String(row.storeRepositoryUrl || row.store_repository_url || '').trim()
|
||||||
|
}))
|
||||||
|
.filter((row) => row.name && row.catalogUrl && row.storeRepositoryUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `…/stores/ttg-desktop-store` -> the raw config.json on its default branch. */
|
||||||
|
function configUrl (repositoryUrl, branch = 'master') {
|
||||||
|
return `${repositoryUrl.replace(/\/+$/, '')}/raw/branch/${branch}/config.json`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A store id from its repository name: `ttg-desktop-store` -> `ttg`.
|
||||||
|
*
|
||||||
|
* The id names the store home and the folder the games land in, so it has to be
|
||||||
|
* short and filesystem-safe. The repository name is the best source we have; the
|
||||||
|
* store's own config.json overrides it whenever it exists.
|
||||||
|
*/
|
||||||
|
function storeId (store) {
|
||||||
|
const last = store.storeRepositoryUrl.replace(/\/+$/, '').split('/').pop() || ''
|
||||||
|
const base = last.replace(/-(desktop-)?store$/, '') || store.name
|
||||||
|
return base.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'store'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
async function storeConfig (store, { onLog = () => {} } = {}) {
|
||||||
|
const id = storeId(store)
|
||||||
|
let config = null
|
||||||
|
try {
|
||||||
|
onLog(`reading the store config from ${store.storeRepositoryUrl}`)
|
||||||
|
config = JSON.parse(await fetchText(configUrl(store.storeRepositoryUrl)))
|
||||||
|
} catch (err) {
|
||||||
|
if (err.statusCode !== 404) throw err
|
||||||
|
onLog('no config.json in the store repository — using the engine defaults')
|
||||||
|
config = { paths: { subfolder: id }, catalog: { statuses: ['released', 'archived', 'demo'] } }
|
||||||
|
}
|
||||||
|
// The registry is the authority on identity and on which catalog to read, so
|
||||||
|
// those two win over whatever the file says.
|
||||||
|
config.store = { ...(config.store || {}) }
|
||||||
|
config.store.id = config.store.id || id
|
||||||
|
config.store.name = store.name
|
||||||
|
config.store.base_url = store.catalogUrl
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where this store's home goes. */
|
||||||
|
function homeFor (store, root) {
|
||||||
|
return path.join(root, `${storeId(store)}-desktop`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the engine, the shared core and the store's config into `home`.
|
||||||
*
|
*
|
||||||
* `onLog` reports each step, because on a slow line this takes a few seconds and
|
* `onLog` reports each step, because on a slow line this takes a few seconds and
|
||||||
* silence looks like a hang. An existing config is left alone: a store that is
|
* silence looks like a hang.
|
||||||
* already set up keeps its settings.
|
|
||||||
*/
|
*/
|
||||||
async function install (home, { onLog = () => {} } = {}) {
|
async function install (home, store, { onLog = () => {} } = {}) {
|
||||||
|
if (!store) throw new Error('no store was chosen')
|
||||||
fs.mkdirSync(home, { recursive: true })
|
fs.mkdirSync(home, { recursive: true })
|
||||||
const wrote = []
|
|
||||||
|
|
||||||
for (const [name, file] of [['engine', 'desktop_store.py'], ['core', 'warpstore.py']]) {
|
for (const [file, url] of Object.entries(ENGINE_SOURCES)) {
|
||||||
onLog(`downloading ${file}`)
|
onLog(`downloading ${file}`)
|
||||||
const body = await fetchText(SOURCES[name])
|
const body = await fetchText(url)
|
||||||
if (!body.startsWith('#!/usr/bin/env python3')) {
|
if (!body.startsWith('#!/usr/bin/env python3')) {
|
||||||
throw new Error(`${file} does not look like the store engine — refusing to install it`)
|
throw new Error(`${file} does not look like the store engine — refusing to install it`)
|
||||||
}
|
}
|
||||||
const dest = path.join(home, file)
|
fs.writeFileSync(path.join(home, file), body, { mode: 0o755 })
|
||||||
fs.writeFileSync(dest, body, { mode: 0o755 })
|
|
||||||
wrote.push(dest)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = path.join(home, 'config.json')
|
const configPath = path.join(home, 'config.json')
|
||||||
if (fs.existsSync(config)) {
|
const config = await storeConfig(store, { onLog })
|
||||||
onLog('keeping the config already in place')
|
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||||
} else {
|
|
||||||
onLog('downloading config.json')
|
|
||||||
const body = await fetchText(SOURCES.config)
|
|
||||||
JSON.parse(body) // a broken config would fail later and less clearly
|
|
||||||
fs.writeFileSync(config, body)
|
|
||||||
wrote.push(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
onLog(`the store is set up in ${home}`)
|
onLog(`${store.name} is set up in ${home}`)
|
||||||
return { home, config, script: path.join(home, 'desktop_store.py'), wrote }
|
return { home, config: configPath, script: path.join(home, 'desktop_store.py'), id: config.store.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { FORGE, SOURCES, fetchText, install }
|
module.exports = {
|
||||||
|
ENGINE_SOURCES, FORGE, REGISTRY_URL,
|
||||||
|
configUrl, fetchText, homeFor, install, registry, storeConfig, storeId
|
||||||
|
}
|
||||||
|
|||||||
+40
-4
@@ -22,11 +22,29 @@ const STRINGS = {
|
|||||||
nativeHint: 'Installed on this machine — works offline',
|
nativeHint: 'Installed on this machine — works offline',
|
||||||
updateAvailable: 'update available',
|
updateAvailable: 'update available',
|
||||||
log: 'Log',
|
log: 'Log',
|
||||||
|
menu: 'Menu',
|
||||||
|
stores: 'Stores',
|
||||||
|
addStore: 'Add a store…',
|
||||||
|
switchFailed: 'That store could not be opened',
|
||||||
|
actions: 'Actions',
|
||||||
|
categories: 'Categories',
|
||||||
|
catAll: 'Everything',
|
||||||
|
catInstalled: 'Installed',
|
||||||
|
catUpdates: 'Updates',
|
||||||
|
catAvailable: 'Not installed',
|
||||||
|
catPlatform: 'Platform',
|
||||||
|
catMode: 'Kind',
|
||||||
|
language: 'Language',
|
||||||
noGames: 'No installable titles in the catalog.',
|
noGames: 'No installable titles in the catalog.',
|
||||||
setupTitle: 'Set up the store',
|
noMatch: 'Nothing in this category.',
|
||||||
setupBody: 'The store engine is not on this machine yet. It can be downloaded now — the same files the shell installer would place, in the same folder.',
|
setupTitle: 'Set up a store',
|
||||||
|
setupBody: 'No store on this machine yet. Pick one and it will be downloaded — the same files the shell installer would place, in the same folder.',
|
||||||
setupAction: 'Download the store',
|
setupAction: 'Download the store',
|
||||||
setupWorking: 'Setting up…',
|
setupWorking: 'Setting up…',
|
||||||
|
setupChoose: 'Store',
|
||||||
|
registryFailed: 'The list of stores could not be fetched',
|
||||||
|
registryEmpty: 'The list of stores came back empty. Nothing to install from yet.',
|
||||||
|
registryRetry: 'Try again',
|
||||||
oldEngineTitle: 'The store needs refreshing',
|
oldEngineTitle: 'The store needs refreshing',
|
||||||
oldEngineBody: 'The store engine on this machine is older than this client can drive. Refreshing it downloads the current engine and keeps your settings and installed games.',
|
oldEngineBody: 'The store engine on this machine is older than this client can drive. Refreshing it downloads the current engine and keeps your settings and installed games.',
|
||||||
oldEngineAction: 'Refresh the store',
|
oldEngineAction: 'Refresh the store',
|
||||||
@@ -58,11 +76,29 @@ const STRINGS = {
|
|||||||
nativeHint: 'Erre a gépre telepítve — internet nélkül is megy',
|
nativeHint: 'Erre a gépre telepítve — internet nélkül is megy',
|
||||||
updateAvailable: 'frissítés elérhető',
|
updateAvailable: 'frissítés elérhető',
|
||||||
log: 'Napló',
|
log: 'Napló',
|
||||||
|
menu: 'Menü',
|
||||||
|
stores: 'Store-ok',
|
||||||
|
addStore: 'Store hozzáadása…',
|
||||||
|
switchFailed: 'Ez a store nem nyitható meg',
|
||||||
|
actions: 'Műveletek',
|
||||||
|
categories: 'Kategóriák',
|
||||||
|
catAll: 'Minden',
|
||||||
|
catInstalled: 'Telepítve',
|
||||||
|
catUpdates: 'Frissítés',
|
||||||
|
catAvailable: 'Nincs telepítve',
|
||||||
|
catPlatform: 'Platform',
|
||||||
|
catMode: 'Fajta',
|
||||||
|
language: 'Nyelv',
|
||||||
noGames: 'Nincs telepíthető cím a katalógusban.',
|
noGames: 'Nincs telepíthető cím a katalógusban.',
|
||||||
setupTitle: 'A store beállítása',
|
noMatch: 'Ebben a kategóriában nincs semmi.',
|
||||||
setupBody: 'A store motorja még nincs ezen a gépen. Most letölthető — ugyanazok a fájlok, ugyanabba a könyvtárba, ahová a shell-telepítő tenné.',
|
setupTitle: 'Store beállítása',
|
||||||
|
setupBody: 'Ezen a gépen még nincs store. Válassz egyet, és letöltöm — ugyanazokat a fájlokat, ugyanabba a könyvtárba, ahová a shell-telepítő tenné.',
|
||||||
setupAction: 'Store letöltése',
|
setupAction: 'Store letöltése',
|
||||||
setupWorking: 'Beállítás…',
|
setupWorking: 'Beállítás…',
|
||||||
|
setupChoose: 'Store',
|
||||||
|
registryFailed: 'A store-ok listája nem érhető el',
|
||||||
|
registryEmpty: 'A store-ok listája üresen jött vissza. Egyelőre nincs miből telepíteni.',
|
||||||
|
registryRetry: 'Újra',
|
||||||
oldEngineTitle: 'A store frissítésre vár',
|
oldEngineTitle: 'A store frissítésre vár',
|
||||||
oldEngineBody: 'A gépen lévő store-motor régebbi, mint amit ez a kliens vezérelni tud. A frissítés letölti a mostani motort, a beállításaid és a telepített játékok pedig megmaradnak.',
|
oldEngineBody: 'A gépen lévő store-motor régebbi, mint amit ez a kliens vezérelni tud. A frissítés letölti a mostani motort, a beállításaid és a telepített játékok pedig megmaradnak.',
|
||||||
oldEngineAction: 'Store frissítése',
|
oldEngineAction: 'Store frissítése',
|
||||||
|
|||||||
+34
-4
@@ -65,8 +65,37 @@ function findStores () {
|
|||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
function findStore () {
|
/**
|
||||||
return findStores()[0] || null
|
* The store to drive: the one asked for by home if it is still there, otherwise
|
||||||
|
* the first one found. The client remembers the choice, so a machine with two
|
||||||
|
* stores reopens on the one last used rather than on whichever sorts first.
|
||||||
|
*/
|
||||||
|
function findStore (preferredHome) {
|
||||||
|
const stores = findStores()
|
||||||
|
if (preferredHome) {
|
||||||
|
const wanted = stores.find((store) => store.home === preferredHome)
|
||||||
|
if (wanted) return wanted
|
||||||
|
}
|
||||||
|
return stores[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The store's own name, from the config the installer wrote. Worth reading here
|
||||||
|
* rather than waiting for `paths`: the switcher lists every store on the machine,
|
||||||
|
* and starting a Python process per entry to learn its name would be absurd.
|
||||||
|
*/
|
||||||
|
function storeName (store) {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(fs.readFileSync(store.config, 'utf8'))
|
||||||
|
return (config.store && config.store.name) || store.id
|
||||||
|
} catch {
|
||||||
|
return store.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An installed store as the window needs it. */
|
||||||
|
function describe (store) {
|
||||||
|
return store && { id: store.id, home: store.home, engine: store.engine, name: storeName(store) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where a store would be installed if there is none yet. */
|
/** Where a store would be installed if there is none yet. */
|
||||||
@@ -223,6 +252,7 @@ function purge (store, hooks) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
ENGINES, MIN_ENGINE, StoreError, atLeast, defaultHome, engineVersion, findPython,
|
ENGINES, MIN_ENGINE, StoreError, atLeast, defaultHome, describe, engineVersion,
|
||||||
findStore, findStores, list, parseVersion, paths, purge, remove, run, storeRoots, sync
|
findPython, findStore, findStores, list, parseVersion, paths, purge, remove, run,
|
||||||
|
storeName, storeRoots, sync
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ async function guarded (fn) {
|
|||||||
// would otherwise be noticed: the main process log stays empty.
|
// would otherwise be noticed: the main process log stays empty.
|
||||||
const SELFTEST = process.argv.includes('--selftest')
|
const SELFTEST = process.argv.includes('--selftest')
|
||||||
|
|
||||||
|
// A test run must never be swallowed by a copy the user already has open: it gets
|
||||||
|
// its own user-data directory and skips the single-instance lock. Without this the
|
||||||
|
// second process exits silently with status 0, which reads as a passing test.
|
||||||
|
if (SELFTEST) {
|
||||||
|
app.setPath('userData', path.join(app.getPath('temp'), 'warpstore-gui-selftest'))
|
||||||
|
}
|
||||||
|
|
||||||
async function selftest () {
|
async function selftest () {
|
||||||
const result = await win.webContents.executeJavaScript(`(() => ({
|
const result = await win.webContents.executeJavaScript(`(() => ({
|
||||||
cards: document.querySelectorAll('.card').length,
|
cards: document.querySelectorAll('.card').length,
|
||||||
@@ -67,16 +74,73 @@ async function selftest () {
|
|||||||
buttons: document.querySelectorAll('.card .actions button').length,
|
buttons: document.querySelectorAll('.card .actions button').length,
|
||||||
gateVisible: !document.getElementById('gate').hidden,
|
gateVisible: !document.getElementById('gate').hidden,
|
||||||
gateTitle: document.getElementById('gate-title').textContent,
|
gateTitle: document.getElementById('gate-title').textContent,
|
||||||
|
gateChoices: [...document.getElementById('gate-select').options].map((o) => o.text),
|
||||||
|
gateAction: document.getElementById('gate-action').textContent,
|
||||||
appName: document.getElementById('app-name').textContent,
|
appName: document.getElementById('app-name').textContent,
|
||||||
storeId: document.getElementById('store-id').textContent,
|
storeId: document.getElementById('store-id').textContent,
|
||||||
|
navOpen: !document.body.classList.contains('nav-closed'),
|
||||||
|
stores: [...document.querySelectorAll('#store-list .store-row')].map((n) => n.textContent),
|
||||||
|
categories: [...document.querySelectorAll('#cats .cat')].map((n) => n.textContent),
|
||||||
|
activeCategory: (document.querySelector('#cats .cat.is-active') || {}).textContent || null,
|
||||||
paths: document.getElementById('log-paths').textContent.slice(0, 120),
|
paths: document.getElementById('log-paths').textContent.slice(0, 120),
|
||||||
logLines: document.querySelectorAll('.log-line').length,
|
logLines: document.querySelectorAll('.log-line').length,
|
||||||
locales: [...document.getElementById('locale').options].map((o) => o.value)
|
locales: [...document.getElementById('locale').options].map((o) => o.value)
|
||||||
}))()`)
|
}))()`)
|
||||||
console.log(JSON.stringify(result, null, 2))
|
console.log(JSON.stringify(result, null, 2))
|
||||||
const good = result.cards > 0 && !result.gateVisible && result.locales.length > 1
|
|
||||||
console.log(good ? 'SELFTEST OK' : 'SELFTEST FAILED')
|
// With two stores on the machine the switcher is the thing most likely to be
|
||||||
app.exit(good ? 0 : 1)
|
// broken without anyone noticing, so the test uses it: click the store that is
|
||||||
|
// not open and see whether the window follows. Skipped when there is only one,
|
||||||
|
// which is the normal case — a single store cannot be switched away from.
|
||||||
|
let switched = null
|
||||||
|
if (result.stores.length > 1) {
|
||||||
|
switched = await win.webContents.executeJavaScript(`(async () => {
|
||||||
|
const other = [...document.querySelectorAll('#store-list .store-row')]
|
||||||
|
.find((row) => !row.classList.contains('is-active'))
|
||||||
|
other.click()
|
||||||
|
await new Promise((done) => setTimeout(done, 8000))
|
||||||
|
return {
|
||||||
|
storeId: document.getElementById('store-id').textContent,
|
||||||
|
active: (document.querySelector('#store-list .store-row.is-active') || {}).textContent || null,
|
||||||
|
cards: document.querySelectorAll('.card').length,
|
||||||
|
categories: document.querySelectorAll('#cats .cat').length
|
||||||
|
}
|
||||||
|
})()`)
|
||||||
|
console.log(`switched: ${JSON.stringify(switched)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A layout mistake does not show up in the DOM counts above, so on request the
|
||||||
|
// window photographs itself — the terminal cannot screenshot it from outside.
|
||||||
|
if (process.env.SELFTEST_SHOT) {
|
||||||
|
// capturePage hands back the last painted frame, so a window that is behind
|
||||||
|
// others — or still loading box art — photographs as a half-drawn page. Focus
|
||||||
|
// it, wait for the images, then let one frame go by.
|
||||||
|
win.show()
|
||||||
|
win.focus()
|
||||||
|
await win.webContents.executeJavaScript(`(async () => {
|
||||||
|
await Promise.all([...document.images].map((img) => img.complete
|
||||||
|
? null
|
||||||
|
: new Promise((done) => { img.onload = done; img.onerror = done })))
|
||||||
|
await new Promise((done) => requestAnimationFrame(() => setTimeout(done, 400)))
|
||||||
|
return document.images.length
|
||||||
|
})()`)
|
||||||
|
const image = await win.webContents.capturePage()
|
||||||
|
fs.writeFileSync(process.env.SELFTEST_SHOT, image.toPNG())
|
||||||
|
console.log(`shot: ${process.env.SELFTEST_SHOT}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Either outcome is a pass: a grid when a store is installed — with the side
|
||||||
|
// menu populated, which is the part a blank render would silently lose — or the
|
||||||
|
// setup gate with something to choose from when there is none.
|
||||||
|
const good = result.locales.length > 1 && (
|
||||||
|
(result.cards > 0 && !result.gateVisible &&
|
||||||
|
result.stores.length > 0 && result.categories.length > 0 && result.activeCategory) ||
|
||||||
|
(result.gateVisible && result.gateChoices.length > 0 && result.gateAction))
|
||||||
|
const switchGood = switched === null || (
|
||||||
|
switched.storeId && switched.storeId !== result.storeId &&
|
||||||
|
switched.cards > 0 && switched.categories > 0)
|
||||||
|
console.log(good && switchGood ? 'SELFTEST OK' : 'SELFTEST FAILED')
|
||||||
|
app.exit(good && switchGood ? 0 : 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWindow () {
|
function createWindow () {
|
||||||
@@ -132,7 +196,10 @@ function createWindow () {
|
|||||||
ipcMain.handle('app:state', () => {
|
ipcMain.handle('app:state', () => {
|
||||||
const prefs = loadPrefs()
|
const prefs = loadPrefs()
|
||||||
const python = store.findPython()
|
const python = store.findPython()
|
||||||
current = store.findStore()
|
// Every store on the machine, because the window offers a switch between them —
|
||||||
|
// and the remembered one wins, so reopening lands where the user left off.
|
||||||
|
const stores = store.findStores()
|
||||||
|
current = store.findStore(prefs.store)
|
||||||
// An engine that predates `--json` cannot be driven from a window; the client
|
// An engine that predates `--json` cannot be driven from a window; the client
|
||||||
// says so and offers to refresh it rather than failing on the first call.
|
// says so and offers to refresh it rather than failing on the first call.
|
||||||
const engine = current ? store.engineVersion(current) : null
|
const engine = current ? store.engineVersion(current) : null
|
||||||
@@ -140,15 +207,48 @@ ipcMain.handle('app:state', () => {
|
|||||||
locale: i18n.pick(prefs.locale || app.getLocale()),
|
locale: i18n.pick(prefs.locale || app.getLocale()),
|
||||||
languages: i18n.languages,
|
languages: i18n.languages,
|
||||||
strings: i18n.dict(prefs.locale || app.getLocale()),
|
strings: i18n.dict(prefs.locale || app.getLocale()),
|
||||||
|
nav: prefs.nav !== false,
|
||||||
python: python ? python.version : null,
|
python: python ? python.version : null,
|
||||||
store: current ? { id: current.id, home: current.home } : null,
|
store: store.describe(current),
|
||||||
|
stores: stores.map(store.describe),
|
||||||
engine: engine ? { text: engine.text, ok: engine.ok } : null,
|
engine: engine ? { text: engine.text, ok: engine.ok } : null,
|
||||||
minEngine: store.MIN_ENGINE.join('.'),
|
minEngine: store.MIN_ENGINE.join('.'),
|
||||||
defaultHome: store.defaultHome(),
|
registryUrl: bootstrap.REGISTRY_URL,
|
||||||
|
storeRoot: store.storeRoots()[0],
|
||||||
version: app.getVersion()
|
version: app.getVersion()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The side menu's open/closed state is worth keeping between runs; it is the one
|
||||||
|
// preference the window sets that is not a language.
|
||||||
|
ipcMain.handle('app:setNav', (_event, open) => {
|
||||||
|
const prefs = loadPrefs()
|
||||||
|
prefs.nav = Boolean(open)
|
||||||
|
savePrefs(prefs)
|
||||||
|
return prefs.nav
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to another installed store.
|
||||||
|
*
|
||||||
|
* The choice is remembered, and the engine is checked here rather than in the
|
||||||
|
* window: two stores on one machine can be at different versions, and the one
|
||||||
|
* being switched to may be the older one.
|
||||||
|
*/
|
||||||
|
ipcMain.handle('store:use', (_event, home) => {
|
||||||
|
const wanted = store.findStores().find((candidate) => candidate.home === home)
|
||||||
|
if (!wanted) throw new Error('that store is no longer on this machine')
|
||||||
|
current = wanted
|
||||||
|
const prefs = loadPrefs()
|
||||||
|
prefs.store = wanted.home
|
||||||
|
savePrefs(prefs)
|
||||||
|
const engine = store.engineVersion(current)
|
||||||
|
return {
|
||||||
|
store: store.describe(current),
|
||||||
|
engine: engine ? { text: engine.text, ok: engine.ok } : null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('app:setLocale', (_event, locale) => {
|
ipcMain.handle('app:setLocale', (_event, locale) => {
|
||||||
const prefs = loadPrefs()
|
const prefs = loadPrefs()
|
||||||
prefs.locale = i18n.pick(locale)
|
prefs.locale = i18n.pick(locale)
|
||||||
@@ -165,11 +265,26 @@ ipcMain.handle('store:sync', (_event, names) =>
|
|||||||
ipcMain.handle('store:remove', (_event, name) =>
|
ipcMain.handle('store:remove', (_event, name) =>
|
||||||
guarded(() => store.remove(current, String(name), hooks())))
|
guarded(() => store.remove(current, String(name), hooks())))
|
||||||
|
|
||||||
ipcMain.handle('store:bootstrap', () => guarded(async () => {
|
// The stores this client can install, from the site's registry rather than from
|
||||||
const home = store.defaultHome()
|
// anything baked in here. A separate call because it needs the network: the first
|
||||||
const result = await bootstrap.install(home, { onLog: (line) => send('store:log', line) })
|
// window paints without waiting for it.
|
||||||
current = { engine: 'desktop', id: path.basename(home).replace(/-desktop$/, ''), ...result }
|
ipcMain.handle('store:registry', async () => {
|
||||||
return { id: current.id, home: current.home }
|
try {
|
||||||
|
return { stores: await bootstrap.registry() }
|
||||||
|
} catch (err) {
|
||||||
|
return { stores: [], error: err.message, url: bootstrap.REGISTRY_URL }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('store:bootstrap', (_event, chosen) => guarded(async () => {
|
||||||
|
if (!chosen) throw new Error('no store was chosen')
|
||||||
|
const home = bootstrap.homeFor(chosen, store.storeRoots()[0])
|
||||||
|
const result = await bootstrap.install(home, chosen, { onLog: (line) => send('store:log', line) })
|
||||||
|
current = { engine: 'desktop', ...result }
|
||||||
|
const prefs = loadPrefs()
|
||||||
|
prefs.store = current.home
|
||||||
|
savePrefs(prefs)
|
||||||
|
return { ...store.describe(current), name: chosen.name }
|
||||||
}))
|
}))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -215,7 +330,7 @@ ipcMain.handle('app:openExternal', async (_event, url) => {
|
|||||||
|
|
||||||
// --- lifecycle ------------------------------------------------------------
|
// --- lifecycle ------------------------------------------------------------
|
||||||
|
|
||||||
if (!app.requestSingleInstanceLock()) {
|
if (!SELFTEST && !app.requestSingleInstanceLock()) {
|
||||||
app.quit()
|
app.quit()
|
||||||
} else {
|
} else {
|
||||||
app.on('second-instance', () => {
|
app.on('second-instance', () => {
|
||||||
|
|||||||
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "warp-engine-desktop-gui",
|
"name": "warp-engine-desktop-gui",
|
||||||
"productName": "WarpEngine Store",
|
"productName": "WarpEngine Store",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"description": "Graphical client for a WarpEngine desktop store: install the catalog into your own application menu.",
|
"description": "Graphical client for a WarpEngine desktop store: install the catalog into your own application menu.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Teletype Games <games@teletype.hu>",
|
"author": "Teletype Games <games@teletype.hu>",
|
||||||
@@ -51,7 +51,8 @@
|
|||||||
"AppImage",
|
"AppImage",
|
||||||
"deb"
|
"deb"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"afterPack": "scripts/after-pack.js"
|
||||||
},
|
},
|
||||||
"allowScripts": {
|
"allowScripts": {
|
||||||
"electron@43.4.0": true
|
"electron@43.4.0": true
|
||||||
|
|||||||
+4
-1
@@ -7,12 +7,15 @@ const { contextBridge, ipcRenderer } = require('electron')
|
|||||||
contextBridge.exposeInMainWorld('storeApi', {
|
contextBridge.exposeInMainWorld('storeApi', {
|
||||||
state: () => ipcRenderer.invoke('app:state'),
|
state: () => ipcRenderer.invoke('app:state'),
|
||||||
setLocale: (locale) => ipcRenderer.invoke('app:setLocale', locale),
|
setLocale: (locale) => ipcRenderer.invoke('app:setLocale', locale),
|
||||||
|
setNav: (open) => ipcRenderer.invoke('app:setNav', open),
|
||||||
|
|
||||||
list: () => ipcRenderer.invoke('store:list'),
|
list: () => ipcRenderer.invoke('store:list'),
|
||||||
paths: () => ipcRenderer.invoke('store:paths'),
|
paths: () => ipcRenderer.invoke('store:paths'),
|
||||||
sync: (names) => ipcRenderer.invoke('store:sync', names),
|
sync: (names) => ipcRenderer.invoke('store:sync', names),
|
||||||
remove: (name) => ipcRenderer.invoke('store:remove', name),
|
remove: (name) => ipcRenderer.invoke('store:remove', name),
|
||||||
bootstrap: () => ipcRenderer.invoke('store:bootstrap'),
|
use: (home) => ipcRenderer.invoke('store:use', home),
|
||||||
|
registry: () => ipcRenderer.invoke('store:registry'),
|
||||||
|
bootstrap: (store) => ipcRenderer.invoke('store:bootstrap', store),
|
||||||
launch: (game) => ipcRenderer.invoke('store:launch', game),
|
launch: (game) => ipcRenderer.invoke('store:launch', game),
|
||||||
|
|
||||||
openFolder: (dir) => ipcRenderer.invoke('app:openFolder', dir),
|
openFolder: (dir) => ipcRenderer.invoke('app:openFolder', dir),
|
||||||
|
|||||||
+262
-28
@@ -1,16 +1,22 @@
|
|||||||
'use strict'
|
'use strict'
|
||||||
// The whole renderer. No framework and no build step: the app is a grid of
|
// The whole renderer. No framework and no build step: a side menu on the left
|
||||||
// cards, and every action is one call over the bridge in preload.js.
|
// decides what is shown, a grid of cards on the right shows it, and every action
|
||||||
|
// is one call over the bridge in preload.js.
|
||||||
|
|
||||||
const api = window.storeApi
|
const api = window.storeApi
|
||||||
const el = (id) => document.getElementById(id)
|
const el = (id) => document.getElementById(id)
|
||||||
|
|
||||||
let T = {} // the active string table
|
let T = {} // the active string table
|
||||||
|
let state = null // what the main process knows: stores, python, engine
|
||||||
let games = []
|
let games = []
|
||||||
let paths = null
|
let paths = null
|
||||||
let busy = false
|
let busy = false
|
||||||
let plan = null // { total, done } while a sync is running
|
let plan = null // { total, done } while a sync is running
|
||||||
|
|
||||||
|
// What the grid is narrowed down to. One category at a time on purpose: a matrix
|
||||||
|
// of filters would need explaining, and a catalog of this size does not earn it.
|
||||||
|
let filter = { kind: 'group', value: 'all' }
|
||||||
|
|
||||||
// --- helpers --------------------------------------------------------------
|
// --- helpers --------------------------------------------------------------
|
||||||
|
|
||||||
function text (node, value) {
|
function text (node, value) {
|
||||||
@@ -34,10 +40,14 @@ function logLine (line) {
|
|||||||
box.scrollTop = box.scrollHeight
|
box.scrollTop = box.scrollHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// While the CLI runs, anything that would start a second call is disabled. The
|
||||||
|
// menu toggle, the log drawer and the category filters are not among them: they
|
||||||
|
// only change what is on screen.
|
||||||
function setBusy (value) {
|
function setBusy (value) {
|
||||||
busy = value
|
busy = value
|
||||||
for (const node of document.querySelectorAll('button')) {
|
for (const node of document.querySelectorAll('button')) {
|
||||||
if (node.id === 'log-toggle') continue
|
if (node.id === 'log-toggle' || node.id === 'nav-toggle') continue
|
||||||
|
if (node.classList.contains('cat')) continue
|
||||||
node.disabled = value
|
node.disabled = value
|
||||||
}
|
}
|
||||||
const progress = el('progress')
|
const progress = el('progress')
|
||||||
@@ -53,6 +63,136 @@ function showProgress (label) {
|
|||||||
text(progress, label)
|
text(progress, label)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- the side menu --------------------------------------------------------
|
||||||
|
|
||||||
|
function setNav (open) {
|
||||||
|
document.body.classList.toggle('nav-closed', !open)
|
||||||
|
el('nav-toggle').setAttribute('aria-expanded', String(open))
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStores () {
|
||||||
|
const box = el('store-list')
|
||||||
|
const stores = (state && state.stores) || []
|
||||||
|
const active = state && state.store ? state.store.home : null
|
||||||
|
// Two stores can carry the same id in different roots — the same catalog
|
||||||
|
// installed twice. Then the id says nothing and the folder is what tells them
|
||||||
|
// apart, so that is what the row shows.
|
||||||
|
const ambiguous = new Set(stores
|
||||||
|
.filter((store, index) => stores.findIndex((other) => other.id === store.id) !== index)
|
||||||
|
.map((store) => store.id))
|
||||||
|
box.replaceChildren(...stores.map((store) => {
|
||||||
|
const row = document.createElement('button')
|
||||||
|
row.className = 'store-row'
|
||||||
|
if (store.home === active) row.classList.add('is-active')
|
||||||
|
const name = document.createElement('span')
|
||||||
|
name.className = 'store-row-name'
|
||||||
|
text(name, store.name)
|
||||||
|
row.appendChild(name)
|
||||||
|
const id = document.createElement('span')
|
||||||
|
id.className = 'store-row-id'
|
||||||
|
text(id, ambiguous.has(store.id) ? store.home : store.id)
|
||||||
|
row.appendChild(id)
|
||||||
|
row.title = store.home
|
||||||
|
row.addEventListener('click', () => {
|
||||||
|
if (store.home !== active) switchStore(store.home)
|
||||||
|
})
|
||||||
|
return row
|
||||||
|
}))
|
||||||
|
el('add-store').hidden = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The categories, built from what the catalog actually contains.
|
||||||
|
*
|
||||||
|
* There is no genre in a WarpEngine catalog, so the useful axes are the state of
|
||||||
|
* a title on this machine, the platform it was built with, and whether it runs
|
||||||
|
* here or in a browser. Empty axes are left out rather than shown as zeroes.
|
||||||
|
*/
|
||||||
|
function categories () {
|
||||||
|
const count = (fn) => games.filter(fn).length
|
||||||
|
const sections = [{
|
||||||
|
group: null,
|
||||||
|
items: [
|
||||||
|
{ kind: 'group', value: 'all', label: T.catAll, count: games.length },
|
||||||
|
{ kind: 'group', value: 'installed', label: T.catInstalled, count: count((g) => g.installed) },
|
||||||
|
{ kind: 'group', value: 'updates', label: T.catUpdates, count: count((g) => g.update_available) },
|
||||||
|
{ kind: 'group', value: 'available', label: T.catAvailable, count: count((g) => !g.installed) }
|
||||||
|
].filter((item) => item.value === 'all' || item.count > 0)
|
||||||
|
}]
|
||||||
|
|
||||||
|
const platforms = [...new Set(games.map((g) => g.platform).filter(Boolean))].sort()
|
||||||
|
if (platforms.length > 1) {
|
||||||
|
sections.push({
|
||||||
|
group: T.catPlatform,
|
||||||
|
items: platforms.map((platform) => ({
|
||||||
|
kind: 'platform', value: platform, label: platform, count: count((g) => g.platform === platform)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const modes = [...new Set(games.map((g) => g.mode).filter(Boolean))]
|
||||||
|
if (modes.length > 1) {
|
||||||
|
sections.push({
|
||||||
|
group: T.catMode,
|
||||||
|
items: modes.map((mode) => ({
|
||||||
|
kind: 'mode', value: mode, label: mode === 'web' ? T.hosted : T.native, count: count((g) => g.mode === mode)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches (game) {
|
||||||
|
if (filter.kind === 'platform') return game.platform === filter.value
|
||||||
|
if (filter.kind === 'mode') return game.mode === filter.value
|
||||||
|
if (filter.value === 'installed') return Boolean(game.installed)
|
||||||
|
if (filter.value === 'updates') return Boolean(game.update_available)
|
||||||
|
if (filter.value === 'available') return !game.installed
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCats () {
|
||||||
|
const box = el('cats')
|
||||||
|
const sections = categories()
|
||||||
|
|
||||||
|
// A category can vanish under us — the last title of a platform is removed, or
|
||||||
|
// an update is applied — and a filter matching nothing would look like an empty
|
||||||
|
// catalog. Falling back to everything is the honest answer.
|
||||||
|
const known = sections.flatMap((section) => section.items)
|
||||||
|
.some((item) => item.kind === filter.kind && item.value === filter.value)
|
||||||
|
if (!known) filter = { kind: 'group', value: 'all' }
|
||||||
|
|
||||||
|
const nodes = []
|
||||||
|
for (const section of sections) {
|
||||||
|
if (section.group) {
|
||||||
|
const head = document.createElement('div')
|
||||||
|
head.className = 'cat-group'
|
||||||
|
text(head, section.group)
|
||||||
|
nodes.push(head)
|
||||||
|
}
|
||||||
|
for (const item of section.items) {
|
||||||
|
const button = document.createElement('button')
|
||||||
|
button.className = 'cat'
|
||||||
|
if (item.kind === filter.kind && item.value === filter.value) button.classList.add('is-active')
|
||||||
|
const label = document.createElement('span')
|
||||||
|
label.className = 'cat-label'
|
||||||
|
text(label, item.label)
|
||||||
|
button.appendChild(label)
|
||||||
|
const count = document.createElement('span')
|
||||||
|
count.className = 'cat-count'
|
||||||
|
text(count, item.count)
|
||||||
|
button.appendChild(count)
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
filter = { kind: item.kind, value: item.value }
|
||||||
|
renderCats()
|
||||||
|
renderGrid()
|
||||||
|
})
|
||||||
|
nodes.push(button)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
box.replaceChildren(...nodes)
|
||||||
|
}
|
||||||
|
|
||||||
// --- the card grid --------------------------------------------------------
|
// --- the card grid --------------------------------------------------------
|
||||||
|
|
||||||
function card (game) {
|
function card (game) {
|
||||||
@@ -70,6 +210,8 @@ function card (game) {
|
|||||||
img.loading = 'lazy'
|
img.loading = 'lazy'
|
||||||
art.appendChild(img)
|
art.appendChild(img)
|
||||||
} else {
|
} else {
|
||||||
|
// No box art in the catalog: the first letter, on the same band an image
|
||||||
|
// would fill, so a row of cards stays aligned either way.
|
||||||
const glyph = document.createElement('span')
|
const glyph = document.createElement('span')
|
||||||
glyph.className = 'art-glyph'
|
glyph.className = 'art-glyph'
|
||||||
text(glyph, game.title.slice(0, 1).toUpperCase())
|
text(glyph, game.title.slice(0, 1).toUpperCase())
|
||||||
@@ -141,12 +283,14 @@ function card (game) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderGrid () {
|
function renderGrid () {
|
||||||
|
const shown = games.filter(matches)
|
||||||
const grid = el('grid')
|
const grid = el('grid')
|
||||||
grid.replaceChildren(...games.map(card))
|
grid.replaceChildren(...shown.map(card))
|
||||||
grid.hidden = games.length === 0
|
grid.hidden = shown.length === 0
|
||||||
|
grid.scrollTop = 0
|
||||||
const empty = el('empty')
|
const empty = el('empty')
|
||||||
empty.hidden = games.length !== 0
|
empty.hidden = shown.length !== 0
|
||||||
text(empty, T.noGames)
|
text(empty, games.length === 0 ? T.noGames : T.noMatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPaths () {
|
function renderPaths () {
|
||||||
@@ -175,6 +319,7 @@ async function refresh () {
|
|||||||
const result = await api.list()
|
const result = await api.list()
|
||||||
games = result.games || []
|
games = result.games || []
|
||||||
paths = result.paths || paths
|
paths = result.paths || paths
|
||||||
|
renderCats()
|
||||||
renderGrid()
|
renderGrid()
|
||||||
renderPaths()
|
renderPaths()
|
||||||
for (const reason of result.skipped || []) logLine(`skipped ${reason}`)
|
for (const reason of result.skipped || []) logLine(`skipped ${reason}`)
|
||||||
@@ -201,20 +346,66 @@ async function runRemove (name) {
|
|||||||
await refresh()
|
await refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- gate: no python, or no store yet -------------------------------------
|
/** Open another store that is already on this machine. */
|
||||||
|
async function switchStore (home) {
|
||||||
|
try {
|
||||||
|
const next = await api.use(home)
|
||||||
|
state.store = next.store
|
||||||
|
state.engine = next.engine
|
||||||
|
text(el('store-id'), next.store.id)
|
||||||
|
renderStores()
|
||||||
|
games = []
|
||||||
|
paths = null
|
||||||
|
filter = { kind: 'group', value: 'all' }
|
||||||
|
if (next.engine && !next.engine.ok) {
|
||||||
|
renderCats()
|
||||||
|
showOldEngineGate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hideGate()
|
||||||
|
await refresh()
|
||||||
|
} catch (err) {
|
||||||
|
logLine(`${T.switchFailed}: ${err && err.message ? err.message : err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function showGate (title, body, action, link) {
|
/** Pick up a store that appeared since the window opened. */
|
||||||
|
async function reloadState () {
|
||||||
|
state = await api.state()
|
||||||
|
text(el('store-id'), state.store ? state.store.id : '')
|
||||||
|
renderStores()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- gate: no python, no store yet, or an engine too old ------------------
|
||||||
|
|
||||||
|
function showGate (title, body, action, link, choices) {
|
||||||
el('grid').hidden = true
|
el('grid').hidden = true
|
||||||
el('empty').hidden = true
|
el('empty').hidden = true
|
||||||
const gate = el('gate')
|
const gate = el('gate')
|
||||||
gate.hidden = false
|
gate.hidden = false
|
||||||
text(el('gate-title'), title)
|
text(el('gate-title'), title)
|
||||||
text(el('gate-body'), body)
|
text(el('gate-body'), body)
|
||||||
|
|
||||||
|
// Only shown when the registry offers more than one store; with a single one
|
||||||
|
// there is nothing to decide.
|
||||||
|
const choice = el('gate-choice')
|
||||||
|
const select = el('gate-select')
|
||||||
|
choice.hidden = !choices || choices.length < 2
|
||||||
|
if (!choice.hidden) {
|
||||||
|
text(el('gate-choice-label'), T.setupChoose)
|
||||||
|
select.replaceChildren(...choices.map((store, index) => {
|
||||||
|
const option = document.createElement('option')
|
||||||
|
option.value = String(index)
|
||||||
|
option.textContent = store.name
|
||||||
|
return option
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const button = el('gate-action')
|
const button = el('gate-action')
|
||||||
button.hidden = !action
|
button.hidden = !action
|
||||||
if (action) {
|
if (action) {
|
||||||
text(button, action.label)
|
text(button, action.label)
|
||||||
button.onclick = action.onClick
|
button.onclick = () => action.onClick(choices ? choices[Number(select.value) || 0] : undefined)
|
||||||
}
|
}
|
||||||
const anchor = el('gate-link')
|
const anchor = el('gate-link')
|
||||||
anchor.hidden = !link
|
anchor.hidden = !link
|
||||||
@@ -228,6 +419,44 @@ function hideGate () {
|
|||||||
el('gate').hidden = true
|
el('gate').hidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showOldEngineGate () {
|
||||||
|
showGate(T.oldEngineTitle,
|
||||||
|
`${T.oldEngineBody}\n\n${state.engine.text} → ${state.minEngine}`,
|
||||||
|
{ label: T.oldEngineAction, onClick: offerStores })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setUpStore (chosen) {
|
||||||
|
showProgress(T.setupWorking)
|
||||||
|
try {
|
||||||
|
await api.bootstrap(chosen)
|
||||||
|
await reloadState()
|
||||||
|
hideGate()
|
||||||
|
await refresh()
|
||||||
|
} catch (err) {
|
||||||
|
logLine(String(err && err.message ? err.message : err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which stores exist is the site's answer, not this client's: the registry is
|
||||||
|
// asked for it, and its records carry the catalog and the config repository.
|
||||||
|
async function offerStores () {
|
||||||
|
const result = await api.registry()
|
||||||
|
if (result.error) {
|
||||||
|
showGate(`${T.registryFailed}`, `${result.url}\n\n${result.error}`,
|
||||||
|
{ label: T.registryRetry, onClick: offerStores })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!result.stores.length) {
|
||||||
|
showGate(T.setupTitle, `${T.registryEmpty}\n\n${result.url}`, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showGate(T.setupTitle,
|
||||||
|
`${T.setupBody}\n\n${state.storeRoot}`,
|
||||||
|
{ label: T.setupAction, onClick: async (chosen) => { await setUpStore(chosen); await runSync([]) } },
|
||||||
|
null,
|
||||||
|
result.stores)
|
||||||
|
}
|
||||||
|
|
||||||
// --- boot -----------------------------------------------------------------
|
// --- boot -----------------------------------------------------------------
|
||||||
|
|
||||||
function applyStrings (strings) {
|
function applyStrings (strings) {
|
||||||
@@ -236,13 +465,26 @@ function applyStrings (strings) {
|
|||||||
text(el('sync-all'), T.syncAll)
|
text(el('sync-all'), T.syncAll)
|
||||||
text(el('refresh'), T.refresh)
|
text(el('refresh'), T.refresh)
|
||||||
text(el('log-toggle'), T.log)
|
text(el('log-toggle'), T.log)
|
||||||
|
text(el('head-stores'), T.stores)
|
||||||
|
text(el('head-actions'), T.actions)
|
||||||
|
text(el('head-cats'), T.categories)
|
||||||
|
text(el('head-lang'), T.language)
|
||||||
|
text(el('add-store'), T.addStore)
|
||||||
|
el('nav-toggle').title = T.menu
|
||||||
|
el('nav-toggle').setAttribute('aria-label', T.menu)
|
||||||
renderPaths()
|
renderPaths()
|
||||||
if (games.length) renderGrid()
|
if (state) renderStores()
|
||||||
|
if (games.length) {
|
||||||
|
renderCats()
|
||||||
|
renderGrid()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function boot () {
|
async function boot () {
|
||||||
const state = await api.state()
|
state = await api.state()
|
||||||
applyStrings(state.strings)
|
applyStrings(state.strings)
|
||||||
|
setNav(state.nav !== false)
|
||||||
|
renderStores()
|
||||||
|
|
||||||
const select = el('locale')
|
const select = el('locale')
|
||||||
select.replaceChildren(...state.languages.map((code) => {
|
select.replaceChildren(...state.languages.map((code) => {
|
||||||
@@ -263,27 +505,13 @@ async function boot () {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const setUpStore = async () => {
|
|
||||||
showProgress(T.setupWorking)
|
|
||||||
try {
|
|
||||||
await api.bootstrap()
|
|
||||||
hideGate()
|
|
||||||
await refresh()
|
|
||||||
} catch (err) {
|
|
||||||
logLine(String(err && err.message ? err.message : err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.store) {
|
if (!state.store) {
|
||||||
showGate(T.setupTitle, `${T.setupBody}\n\n${state.defaultHome}`,
|
await offerStores()
|
||||||
{ label: T.setupAction, onClick: async () => { await setUpStore(); await runSync([]) } })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.engine && !state.engine.ok) {
|
if (state.engine && !state.engine.ok) {
|
||||||
showGate(T.oldEngineTitle,
|
showOldEngineGate()
|
||||||
`${T.oldEngineBody}\n\n${state.engine.text} → ${state.minEngine}`,
|
|
||||||
{ label: T.oldEngineAction, onClick: setUpStore })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +522,12 @@ async function boot () {
|
|||||||
|
|
||||||
el('sync-all').addEventListener('click', () => runSync([]))
|
el('sync-all').addEventListener('click', () => runSync([]))
|
||||||
el('refresh').addEventListener('click', () => refresh())
|
el('refresh').addEventListener('click', () => refresh())
|
||||||
|
el('add-store').addEventListener('click', () => offerStores())
|
||||||
|
el('nav-toggle').addEventListener('click', () => {
|
||||||
|
const open = document.body.classList.contains('nav-closed')
|
||||||
|
setNav(open)
|
||||||
|
api.setNav(open)
|
||||||
|
})
|
||||||
el('log-toggle').addEventListener('click', () => {
|
el('log-toggle').addEventListener('click', () => {
|
||||||
const box = el('log-lines')
|
const box = el('log-lines')
|
||||||
box.hidden = !box.hidden
|
box.hidden = !box.hidden
|
||||||
|
|||||||
+57
-21
@@ -11,6 +11,9 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="bar">
|
<header class="bar">
|
||||||
|
<button id="nav-toggle" class="nav-toggle" aria-controls="side" aria-expanded="true">
|
||||||
|
<span aria-hidden="true">☰</span>
|
||||||
|
</button>
|
||||||
<div class="bar-title">
|
<div class="bar-title">
|
||||||
<span class="logo" aria-hidden="true">▚</span>
|
<span class="logo" aria-hidden="true">▚</span>
|
||||||
<span id="app-name">WarpEngine Store</span>
|
<span id="app-name">WarpEngine Store</span>
|
||||||
@@ -18,31 +21,64 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="bar-actions">
|
<div class="bar-actions">
|
||||||
<span class="progress" id="progress" hidden></span>
|
<span class="progress" id="progress" hidden></span>
|
||||||
<button id="sync-all" class="btn btn-primary" disabled></button>
|
|
||||||
<button id="refresh" class="btn" disabled></button>
|
|
||||||
<select id="locale" class="select" aria-label="Language"></select>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Shown instead of the grid when there is nothing to drive yet. -->
|
<div class="shell">
|
||||||
<section id="gate" class="gate" hidden>
|
<!-- The side menu: which store, what to do with it, and what to look at.
|
||||||
<h1 id="gate-title"></h1>
|
Collapsed with the button in the bar; the choice is remembered. -->
|
||||||
<p id="gate-body"></p>
|
<aside class="side" id="side">
|
||||||
<div class="gate-actions">
|
<section class="side-block">
|
||||||
<button id="gate-action" class="btn btn-primary" hidden></button>
|
<h2 class="side-head" id="head-stores"></h2>
|
||||||
<a id="gate-link" class="link" href="#" hidden></a>
|
<div class="store-list" id="store-list"></div>
|
||||||
|
<button id="add-store" class="btn btn-ghost btn-wide"></button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<section class="side-block side-cats">
|
||||||
|
<h2 class="side-head" id="head-cats"></h2>
|
||||||
|
<nav class="cats" id="cats"></nav>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="side-block side-foot">
|
||||||
|
<label class="side-lang">
|
||||||
|
<span id="head-lang"></span>
|
||||||
|
<select id="locale" class="select" aria-label="Language"></select>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<!-- Shown instead of the grid when there is nothing to drive yet. -->
|
||||||
|
<section id="gate" class="gate" hidden>
|
||||||
|
<h1 id="gate-title"></h1>
|
||||||
|
<p id="gate-body"></p>
|
||||||
|
<div class="gate-actions">
|
||||||
|
<label id="gate-choice" class="gate-choice" hidden>
|
||||||
|
<span id="gate-choice-label"></span>
|
||||||
|
<select id="gate-select" class="select"></select>
|
||||||
|
</label>
|
||||||
|
<button id="gate-action" class="btn btn-primary" hidden></button>
|
||||||
|
<a id="gate-link" class="link" href="#" hidden></a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main id="grid" class="grid" hidden></main>
|
||||||
|
|
||||||
|
<section id="empty" class="empty" hidden></section>
|
||||||
|
|
||||||
|
<footer class="log">
|
||||||
|
<button id="log-toggle" class="log-toggle" aria-expanded="false"></button>
|
||||||
|
<div class="log-lines" id="log-lines" hidden></div>
|
||||||
|
<div class="log-paths" id="log-paths"></div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<main id="grid" class="grid" hidden></main>
|
|
||||||
|
|
||||||
<section id="empty" class="empty" hidden></section>
|
|
||||||
|
|
||||||
<footer class="log">
|
|
||||||
<button id="log-toggle" class="log-toggle" aria-expanded="false"></button>
|
|
||||||
<div class="log-lines" id="log-lines" hidden></div>
|
|
||||||
<div class="log-paths" id="log-paths"></div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+138
-6
@@ -25,18 +25,138 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- shell: the side menu and everything else --------------------------- */
|
||||||
|
.shell {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden; /* so the collapsed menu is clipped rather than scrolled to */
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
--side-width: 244px;
|
||||||
|
width: var(--side-width);
|
||||||
|
flex: none;
|
||||||
|
background: var(--panel);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
/* The menu itself does not scroll: the category list does. Letting the whole
|
||||||
|
column scroll made the bottom block — pinned there with margin-top: auto —
|
||||||
|
sit on top of the overflowing categories. */
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 14px 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
transition: margin-left .16s ease-out;
|
||||||
|
}
|
||||||
|
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-head {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
margin: 0 0 2px 4px;
|
||||||
|
}
|
||||||
|
.btn-wide { width: 100%; text-align: left; }
|
||||||
|
|
||||||
|
.nav-toggle {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.nav-toggle:hover { color: var(--ink); border-color: #3a4757; }
|
||||||
|
|
||||||
|
/* Store switcher: one row per store on this machine, the open one marked. */
|
||||||
|
.store-list { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.store-row {
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--ink);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
.store-row:hover:not(:disabled) { background: var(--panel-2); }
|
||||||
|
.store-row.is-active {
|
||||||
|
background: var(--panel-2);
|
||||||
|
border-color: #2f5a49;
|
||||||
|
}
|
||||||
|
.store-row .store-row-name { font-weight: 600; }
|
||||||
|
.store-row .store-row-id {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.store-row:disabled { opacity: .55; cursor: default; }
|
||||||
|
|
||||||
|
/* Categories: what the catalog is filtered down to. */
|
||||||
|
.cats { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; min-height: 0; }
|
||||||
|
.cat {
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.cat:hover { background: var(--panel-2); color: var(--ink); }
|
||||||
|
.cat.is-active { background: var(--panel-2); color: var(--ink); font-weight: 600; }
|
||||||
|
.cat-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.cat-count { margin-left: auto; font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||||
|
.cat-group {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #6b7d92;
|
||||||
|
padding: 8px 10px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-lang { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-dim); }
|
||||||
|
.side-lang .select { margin-left: auto; }
|
||||||
|
|
||||||
/* --- top bar ------------------------------------------------------------ */
|
/* --- top bar ------------------------------------------------------------ */
|
||||||
.bar {
|
.bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
padding: 12px 18px;
|
padding: 10px 18px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
.bar-title { display: flex; align-items: baseline; gap: 10px; font-weight: 700; }
|
.bar-title { display: flex; align-items: baseline; gap: 10px; font-weight: 700; margin-right: auto; }
|
||||||
.logo { color: var(--accent); font-size: 18px; }
|
.logo { color: var(--accent); font-size: 18px; }
|
||||||
.store-id {
|
.store-id {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -87,19 +207,28 @@ body {
|
|||||||
}
|
}
|
||||||
.gate h1 { font-size: 20px; margin: 0 0 10px; }
|
.gate h1 { font-size: 20px; margin: 0 0 10px; }
|
||||||
.gate p { color: var(--ink-dim); white-space: pre-line; margin: 0 0 20px; word-break: break-all; }
|
.gate p { color: var(--ink-dim); white-space: pre-line; margin: 0 0 20px; word-break: break-all; }
|
||||||
.gate-actions { display: flex; gap: 14px; justify-content: center; align-items: center; }
|
.gate-actions { display: flex; gap: 14px; justify-content: center; align-items: center; flex-wrap: wrap; }
|
||||||
|
.gate-choice { display: inline-flex; align-items: center; gap: 8px; color: var(--ink-dim); font-size: 13px; }
|
||||||
|
|
||||||
/* --- the grid ----------------------------------------------------------- */
|
/* --- the grid ----------------------------------------------------------- */
|
||||||
.grid {
|
.grid {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
/* Narrower than it was: the side menu takes 244px off the window, and at 260px
|
||||||
|
a 1040px window had room for only two columns. */
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
|
/* Content-sized rows, spelled out. Left to `auto` the implicit rows split the
|
||||||
|
grid's height evenly — every card came out 94px tall with its box art
|
||||||
|
collapsed to nothing and its buttons clipped away, which is how the grid
|
||||||
|
looked before this was measured. */
|
||||||
|
grid-auto-rows: max-content;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
.empty { margin: auto; color: var(--ink-dim); }
|
.empty { margin: auto; color: var(--ink-dim); }
|
||||||
|
.gate { overflow-y: auto; }
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@@ -112,7 +241,10 @@ body {
|
|||||||
.card.is-installed { border-color: #2f5a49; }
|
.card.is-installed { border-color: #2f5a49; }
|
||||||
|
|
||||||
.art {
|
.art {
|
||||||
aspect-ratio: 4 / 3;
|
/* One height for every card, art or not, so the titles and the buttons line up
|
||||||
|
across a row. The images are cropped anyway (object-fit: cover). */
|
||||||
|
height: 148px;
|
||||||
|
flex: none;
|
||||||
background: #0d1117;
|
background: #0d1117;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use strict'
|
||||||
|
// Ad-hoc sign the macOS bundle after packing.
|
||||||
|
//
|
||||||
|
// Without this the bundle carries only the linker's ad-hoc signature on the main
|
||||||
|
// executable, with no resource seal — `codesign --verify` says "code has no
|
||||||
|
// resources but signature indicates they must be present". That runs fine
|
||||||
|
// locally, but a browser download adds the quarantine flag, Gatekeeper evaluates
|
||||||
|
// the broken seal, and macOS reports the app as *damaged* rather than merely
|
||||||
|
// unverified. The first release shipped exactly that.
|
||||||
|
//
|
||||||
|
// An ad-hoc signature is not a Developer ID and does not notarise anything: the
|
||||||
|
// user still has to right-click ▸ Open the first time. It is the difference
|
||||||
|
// between "unidentified developer" and "move it to the Bin".
|
||||||
|
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
exports.default = async function afterPack (context) {
|
||||||
|
if (context.electronPlatformName !== 'darwin') return
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
console.log(' • ad-hoc signing skipped reason=codesign only exists on macOS')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`)
|
||||||
|
// --deep is the pragmatic choice for ad-hoc signing a bundle with nested
|
||||||
|
// frameworks and helpers; Apple discourages it for real identities, where the
|
||||||
|
// inner-to-outer order matters.
|
||||||
|
execFileSync('codesign', ['--force', '--deep', '--sign', '-', app], { stdio: 'inherit' })
|
||||||
|
execFileSync('codesign', ['--verify', '--deep', '--strict', '--verbose=1', app], { stdio: 'inherit' })
|
||||||
|
console.log(` • ad-hoc signed ${app}`)
|
||||||
|
}
|
||||||
Executable
+131
@@ -0,0 +1,131 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Publish the built packages as a Gitea release.
|
||||||
|
#
|
||||||
|
# The version comes from package.json, so the tag follows whatever `npm version`
|
||||||
|
# set — there is nothing to keep in sync by hand. The release is created if it is
|
||||||
|
# not there yet, and an attachment with a name already on it is replaced rather
|
||||||
|
# than refused, which is what makes a rebuild-and-upload repeatable.
|
||||||
|
#
|
||||||
|
# scripts/release.sh every package in dist/
|
||||||
|
# scripts/release.sh dist/foo.dmg just these
|
||||||
|
#
|
||||||
|
# Assumes `tea` is installed and logged in (see the devarea repo: `make tea`).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
LOGIN="${TEA_LOGIN:-ttg}"
|
||||||
|
NOTES="${NOTES:-RELEASE_NOTES.md}"
|
||||||
|
DIST="${DIST:-dist}"
|
||||||
|
|
||||||
|
say() { echo "[release] $*"; }
|
||||||
|
die() { echo "[release] error: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
command -v tea >/dev/null 2>&1 || die "tea is not installed — see the devarea repo, 'make tea'"
|
||||||
|
command -v python3 >/dev/null 2>&1 || die "python3 is required"
|
||||||
|
[ -f package.json ] || die "run this from the repository root"
|
||||||
|
|
||||||
|
# Version and title in one go, through a heredoc rather than a quoted one-liner:
|
||||||
|
# nesting python quoting inside shell quoting inside a command substitution is how
|
||||||
|
# this produced an empty title on its first outing.
|
||||||
|
VERSION="$(python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
print(json.load(open("package.json"))["version"])
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
TITLE="$(python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
d = json.load(open("package.json"))
|
||||||
|
print((d.get("productName") or d["name"]) + " " + d["version"])
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
TAG="${TAG:-v$VERSION}"
|
||||||
|
[ -n "$VERSION" ] || die "cannot read the version from package.json"
|
||||||
|
[ -n "$TITLE" ] || die "cannot work out a release title"
|
||||||
|
|
||||||
|
# The repository is whatever this checkout pushes to, so a fork publishes to the
|
||||||
|
# fork without editing anything.
|
||||||
|
REPO="${REPO:-$(git remote get-url origin 2>/dev/null |
|
||||||
|
sed -e 's#.*[:/]\([^/]*/[^/]*\)$#\1#' -e 's#\.git$##')}"
|
||||||
|
[ -n "$REPO" ] || die "cannot work out the Gitea repo — set REPO=owner/name"
|
||||||
|
|
||||||
|
# What to upload: the arguments, or the packages in dist/ that belong to *this*
|
||||||
|
# version. Two things this has to get right:
|
||||||
|
#
|
||||||
|
# - the version filter, because dist/ keeps whatever earlier builds left there
|
||||||
|
# and a release would quietly get the previous version's files attached;
|
||||||
|
# - the spaces. "WarpEngine Store-1.1.0-arm64.dmg" has one, so the list lives one
|
||||||
|
# path per line in a file and is read with `while IFS= read -r`. Holding it in
|
||||||
|
# a single variable and looping over $list splits it on the space.
|
||||||
|
LIST="$(mktemp)"
|
||||||
|
trap 'rm -f "$LIST"' EXIT
|
||||||
|
if [ "$#" -gt 0 ]; then
|
||||||
|
for given in "$@"; do printf '%s\n' "$given"; done > "$LIST"
|
||||||
|
else
|
||||||
|
find "$DIST" -maxdepth 1 -type f -name "*$VERSION*" \
|
||||||
|
\( -name '*.dmg' -o -name '*-mac.zip' -o -name '*.exe' -o -name '*.AppImage' -o -name '*.deb' \) \
|
||||||
|
2>/dev/null | sort > "$LIST" || true
|
||||||
|
fi
|
||||||
|
[ -s "$LIST" ] || die "no $VERSION packages in $DIST — run 'make dist' first"
|
||||||
|
|
||||||
|
say "$REPO $TAG (version $VERSION), login $LOGIN"
|
||||||
|
|
||||||
|
# The release id, or empty when there is no such tag. `tea api` exits 0 even for a
|
||||||
|
# 404 — it answers {"message":"not found"} — so the body is what has to be read.
|
||||||
|
release_id() {
|
||||||
|
tea api "/repos/$REPO/releases/tags/$TAG" 2>/dev/null | python3 -c '
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
print(json.load(sys.stdin).get("id") or "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- the release itself ----------------------------------------------------
|
||||||
|
if [ -n "$(release_id)" ]; then
|
||||||
|
say "the release already exists"
|
||||||
|
else
|
||||||
|
say "creating the release: $TITLE"
|
||||||
|
if [ -f "$NOTES" ]; then
|
||||||
|
tea releases create --login "$LOGIN" --repo "$REPO" --tag "$TAG" \
|
||||||
|
--title "$TITLE" --note-file "$NOTES" >/dev/null
|
||||||
|
else
|
||||||
|
say "no $NOTES — the release gets a one-line note"
|
||||||
|
tea releases create --login "$LOGIN" --repo "$REPO" --tag "$TAG" \
|
||||||
|
--title "$TITLE" --note "Packages built from $TAG." >/dev/null
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_ID="$(release_id)"
|
||||||
|
[ -n "$RELEASE_ID" ] || die "the release $TAG could not be created or found"
|
||||||
|
|
||||||
|
# --- the attachments -------------------------------------------------------
|
||||||
|
while IFS= read -r asset; do
|
||||||
|
[ -n "$asset" ] || continue
|
||||||
|
[ -f "$asset" ] || die "no such file: $asset"
|
||||||
|
name="$(basename "$asset")"
|
||||||
|
|
||||||
|
# Replacing rather than refusing: a second run after a rebuild should land.
|
||||||
|
old="$(tea api "/repos/$REPO/releases/$RELEASE_ID/assets" | python3 -c "
|
||||||
|
import json, sys
|
||||||
|
name = sys.argv[1]
|
||||||
|
for a in json.load(sys.stdin):
|
||||||
|
if a['name'] == name:
|
||||||
|
print(a['id'])
|
||||||
|
" "$name")"
|
||||||
|
for id in $old; do
|
||||||
|
say "replacing $name"
|
||||||
|
tea api -X DELETE "/repos/$REPO/releases/$RELEASE_ID/assets/$id" >/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
size="$(python3 -c "import os,sys; print(f'{os.path.getsize(sys.argv[1])/1e6:.0f} MB')" "$asset")"
|
||||||
|
say "uploading $name ($size) — large packages take a few minutes"
|
||||||
|
tea releases assets create --login "$LOGIN" --repo "$REPO" "$TAG" "$asset" >/dev/null
|
||||||
|
done < "$LIST"
|
||||||
|
|
||||||
|
say "done:"
|
||||||
|
tea api "/repos/$REPO/releases/$RELEASE_ID" | python3 -c "
|
||||||
|
import json, sys
|
||||||
|
r = json.load(sys.stdin)
|
||||||
|
for a in r.get('assets') or []:
|
||||||
|
print(f\" {a['name']} {a['size']/1e6:.0f} MB\")
|
||||||
|
print(f\" {r['html_url']}\")"
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
const path = require('node:path')
|
const path = require('node:path')
|
||||||
const fs = require('node:fs')
|
const fs = require('node:fs')
|
||||||
const store = require('../lib/store')
|
const store = require('../lib/store')
|
||||||
|
const bootstrap = require('../lib/bootstrap')
|
||||||
const i18n = require('../lib/i18n')
|
const i18n = require('../lib/i18n')
|
||||||
|
|
||||||
function ok (label, value) {
|
function ok (label, value) {
|
||||||
@@ -34,6 +35,30 @@ async function main () {
|
|||||||
else ok(`strings:${lang}`, `${Object.keys(dict).length} keys`)
|
else ok(`strings:${lang}`, `${Object.keys(dict).length} keys`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The registry is what decides which stores exist, so it is checked before
|
||||||
|
// anything that depends on one being installed.
|
||||||
|
try {
|
||||||
|
const stores = await bootstrap.registry()
|
||||||
|
if (!stores.length) bad('registry', `${bootstrap.REGISTRY_URL} returned no stores`)
|
||||||
|
else {
|
||||||
|
ok('registry', `${stores.length} store(s) from ${bootstrap.REGISTRY_URL}`)
|
||||||
|
for (const store of stores) {
|
||||||
|
ok(` ${store.name}`, `${store.catalogUrl} · ${bootstrap.storeId(store)}`)
|
||||||
|
const url = bootstrap.configUrl(store.storeRepositoryUrl)
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(await bootstrap.fetchText(url))
|
||||||
|
ok(' config.json', `${Object.keys(config).length} sections`)
|
||||||
|
} catch (err) {
|
||||||
|
// Not fatal: the engine merges onto its defaults, so a store without a
|
||||||
|
// config file still installs.
|
||||||
|
ok(' config.json', `absent (${err.statusCode || err.message}) — defaults would be used`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
bad('registry', `${bootstrap.REGISTRY_URL}: ${err.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
let target = null
|
let target = null
|
||||||
if (process.env.SMOKE_HOME) {
|
if (process.env.SMOKE_HOME) {
|
||||||
const home = path.resolve(process.env.SMOKE_HOME)
|
const home = path.resolve(process.env.SMOKE_HOME)
|
||||||
|
|||||||
Reference in New Issue
Block a user