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
|
||||
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
|
||||
|
||||
- **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
|
||||
offers to download one — that is the whole setup.
|
||||
|
||||
The macOS build is **not signed or notarised**, so the first open needs
|
||||
*right-click ▸ Open* (or *System Settings ▸ Privacy & Security*). Nothing the
|
||||
store itself downloads is affected: those files are fetched by Python, which does
|
||||
not set the quarantine flag.
|
||||
### Opening it on macOS
|
||||
|
||||
The build is ad-hoc signed but **not notarised**, so macOS asks before running a
|
||||
copy that came from a browser. The reliable way through:
|
||||
|
||||
```sh
|
||||
xattr -dr com.apple.quarantine "/Applications/WarpEngine 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
|
||||
|
||||
- **Install all** fetches everything the catalog offers for this machine.
|
||||
- A card's button is **Install**, **Update**, or **Play** / **Open** once it is
|
||||
there. **Remove** takes a title back out.
|
||||
- Each card says whether it is **native** — unpacked and run locally, works
|
||||
offline — or **hosted**: a browser build the catalog serves rather than
|
||||
packages, so its entry opens a page and needs the network.
|
||||
- The **Log** drawer at the bottom carries the store's own output verbatim, and
|
||||
next to it are buttons that open the two folders everything lands in.
|
||||
- The language follows the system and can be switched; **English and Hungarian**.
|
||||
Everything that is not a title lives in the **side menu** on the left, and the
|
||||
`☰` button in the bar folds it away — the state is remembered between runs.
|
||||
|
||||
- **Stores** lists every store on this machine, the open one marked. Clicking
|
||||
another switches to it: the grid, the categories and the folders all follow, and
|
||||
the client reopens on that store next time. Two stores installed from the same
|
||||
catalog into different folders show their folder instead of their id, because
|
||||
the id would not tell them apart. **Add a store…** brings up the registry
|
||||
picker, the same one the first run offers.
|
||||
- **Actions** holds **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
|
||||
your launcher, Dock or Start menu — the app does not have to be running to play.
|
||||
|
||||
## Development
|
||||
|
||||
`make` is the front door; it wraps the npm scripts so the useful sequences have
|
||||
names. `make` on its own lists everything.
|
||||
|
||||
| Target | What it does |
|
||||
|---|---|
|
||||
| `make setup` | install the dependencies (checks the Node version first) |
|
||||
| `make 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
|
||||
npm install
|
||||
npm start # the window, against whatever store is installed
|
||||
npm run smoke # the bridge only: no window, no Electron
|
||||
npm run uitest # loads the window once and reports what rendered
|
||||
npm run dist:mac # or dist:win / dist:linux
|
||||
make release
|
||||
```
|
||||
|
||||
The tag comes from `package.json`, so `npm version patch` is the only place a
|
||||
version is set. The release is created if it is not there yet, and an attachment
|
||||
whose name is already on it is **replaced** rather than refused — so a rebuild and
|
||||
a second `make publish` lands rather than erroring.
|
||||
|
||||
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
|
||||
is ESM-only, and older Node cannot `require()` it. The packaged app carries its
|
||||
own runtime.
|
||||
|
||||
`npm run uitest` runs with its own user-data directory and without the
|
||||
single-instance lock. Otherwise a copy the user already has open swallows the test
|
||||
process, which exits 0 and reads as a pass.
|
||||
|
||||
Both test scripts accept a sandbox store instead of the real one, which is how
|
||||
this repository is tested without touching a working installation:
|
||||
|
||||
@@ -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 |
|
||||
| `preload.js` | the entire surface the renderer gets — no Node reaches it |
|
||||
| `lib/store.js` | finds the store and Python, runs the CLI, parses its JSON |
|
||||
| `lib/bootstrap.js` | downloads the engine, the shared core and a config |
|
||||
| `lib/bootstrap.js` | reads the registry, then downloads the engine, the core and a config |
|
||||
| `lib/i18n.js` | the two string tables |
|
||||
| `renderer/` | plain HTML, CSS and JS — no framework, no build step |
|
||||
| `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
|
||||
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
|
||||
|
||||
Exercised on macOS (arm64): the store is discovered, the catalog lists, a sync
|
||||
installs, the window renders the installed state, and `npm run uitest` passes with
|
||||
the grid rendered and both languages in the picker. The bootstrap download was run
|
||||
into an empty directory and the resulting store answered the bridge.
|
||||
Exercised on macOS (arm64), with the packaged app from the release rather than a
|
||||
dev run: the store is discovered, the catalog lists, a sync installs, the window
|
||||
renders the installed state, and `npm run uitest` passes with the grid rendered
|
||||
and both languages in the picker. The bootstrap download was run into an empty
|
||||
directory and the resulting store answered the bridge.
|
||||
|
||||
The grid was checked by looking at it, not only by counting nodes: `SELFTEST_SHOT`
|
||||
has the window capture itself, which is how the collapsed rows were found — the DOM
|
||||
had ten cards and twenty buttons all along, and every count passed while the page
|
||||
showed neither art nor buttons. A screenshot from outside the app is not available
|
||||
here, so the window takes its own.
|
||||
|
||||
The side menu was measured with two stores in one root — a sandbox copy alongside
|
||||
the real install — and `npm run uitest` clicks the store that is not open and
|
||||
checks that the bar, the grid and the categories follow. With a single store the
|
||||
switch is skipped, which is what the normal run reports.
|
||||
|
||||
The registry path was exercised against a local endpoint serving the same payload
|
||||
the site returns, with two records: one store whose repository has a `config.json`
|
||||
and one without. Both installed, and the engine listed all ten titles with the
|
||||
synthesised config. `npm run uitest` was run twice — with a store present it shows
|
||||
the grid, with none it shows the setup gate and its picker carries both names —
|
||||
and once more with the registry unreachable, which produces the retry gate.
|
||||
|
||||
The signing was measured rather than assumed, by setting the quarantine flag on a
|
||||
copy unzipped from the release artifact: `codesign --verify --deep --strict` is
|
||||
clean, and `syspolicy_check` reports only the expected *"adhoc signed"* warning.
|
||||
A quarantined copy is still stopped until it is approved — that part is Gatekeeper
|
||||
policy, not a fault in the package.
|
||||
|
||||
**Not tried on Linux or Windows.** The paths and the launch behaviour are written
|
||||
for them, and the store CLI itself has the same gap — `.desktop` and `.lnk`
|
||||
|
||||
@@ -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'
|
||||
// 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
|
||||
// `curl … | sh`, which Windows does not have. The three files it would place are
|
||||
// downloaded here instead, into the very same store home — so the CLI and the
|
||||
// client stay one installation, and running install.sh afterwards only adds the
|
||||
// launcher script.
|
||||
//
|
||||
// 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 https = require('node:https')
|
||||
const http = require('node:http')
|
||||
const path = require('node:path')
|
||||
|
||||
const FORGE = 'https://git.teletypegames.org'
|
||||
const SOURCES = {
|
||||
engine: `${FORGE}/stores/warp-engine-desktop-store/raw/branch/master/desktop_store.py`,
|
||||
core: `${FORGE}/engines/warpstore/raw/branch/master/warpstore.py`,
|
||||
config: `${FORGE}/stores/ttg-desktop-store/raw/branch/master/config.json`
|
||||
// Where the engine itself comes from. Not part of the registry: this is the
|
||||
// client's own machinery, the same for every store it can drive.
|
||||
const FORGE = process.env.FORGE_BASE || 'https://git.teletypegames.org'
|
||||
const ENGINE_SOURCES = {
|
||||
'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. */
|
||||
function fetchText (url, redirects = 5) {
|
||||
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) {
|
||||
res.resume()
|
||||
if (redirects <= 0) return reject(new Error(`too many redirects for ${url}`))
|
||||
const next = new URL(res.headers.location, url).toString()
|
||||
return fetchText(next, redirects - 1).then(resolve, reject)
|
||||
return fetchText(new URL(res.headers.location, url).toString(), redirects - 1).then(resolve, reject)
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
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 = ''
|
||||
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
|
||||
* silence looks like a hang. An existing config is left alone: a store that is
|
||||
* already set up keeps its settings.
|
||||
* silence looks like a hang.
|
||||
*/
|
||||
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 })
|
||||
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}`)
|
||||
const body = await fetchText(SOURCES[name])
|
||||
const body = await fetchText(url)
|
||||
if (!body.startsWith('#!/usr/bin/env python3')) {
|
||||
throw new Error(`${file} does not look like the store engine — refusing to install it`)
|
||||
}
|
||||
const dest = path.join(home, file)
|
||||
fs.writeFileSync(dest, body, { mode: 0o755 })
|
||||
wrote.push(dest)
|
||||
fs.writeFileSync(path.join(home, file), body, { mode: 0o755 })
|
||||
}
|
||||
|
||||
const config = path.join(home, 'config.json')
|
||||
if (fs.existsSync(config)) {
|
||||
onLog('keeping the config already in place')
|
||||
} else {
|
||||
onLog('downloading config.json')
|
||||
const body = await fetchText(SOURCES.config)
|
||||
JSON.parse(body) // a broken config would fail later and less clearly
|
||||
fs.writeFileSync(config, body)
|
||||
wrote.push(config)
|
||||
}
|
||||
const configPath = path.join(home, 'config.json')
|
||||
const config = await storeConfig(store, { onLog })
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||
|
||||
onLog(`the store is set up in ${home}`)
|
||||
return { home, config, script: path.join(home, 'desktop_store.py'), wrote }
|
||||
onLog(`${store.name} is set up in ${home}`)
|
||||
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',
|
||||
updateAvailable: 'update available',
|
||||
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.',
|
||||
setupTitle: 'Set up the store',
|
||||
setupBody: 'The store engine is not on this machine yet. It can be downloaded now — the same files the shell installer would place, in the same folder.',
|
||||
noMatch: 'Nothing in this category.',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
@@ -58,11 +76,29 @@ const STRINGS = {
|
||||
nativeHint: 'Erre a gépre telepítve — internet nélkül is megy',
|
||||
updateAvailable: 'frissítés elérhető',
|
||||
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.',
|
||||
setupTitle: 'A store beállítása',
|
||||
setupBody: 'A store motorja még nincs ezen a gépen. Most letölthető — ugyanazok a fájlok, ugyanabba a könyvtárba, ahová a shell-telepítő tenné.',
|
||||
noMatch: 'Ebben a kategóriában nincs semmi.',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
|
||||
+34
-4
@@ -65,8 +65,37 @@ function findStores () {
|
||||
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. */
|
||||
@@ -223,6 +252,7 @@ function purge (store, hooks) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ENGINES, MIN_ENGINE, StoreError, atLeast, defaultHome, engineVersion, findPython,
|
||||
findStore, findStores, list, parseVersion, paths, purge, remove, run, storeRoots, sync
|
||||
ENGINES, MIN_ENGINE, StoreError, atLeast, defaultHome, describe, engineVersion,
|
||||
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.
|
||||
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 () {
|
||||
const result = await win.webContents.executeJavaScript(`(() => ({
|
||||
cards: document.querySelectorAll('.card').length,
|
||||
@@ -67,16 +74,73 @@ async function selftest () {
|
||||
buttons: document.querySelectorAll('.card .actions button').length,
|
||||
gateVisible: !document.getElementById('gate').hidden,
|
||||
gateTitle: document.getElementById('gate-title').textContent,
|
||||
gateChoices: [...document.getElementById('gate-select').options].map((o) => o.text),
|
||||
gateAction: document.getElementById('gate-action').textContent,
|
||||
appName: document.getElementById('app-name').textContent,
|
||||
storeId: document.getElementById('store-id').textContent,
|
||||
navOpen: !document.body.classList.contains('nav-closed'),
|
||||
stores: [...document.querySelectorAll('#store-list .store-row')].map((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),
|
||||
logLines: document.querySelectorAll('.log-line').length,
|
||||
locales: [...document.getElementById('locale').options].map((o) => o.value)
|
||||
}))()`)
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
const good = result.cards > 0 && !result.gateVisible && result.locales.length > 1
|
||||
console.log(good ? 'SELFTEST OK' : 'SELFTEST FAILED')
|
||||
app.exit(good ? 0 : 1)
|
||||
|
||||
// With two stores on the machine the switcher is the thing most likely to be
|
||||
// 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 () {
|
||||
@@ -132,7 +196,10 @@ function createWindow () {
|
||||
ipcMain.handle('app:state', () => {
|
||||
const prefs = loadPrefs()
|
||||
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
|
||||
// says so and offers to refresh it rather than failing on the first call.
|
||||
const engine = current ? store.engineVersion(current) : null
|
||||
@@ -140,15 +207,48 @@ ipcMain.handle('app:state', () => {
|
||||
locale: i18n.pick(prefs.locale || app.getLocale()),
|
||||
languages: i18n.languages,
|
||||
strings: i18n.dict(prefs.locale || app.getLocale()),
|
||||
nav: prefs.nav !== false,
|
||||
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,
|
||||
minEngine: store.MIN_ENGINE.join('.'),
|
||||
defaultHome: store.defaultHome(),
|
||||
registryUrl: bootstrap.REGISTRY_URL,
|
||||
storeRoot: store.storeRoots()[0],
|
||||
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) => {
|
||||
const prefs = loadPrefs()
|
||||
prefs.locale = i18n.pick(locale)
|
||||
@@ -165,11 +265,26 @@ ipcMain.handle('store:sync', (_event, names) =>
|
||||
ipcMain.handle('store:remove', (_event, name) =>
|
||||
guarded(() => store.remove(current, String(name), hooks())))
|
||||
|
||||
ipcMain.handle('store:bootstrap', () => guarded(async () => {
|
||||
const home = store.defaultHome()
|
||||
const result = await bootstrap.install(home, { onLog: (line) => send('store:log', line) })
|
||||
current = { engine: 'desktop', id: path.basename(home).replace(/-desktop$/, ''), ...result }
|
||||
return { id: current.id, home: current.home }
|
||||
// The stores this client can install, from the site's registry rather than from
|
||||
// anything baked in here. A separate call because it needs the network: the first
|
||||
// window paints without waiting for it.
|
||||
ipcMain.handle('store:registry', async () => {
|
||||
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 ------------------------------------------------------------
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
if (!SELFTEST && !app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "warp-engine-desktop-gui",
|
||||
"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.",
|
||||
"license": "MIT",
|
||||
"author": "Teletype Games <games@teletype.hu>",
|
||||
@@ -51,7 +51,8 @@
|
||||
"AppImage",
|
||||
"deb"
|
||||
]
|
||||
}
|
||||
},
|
||||
"afterPack": "scripts/after-pack.js"
|
||||
},
|
||||
"allowScripts": {
|
||||
"electron@43.4.0": true
|
||||
|
||||
+4
-1
@@ -7,12 +7,15 @@ const { contextBridge, ipcRenderer } = require('electron')
|
||||
contextBridge.exposeInMainWorld('storeApi', {
|
||||
state: () => ipcRenderer.invoke('app:state'),
|
||||
setLocale: (locale) => ipcRenderer.invoke('app:setLocale', locale),
|
||||
setNav: (open) => ipcRenderer.invoke('app:setNav', open),
|
||||
|
||||
list: () => ipcRenderer.invoke('store:list'),
|
||||
paths: () => ipcRenderer.invoke('store:paths'),
|
||||
sync: (names) => ipcRenderer.invoke('store:sync', names),
|
||||
remove: (name) => ipcRenderer.invoke('store:remove', name),
|
||||
bootstrap: () => ipcRenderer.invoke('store:bootstrap'),
|
||||
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),
|
||||
|
||||
openFolder: (dir) => ipcRenderer.invoke('app:openFolder', dir),
|
||||
|
||||
+262
-28
@@ -1,16 +1,22 @@
|
||||
'use strict'
|
||||
// The whole renderer. No framework and no build step: the app is a grid of
|
||||
// cards, and every action is one call over the bridge in preload.js.
|
||||
// The whole renderer. No framework and no build step: a side menu on the left
|
||||
// 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 el = (id) => document.getElementById(id)
|
||||
|
||||
let T = {} // the active string table
|
||||
let state = null // what the main process knows: stores, python, engine
|
||||
let games = []
|
||||
let paths = null
|
||||
let busy = false
|
||||
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 --------------------------------------------------------------
|
||||
|
||||
function text (node, value) {
|
||||
@@ -34,10 +40,14 @@ function logLine (line) {
|
||||
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) {
|
||||
busy = value
|
||||
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
|
||||
}
|
||||
const progress = el('progress')
|
||||
@@ -53,6 +63,136 @@ function showProgress (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 --------------------------------------------------------
|
||||
|
||||
function card (game) {
|
||||
@@ -70,6 +210,8 @@ function card (game) {
|
||||
img.loading = 'lazy'
|
||||
art.appendChild(img)
|
||||
} 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')
|
||||
glyph.className = 'art-glyph'
|
||||
text(glyph, game.title.slice(0, 1).toUpperCase())
|
||||
@@ -141,12 +283,14 @@ function card (game) {
|
||||
}
|
||||
|
||||
function renderGrid () {
|
||||
const shown = games.filter(matches)
|
||||
const grid = el('grid')
|
||||
grid.replaceChildren(...games.map(card))
|
||||
grid.hidden = games.length === 0
|
||||
grid.replaceChildren(...shown.map(card))
|
||||
grid.hidden = shown.length === 0
|
||||
grid.scrollTop = 0
|
||||
const empty = el('empty')
|
||||
empty.hidden = games.length !== 0
|
||||
text(empty, T.noGames)
|
||||
empty.hidden = shown.length !== 0
|
||||
text(empty, games.length === 0 ? T.noGames : T.noMatch)
|
||||
}
|
||||
|
||||
function renderPaths () {
|
||||
@@ -175,6 +319,7 @@ async function refresh () {
|
||||
const result = await api.list()
|
||||
games = result.games || []
|
||||
paths = result.paths || paths
|
||||
renderCats()
|
||||
renderGrid()
|
||||
renderPaths()
|
||||
for (const reason of result.skipped || []) logLine(`skipped ${reason}`)
|
||||
@@ -201,20 +346,66 @@ async function runRemove (name) {
|
||||
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('empty').hidden = true
|
||||
const gate = el('gate')
|
||||
gate.hidden = false
|
||||
text(el('gate-title'), title)
|
||||
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')
|
||||
button.hidden = !action
|
||||
if (action) {
|
||||
text(button, action.label)
|
||||
button.onclick = action.onClick
|
||||
button.onclick = () => action.onClick(choices ? choices[Number(select.value) || 0] : undefined)
|
||||
}
|
||||
const anchor = el('gate-link')
|
||||
anchor.hidden = !link
|
||||
@@ -228,6 +419,44 @@ function hideGate () {
|
||||
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 -----------------------------------------------------------------
|
||||
|
||||
function applyStrings (strings) {
|
||||
@@ -236,13 +465,26 @@ function applyStrings (strings) {
|
||||
text(el('sync-all'), T.syncAll)
|
||||
text(el('refresh'), T.refresh)
|
||||
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()
|
||||
if (games.length) renderGrid()
|
||||
if (state) renderStores()
|
||||
if (games.length) {
|
||||
renderCats()
|
||||
renderGrid()
|
||||
}
|
||||
}
|
||||
|
||||
async function boot () {
|
||||
const state = await api.state()
|
||||
state = await api.state()
|
||||
applyStrings(state.strings)
|
||||
setNav(state.nav !== false)
|
||||
renderStores()
|
||||
|
||||
const select = el('locale')
|
||||
select.replaceChildren(...state.languages.map((code) => {
|
||||
@@ -263,27 +505,13 @@ async function boot () {
|
||||
return
|
||||
}
|
||||
|
||||
const setUpStore = async () => {
|
||||
showProgress(T.setupWorking)
|
||||
try {
|
||||
await api.bootstrap()
|
||||
hideGate()
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
logLine(String(err && err.message ? err.message : err))
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.store) {
|
||||
showGate(T.setupTitle, `${T.setupBody}\n\n${state.defaultHome}`,
|
||||
{ label: T.setupAction, onClick: async () => { await setUpStore(); await runSync([]) } })
|
||||
await offerStores()
|
||||
return
|
||||
}
|
||||
|
||||
if (state.engine && !state.engine.ok) {
|
||||
showGate(T.oldEngineTitle,
|
||||
`${T.oldEngineBody}\n\n${state.engine.text} → ${state.minEngine}`,
|
||||
{ label: T.oldEngineAction, onClick: setUpStore })
|
||||
showOldEngineGate()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -294,6 +522,12 @@ async function boot () {
|
||||
|
||||
el('sync-all').addEventListener('click', () => runSync([]))
|
||||
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', () => {
|
||||
const box = el('log-lines')
|
||||
box.hidden = !box.hidden
|
||||
|
||||
+57
-21
@@ -11,6 +11,9 @@
|
||||
</head>
|
||||
<body>
|
||||
<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">
|
||||
<span class="logo" aria-hidden="true">▚</span>
|
||||
<span id="app-name">WarpEngine Store</span>
|
||||
@@ -18,31 +21,64 @@
|
||||
</div>
|
||||
<div class="bar-actions">
|
||||
<span class="progress" id="progress" hidden></span>
|
||||
<button id="sync-all" class="btn btn-primary" disabled></button>
|
||||
<button id="refresh" class="btn" disabled></button>
|
||||
<select id="locale" class="select" aria-label="Language"></select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Shown instead of the grid when there is nothing to drive yet. -->
|
||||
<section id="gate" class="gate" hidden>
|
||||
<h1 id="gate-title"></h1>
|
||||
<p id="gate-body"></p>
|
||||
<div class="gate-actions">
|
||||
<button id="gate-action" class="btn btn-primary" hidden></button>
|
||||
<a id="gate-link" class="link" href="#" hidden></a>
|
||||
<div class="shell">
|
||||
<!-- The side menu: which store, what to do with it, and what to look at.
|
||||
Collapsed with the button in the bar; the choice is remembered. -->
|
||||
<aside class="side" id="side">
|
||||
<section class="side-block">
|
||||
<h2 class="side-head" id="head-stores"></h2>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
+138
-6
@@ -25,18 +25,138 @@ body {
|
||||
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 ------------------------------------------------------------ */
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 18px;
|
||||
gap: 12px;
|
||||
padding: 10px 18px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
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; }
|
||||
.store-id {
|
||||
font-weight: 500;
|
||||
@@ -87,19 +207,28 @@ body {
|
||||
}
|
||||
.gate h1 { font-size: 20px; margin: 0 0 10px; }
|
||||
.gate p { color: var(--ink-dim); white-space: pre-line; margin: 0 0 20px; word-break: break-all; }
|
||||
.gate-actions { display: flex; gap: 14px; justify-content: center; align-items: center; }
|
||||
.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 ----------------------------------------------------------- */
|
||||
.grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
.empty { margin: auto; color: var(--ink-dim); }
|
||||
.gate { overflow-y: auto; }
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
@@ -112,7 +241,10 @@ body {
|
||||
.card.is-installed { border-color: #2f5a49; }
|
||||
|
||||
.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;
|
||||
display: flex;
|
||||
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 fs = require('node:fs')
|
||||
const store = require('../lib/store')
|
||||
const bootstrap = require('../lib/bootstrap')
|
||||
const i18n = require('../lib/i18n')
|
||||
|
||||
function ok (label, value) {
|
||||
@@ -34,6 +35,30 @@ async function main () {
|
||||
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
|
||||
if (process.env.SMOKE_HOME) {
|
||||
const home = path.resolve(process.env.SMOKE_HOME)
|
||||
|
||||
Reference in New Issue
Block a user