Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f5da38a27 | ||
|
|
cb8a28b156 | ||
|
|
85c6d33b05 | ||
|
|
e635a032ea |
@@ -0,0 +1,88 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
release: 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,10 +31,67 @@ 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
|
||||||
|
|
||||||
@@ -49,18 +110,56 @@ your launcher, Dock or Start menu — the app does not have to be running to pla
|
|||||||
|
|
||||||
## 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 |
|
||||||
|
| `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 +175,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 +197,24 @@ 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 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,46 @@
|
|||||||
|
The client no longer carries a store address. It asks the site which stores exist
|
||||||
|
— `GET /api/stores` — and installs what comes back: one record and there is
|
||||||
|
nothing to decide, several and the setup screen shows a picker.
|
||||||
|
|
||||||
|
Adding a store is now a database row on the site, maintained from its admin panel,
|
||||||
|
rather than a release of this app.
|
||||||
|
|
||||||
|
### What a record gives it
|
||||||
|
|
||||||
|
- **`storeRepositoryUrl`** → the store's `config.json`, which stays the authority
|
||||||
|
on how that store behaves: platforms, statuses, where things land.
|
||||||
|
- **`catalogUrl`** and **`name`** override the config's `store.base_url` and
|
||||||
|
`store.name` — the registry is what says which catalog a store is *for*.
|
||||||
|
- **the store id**, from the repository name: `ttg-desktop-store` becomes `ttg`.
|
||||||
|
|
||||||
|
A store repository with no `config.json` still installs. The engine merges
|
||||||
|
whatever it is handed onto its own defaults, so the client writes a three-field
|
||||||
|
config and the store behaves like the default one pointed at that catalog.
|
||||||
|
|
||||||
|
`STORES_API` overrides the registry address, which is the one thing about a
|
||||||
|
particular site left in the client.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
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. The
|
||||||
|
window was checked on both outcomes: the grid with a store present, the setup gate
|
||||||
|
with a populated picker when there is none.
|
||||||
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
|
||||||
|
}
|
||||||
|
|||||||
+12
-4
@@ -23,10 +23,14 @@ const STRINGS = {
|
|||||||
updateAvailable: 'update available',
|
updateAvailable: 'update available',
|
||||||
log: 'Log',
|
log: 'Log',
|
||||||
noGames: 'No installable titles in the catalog.',
|
noGames: 'No installable titles in the catalog.',
|
||||||
setupTitle: 'Set up the store',
|
setupTitle: 'Set up a 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.',
|
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',
|
||||||
@@ -59,10 +63,14 @@ const STRINGS = {
|
|||||||
updateAvailable: 'frissítés elérhető',
|
updateAvailable: 'frissítés elérhető',
|
||||||
log: 'Napló',
|
log: 'Napló',
|
||||||
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',
|
setupTitle: '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é.',
|
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',
|
||||||
|
|||||||
@@ -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,6 +74,8 @@ 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,
|
||||||
paths: document.getElementById('log-paths').textContent.slice(0, 120),
|
paths: document.getElementById('log-paths').textContent.slice(0, 120),
|
||||||
@@ -74,7 +83,11 @@ async function selftest () {
|
|||||||
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
|
// Either outcome is a pass: a grid when a store is installed, 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.gateVisible && result.gateChoices.length > 0 && result.gateAction))
|
||||||
console.log(good ? 'SELFTEST OK' : 'SELFTEST FAILED')
|
console.log(good ? 'SELFTEST OK' : 'SELFTEST FAILED')
|
||||||
app.exit(good ? 0 : 1)
|
app.exit(good ? 0 : 1)
|
||||||
}
|
}
|
||||||
@@ -144,7 +157,8 @@ ipcMain.handle('app:state', () => {
|
|||||||
store: current ? { id: current.id, home: current.home } : null,
|
store: current ? { id: current.id, home: current.home } : null,
|
||||||
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()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -165,11 +179,23 @@ 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 }
|
||||||
|
return { id: current.id, home: current.home, name: chosen.name }
|
||||||
}))
|
}))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -215,7 +241,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.1.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
|
||||||
|
|||||||
+2
-1
@@ -12,7 +12,8 @@ contextBridge.exposeInMainWorld('storeApi', {
|
|||||||
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'),
|
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),
|
||||||
|
|||||||
+42
-7
@@ -203,18 +203,34 @@ async function runRemove (name) {
|
|||||||
|
|
||||||
// --- gate: no python, or no store yet -------------------------------------
|
// --- gate: no python, or no store yet -------------------------------------
|
||||||
|
|
||||||
function showGate (title, body, action, link) {
|
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
|
||||||
@@ -263,10 +279,10 @@ async function boot () {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const setUpStore = async () => {
|
const setUpStore = async (chosen) => {
|
||||||
showProgress(T.setupWorking)
|
showProgress(T.setupWorking)
|
||||||
try {
|
try {
|
||||||
await api.bootstrap()
|
await api.bootstrap(chosen)
|
||||||
hideGate()
|
hideGate()
|
||||||
await refresh()
|
await refresh()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -274,16 +290,35 @@ async function boot () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const offerStores = async () => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
showGate(T.oldEngineTitle,
|
||||||
`${T.oldEngineBody}\n\n${state.engine.text} → ${state.minEngine}`,
|
`${T.oldEngineBody}\n\n${state.engine.text} → ${state.minEngine}`,
|
||||||
{ label: T.oldEngineAction, onClick: setUpStore })
|
{ label: T.oldEngineAction, onClick: offerStores })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
<h1 id="gate-title"></h1>
|
<h1 id="gate-title"></h1>
|
||||||
<p id="gate-body"></p>
|
<p id="gate-body"></p>
|
||||||
<div class="gate-actions">
|
<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>
|
<button id="gate-action" class="btn btn-primary" hidden></button>
|
||||||
<a id="gate-link" class="link" href="#" hidden></a>
|
<a id="gate-link" class="link" href="#" hidden></a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-1
@@ -87,7 +87,8 @@ 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 {
|
||||||
|
|||||||
@@ -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
+96
@@ -0,0 +1,96 @@
|
|||||||
|
#!/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="$(python3 -c 'import json; print(json.load(open("package.json"))["version"])')"
|
||||||
|
TAG="${TAG:-v$VERSION}"
|
||||||
|
|
||||||
|
# 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 every package dist/ holds.
|
||||||
|
if [ "$#" -gt 0 ]; then
|
||||||
|
ASSETS="$*"
|
||||||
|
else
|
||||||
|
ASSETS="$(find "$DIST" -maxdepth 1 -type f \
|
||||||
|
\( -name '*.dmg' -o -name '*-mac.zip' -o -name '*.exe' -o -name '*.AppImage' -o -name '*.deb' \) \
|
||||||
|
2>/dev/null | sort || true)"
|
||||||
|
fi
|
||||||
|
[ -n "$ASSETS" ] || die "no packages in $DIST — run 'make dist' first"
|
||||||
|
|
||||||
|
say "$REPO $TAG (version $VERSION), login $LOGIN"
|
||||||
|
|
||||||
|
# --- the release itself ----------------------------------------------------
|
||||||
|
if tea api "/repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then
|
||||||
|
say "the release already exists"
|
||||||
|
else
|
||||||
|
say "creating the release"
|
||||||
|
if [ -f "$NOTES" ]; then
|
||||||
|
tea releases create --login "$LOGIN" --repo "$REPO" --tag "$TAG" \
|
||||||
|
--title "$(python3 -c 'import json; d=json.load(open("package.json")); print(f"{d.get(\"productName\") or d[\"name\"]} {d[\"version\"]}")')" \
|
||||||
|
--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 "$(python3 -c 'import json; d=json.load(open("package.json")); print(f"{d.get(\"productName\") or d[\"name\"]} {d[\"version\"]}")')" \
|
||||||
|
--note "Packages built from $TAG." >/dev/null
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_ID="$(tea api "/repos/$REPO/releases/tags/$TAG" |
|
||||||
|
python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
|
||||||
|
|
||||||
|
# --- the attachments -------------------------------------------------------
|
||||||
|
for asset in $ASSETS; do
|
||||||
|
[ -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
|
||||||
|
|
||||||
|
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