An Electron client for the desktop store
The desktop store put the catalog on ordinary computers, and then asked people to open a terminal — which on Windows is not even a workable ask, because the installer is `curl … | sh`. This is the window: a grid of cards, one click to install a title into the application menu, one to play it, one to remove it. The CLI stays the product. Every action runs `desktop_store.py --json`, so there is one catalog logic, one state file and one delete guard; the window never touches the filesystem itself. That is also why the engine grew `--json` first rather than this app growing a parser for prose. It doubles as the Windows install path: with no store on the machine, the app downloads the engine, the shared core and a config into the same folder the shell installer would use — Node's https, no curl. An engine older than 1.1.0 cannot be driven from a window, so the client checks the version and offers to refresh it instead of failing on the first call. Deliberate choices worth knowing: - No renderer framework and no build step. Plain HTML, CSS and JS, one runtime dependency. The whole UI is readable in one sitting. - `contextIsolation` on, `nodeIntegration` off, `sandbox` on, a CSP that permits only the app's own script and stylesheet plus images over HTTPS. The renderer can do exactly what preload.js exposes and nothing else. - English and Hungarian, following the system language. The CLIs and the docs stay English; this is the one end-user surface where that is not enough. - `ENGINES` is a list with one entry. The RetroArch store has the same command shape, so adding it is an entry, not a rewrite. Two ways to test it without a working installation in the way: `npm run smoke` drives the bridge with no window at all, and `npm run uitest` loads the window once and reports what rendered — the only way a renderer error would otherwise be noticed, since the main process log stays empty. Both accept a sandbox store through STORE_ROOT / SMOKE_HOME. Verified on macOS arm64, including the packaged .app: the store is found, ten titles list, a sync installs three, and the window renders them as installed with their Play and Remove buttons. Linux and Windows are unproven, as they are for the CLI itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# warp-engine-desktop-gui — a window for the desktop store
|
||||||
|
|
||||||
|
A graphical client for
|
||||||
|
[`warp-engine-desktop-store`](https://git.teletypegames.org/stores/warp-engine-desktop-store):
|
||||||
|
the catalog as a grid of cards, one click to install a title into your own
|
||||||
|
application menu, one to play it, one to remove it. Linux, macOS and Windows.
|
||||||
|
|
||||||
|
The CLI stays the product; this is its front door. Every action here runs
|
||||||
|
`desktop_store.py`, so there is one catalog logic, one state file and one delete
|
||||||
|
guard — the window never touches the filesystem itself.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## What it needs
|
||||||
|
|
||||||
|
- **Python 3** on the machine, because the store is a Python program. The app
|
||||||
|
looks for `python3`, `python` and `py -3`, and says so plainly if none answer.
|
||||||
|
- Nothing else at runtime. No Node, no package manager, no admin rights: the
|
||||||
|
store installs under your own user account.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Grab the package for your machine from the
|
||||||
|
[releases](https://git.teletypegames.org/stores/warp-engine-desktop-gui/releases)
|
||||||
|
and open it. On first run, if there is no store on the machine yet, the window
|
||||||
|
offers to download one — that is the whole setup.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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**.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
Both test scripts accept a sandbox store instead of the real one, which is how
|
||||||
|
this repository is tested without touching a working installation:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
STORE_ROOT=/tmp/sandbox-root npm start
|
||||||
|
SMOKE_HOME=/tmp/sandbox-root/ttg-desktop npm run smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
### How it is put together
|
||||||
|
|
||||||
|
| File | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `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/i18n.js` | the two string tables |
|
||||||
|
| `renderer/` | plain HTML, CSS and JS — no framework, no build step |
|
||||||
|
|
||||||
|
`contextIsolation` is on, `nodeIntegration` off, `sandbox` on, and the page
|
||||||
|
carries a CSP that allows only its own script and stylesheet plus images over
|
||||||
|
HTTPS. Links open in the real browser; the window itself never navigates.
|
||||||
|
|
||||||
|
`lib/store.js` talks to the CLI through `--json`, which puts data on stdout and
|
||||||
|
the human-readable log on stderr. That flag arrived with engine **1.1.0**, and the
|
||||||
|
client checks: an older store is met with an offer to refresh it rather than a
|
||||||
|
failed call.
|
||||||
|
|
||||||
|
The bridge keeps an `ENGINES` list with one entry today. The RetroArch store has
|
||||||
|
the same command shape, so a second entry is the whole change needed to drive it
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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`
|
||||||
|
launchers have been generated and read, but nobody has clicked one.
|
||||||
Vendored
+82
@@ -0,0 +1,82 @@
|
|||||||
|
'use strict'
|
||||||
|
// Setting up the 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.
|
||||||
|
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const https = require('node:https')
|
||||||
|
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`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
res.resume()
|
||||||
|
return reject(new Error(`${url} answered ${res.statusCode}`))
|
||||||
|
}
|
||||||
|
let body = ''
|
||||||
|
res.setEncoding('utf8')
|
||||||
|
res.on('data', (chunk) => { body += chunk })
|
||||||
|
res.on('end', () => resolve(body))
|
||||||
|
})
|
||||||
|
request.setTimeout(60000, () => request.destroy(new Error(`${url} timed out`)))
|
||||||
|
request.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download 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.
|
||||||
|
*/
|
||||||
|
async function install (home, { onLog = () => {} } = {}) {
|
||||||
|
fs.mkdirSync(home, { recursive: true })
|
||||||
|
const wrote = []
|
||||||
|
|
||||||
|
for (const [name, file] of [['engine', 'desktop_store.py'], ['core', 'warpstore.py']]) {
|
||||||
|
onLog(`downloading ${file}`)
|
||||||
|
const body = await fetchText(SOURCES[name])
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
onLog(`the store is set up in ${home}`)
|
||||||
|
return { home, config, script: path.join(home, 'desktop_store.py'), wrote }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { FORGE, SOURCES, fetchText, install }
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
'use strict'
|
||||||
|
// Two languages, the way the public site has them. The CLI and the docs stay
|
||||||
|
// English; this is the one end-user surface where Hungarian matters.
|
||||||
|
//
|
||||||
|
// Catalog text — titles, descriptions — is never translated here: it arrives
|
||||||
|
// from the store as it was published.
|
||||||
|
|
||||||
|
const STRINGS = {
|
||||||
|
en: {
|
||||||
|
appName: 'WarpEngine Store',
|
||||||
|
syncAll: 'Install all',
|
||||||
|
refresh: 'Refresh',
|
||||||
|
install: 'Install',
|
||||||
|
update: 'Update',
|
||||||
|
play: 'Play',
|
||||||
|
open: 'Open',
|
||||||
|
remove: 'Remove',
|
||||||
|
installed: 'installed',
|
||||||
|
native: 'native',
|
||||||
|
hosted: 'hosted',
|
||||||
|
hostedHint: 'Opens in your browser — needs the network',
|
||||||
|
nativeHint: 'Installed on this machine — works offline',
|
||||||
|
updateAvailable: 'update available',
|
||||||
|
log: 'Log',
|
||||||
|
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.',
|
||||||
|
setupAction: 'Download the store',
|
||||||
|
setupWorking: 'Setting up…',
|
||||||
|
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',
|
||||||
|
noPythonTitle: 'Python 3 is required',
|
||||||
|
noPythonBody: 'The store is a Python program, so Python 3 has to be installed. Install it, then reopen this window.',
|
||||||
|
pythonLink: 'python.org/downloads',
|
||||||
|
paths: 'Where things go',
|
||||||
|
openStoreFolder: 'Open the store folder',
|
||||||
|
openMenuFolder: 'Open the menu folder',
|
||||||
|
busy: 'Working…',
|
||||||
|
failed: 'failed',
|
||||||
|
removed: 'removed',
|
||||||
|
upToDate: 'Everything is up to date.',
|
||||||
|
of: 'of'
|
||||||
|
},
|
||||||
|
hu: {
|
||||||
|
appName: 'WarpEngine Store',
|
||||||
|
syncAll: 'Mind telepítése',
|
||||||
|
refresh: 'Frissítés',
|
||||||
|
install: 'Telepítés',
|
||||||
|
update: 'Frissítés',
|
||||||
|
play: 'Indítás',
|
||||||
|
open: 'Megnyitás',
|
||||||
|
remove: 'Eltávolítás',
|
||||||
|
installed: 'telepítve',
|
||||||
|
native: 'natív',
|
||||||
|
hosted: 'hosztolt',
|
||||||
|
hostedHint: 'A böngészőben nyílik meg — internet kell hozzá',
|
||||||
|
nativeHint: 'Erre a gépre telepítve — internet nélkül is megy',
|
||||||
|
updateAvailable: 'frissítés elérhető',
|
||||||
|
log: 'Napló',
|
||||||
|
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é.',
|
||||||
|
setupAction: 'Store letöltése',
|
||||||
|
setupWorking: 'Beállítás…',
|
||||||
|
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',
|
||||||
|
noPythonTitle: 'Python 3 kell hozzá',
|
||||||
|
noPythonBody: 'A store egy Python program, tehát Python 3 kell a gépre. Telepítsd, majd nyisd meg újra ezt az ablakot.',
|
||||||
|
pythonLink: 'python.org/downloads',
|
||||||
|
paths: 'Hova kerül',
|
||||||
|
openStoreFolder: 'Store könyvtár megnyitása',
|
||||||
|
openMenuFolder: 'Menü könyvtár megnyitása',
|
||||||
|
busy: 'Dolgozom…',
|
||||||
|
failed: 'hiba',
|
||||||
|
removed: 'eltávolítva',
|
||||||
|
upToDate: 'Minden naprakész.',
|
||||||
|
of: '/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK = 'en'
|
||||||
|
|
||||||
|
function pick (locale) {
|
||||||
|
const short = String(locale || '').slice(0, 2).toLowerCase()
|
||||||
|
return STRINGS[short] ? short : FALLBACK
|
||||||
|
}
|
||||||
|
|
||||||
|
function dict (locale) {
|
||||||
|
return STRINGS[pick(locale)]
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { FALLBACK, STRINGS, dict, pick, languages: Object.keys(STRINGS) }
|
||||||
+228
@@ -0,0 +1,228 @@
|
|||||||
|
'use strict'
|
||||||
|
// The bridge to the store CLI.
|
||||||
|
//
|
||||||
|
// The CLI is the product; this file only finds it and talks to it. Every
|
||||||
|
// operation is `desktop_store.py --json …`, which puts data on stdout and its
|
||||||
|
// log on stderr — so nothing here parses a sentence meant for a person.
|
||||||
|
//
|
||||||
|
// Shaped for more than one engine on purpose: ENGINES is a list today with one
|
||||||
|
// entry, and the RetroArch store could be added without touching the callers.
|
||||||
|
|
||||||
|
const { spawn, spawnSync } = require('node:child_process')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const os = require('node:os')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const ENGINES = [
|
||||||
|
{
|
||||||
|
id: 'desktop',
|
||||||
|
script: 'desktop_store.py',
|
||||||
|
// The installer names the store home `<store id>-desktop`, so the RetroArch
|
||||||
|
// engine can share the same root without sharing config.json and state.json.
|
||||||
|
homeSuffix: '-desktop',
|
||||||
|
launcherSuffix: '-desktop-store'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
/** The roots the shell installers use, in the same order they would. */
|
||||||
|
function storeRoots () {
|
||||||
|
const home = os.homedir()
|
||||||
|
const roots = []
|
||||||
|
if (process.env.STORE_ROOT) roots.push(process.env.STORE_ROOT)
|
||||||
|
if (process.env.XDG_DATA_HOME) {
|
||||||
|
roots.push(path.join(process.env.XDG_DATA_HOME, 'warp-engine-store'))
|
||||||
|
}
|
||||||
|
roots.push(path.join(home, '.local', 'share', 'warp-engine-store'))
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
roots.push(path.join(home, 'Library', 'Application Support', 'warp-engine-store'))
|
||||||
|
}
|
||||||
|
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
|
||||||
|
roots.push(path.join(process.env.LOCALAPPDATA, 'warp-engine-store'))
|
||||||
|
}
|
||||||
|
return [...new Set(roots)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every installed store this client can drive. */
|
||||||
|
function findStores () {
|
||||||
|
const found = []
|
||||||
|
for (const root of storeRoots()) {
|
||||||
|
let entries = []
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(root, { withFileTypes: true })
|
||||||
|
} catch { continue }
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue
|
||||||
|
const home = path.join(root, entry.name)
|
||||||
|
for (const engine of ENGINES) {
|
||||||
|
const script = path.join(home, engine.script)
|
||||||
|
const config = path.join(home, 'config.json')
|
||||||
|
if (fs.existsSync(script) && fs.existsSync(config)) {
|
||||||
|
found.push({ engine: engine.id, id: entry.name.replace(engine.homeSuffix, ''), home, script, config })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
function findStore () {
|
||||||
|
return findStores()[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a store would be installed if there is none yet. */
|
||||||
|
function defaultHome (storeId = 'ttg') {
|
||||||
|
const engine = ENGINES[0]
|
||||||
|
return path.join(storeRoots()[0], `${storeId}${engine.homeSuffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Python 3 is what the CLI needs, and its name differs per platform. `py -3` is
|
||||||
|
// the Windows launcher, which is often the only one on PATH.
|
||||||
|
const PYTHON_CANDIDATES = process.platform === 'win32'
|
||||||
|
? [['py', ['-3']], ['python', []], ['python3', []]]
|
||||||
|
: [['python3', []], ['python', []]]
|
||||||
|
|
||||||
|
let cachedPython = null
|
||||||
|
|
||||||
|
function findPython () {
|
||||||
|
if (cachedPython !== undefined && cachedPython !== null) return cachedPython
|
||||||
|
for (const [cmd, args] of PYTHON_CANDIDATES) {
|
||||||
|
try {
|
||||||
|
const probe = spawnSync(cmd, [...args, '--version'], { encoding: 'utf8', timeout: 10000 })
|
||||||
|
const out = `${probe.stdout || ''}${probe.stderr || ''}`
|
||||||
|
if (probe.status === 0 && /Python 3\./.test(out)) {
|
||||||
|
cachedPython = { cmd, args, version: out.trim() }
|
||||||
|
return cachedPython
|
||||||
|
}
|
||||||
|
} catch { /* try the next one */ }
|
||||||
|
}
|
||||||
|
cachedPython = null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
class StoreError extends Error {
|
||||||
|
constructor (message, code) {
|
||||||
|
super(message)
|
||||||
|
this.code = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The oldest engine that speaks `--json`. An older one is not broken, it simply
|
||||||
|
// cannot be driven from a window — and it will be met in the wild, because the
|
||||||
|
// CLI shipped before this client did.
|
||||||
|
const MIN_ENGINE = [1, 1, 0]
|
||||||
|
|
||||||
|
function parseVersion (text) {
|
||||||
|
const match = /(\d+)\.(\d+)\.(\d+)/.exec(String(text || ''))
|
||||||
|
return match ? match.slice(1, 4).map(Number) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function atLeast (version, minimum) {
|
||||||
|
if (!version) return false
|
||||||
|
for (let i = 0; i < minimum.length; i += 1) {
|
||||||
|
if ((version[i] || 0) > minimum[i]) return true
|
||||||
|
if ((version[i] || 0) < minimum[i]) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The installed engine's version string, and whether this client can drive it. */
|
||||||
|
function engineVersion (store) {
|
||||||
|
const python = findPython()
|
||||||
|
if (!python || !store) return null
|
||||||
|
try {
|
||||||
|
const probe = spawnSync(python.cmd, [...python.args, store.script, '--version'],
|
||||||
|
{ encoding: 'utf8', timeout: 15000 })
|
||||||
|
const text = `${probe.stdout || ''}${probe.stderr || ''}`.trim()
|
||||||
|
if (probe.status !== 0 || !text) return null
|
||||||
|
return { text, version: parseVersion(text), ok: atLeast(parseVersion(text), MIN_ENGINE) }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one CLI command.
|
||||||
|
*
|
||||||
|
* `onLine` gets every stdout line already parsed (the CLI emits one JSON object
|
||||||
|
* per line), `onLog` every stderr line as text. Resolves with the parsed lines.
|
||||||
|
*/
|
||||||
|
function run (store, args, { onLine, onLog, signal } = {}) {
|
||||||
|
const python = findPython()
|
||||||
|
if (!python) throw new StoreError('python3 was not found on this machine', 'NO_PYTHON')
|
||||||
|
if (!store) throw new StoreError('no store is installed yet', 'NO_STORE')
|
||||||
|
|
||||||
|
const argv = [...python.args, store.script, '--config', store.config, '--json', ...args]
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(python.cmd, argv, {
|
||||||
|
env: { ...process.env, DESKTOP_STORE_HOME: store.home },
|
||||||
|
signal
|
||||||
|
})
|
||||||
|
const lines = []
|
||||||
|
let stdoutRest = ''
|
||||||
|
let stderrRest = ''
|
||||||
|
|
||||||
|
const takeStdout = (chunk) => {
|
||||||
|
stdoutRest += chunk
|
||||||
|
const parts = stdoutRest.split('\n')
|
||||||
|
stdoutRest = parts.pop()
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!part.trim()) continue
|
||||||
|
let value
|
||||||
|
try {
|
||||||
|
value = JSON.parse(part)
|
||||||
|
} catch {
|
||||||
|
// Not ours to interpret — hand it on as a log line rather than crash.
|
||||||
|
if (onLog) onLog(part)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines.push(value)
|
||||||
|
if (onLine) onLine(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const takeStderr = (chunk) => {
|
||||||
|
stderrRest += chunk
|
||||||
|
const parts = stderrRest.split('\n')
|
||||||
|
stderrRest = parts.pop()
|
||||||
|
for (const part of parts) if (part.trim() && onLog) onLog(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout.setEncoding('utf8')
|
||||||
|
child.stderr.setEncoding('utf8')
|
||||||
|
child.stdout.on('data', takeStdout)
|
||||||
|
child.stderr.on('data', takeStderr)
|
||||||
|
child.on('error', (err) => reject(new StoreError(err.message, 'SPAWN_FAILED')))
|
||||||
|
child.on('close', (code) => {
|
||||||
|
takeStdout('\n')
|
||||||
|
takeStderr('\n')
|
||||||
|
if (code === 0) resolve(lines)
|
||||||
|
else reject(new StoreError(`the store exited with code ${code}`, 'CLI_FAILED'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function list (store, hooks) {
|
||||||
|
const lines = await run(store, ['list'], hooks)
|
||||||
|
return lines[lines.length - 1] || { games: [], skipped: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function paths (store, hooks) {
|
||||||
|
const lines = await run(store, ['paths'], hooks)
|
||||||
|
return lines[lines.length - 1] || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync (store, names = [], hooks) {
|
||||||
|
return run(store, ['sync', ...names], hooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove (store, name, hooks) {
|
||||||
|
return run(store, ['remove', name], hooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
function purge (store, hooks) {
|
||||||
|
return run(store, ['purge'], hooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ENGINES, MIN_ENGINE, StoreError, atLeast, defaultHome, engineVersion, findPython,
|
||||||
|
findStore, findStores, list, parseVersion, paths, purge, remove, run, storeRoots, sync
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
'use strict'
|
||||||
|
// Main process: one window, and the IPC that lets it drive the store CLI.
|
||||||
|
//
|
||||||
|
// The renderer gets no Node access at all (contextIsolation on, nodeIntegration
|
||||||
|
// off, sandbox on); everything it can do is in preload.js and handled here.
|
||||||
|
|
||||||
|
const { app, BrowserWindow, ipcMain, shell, dialog } = require('electron')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const { spawn } = require('node:child_process')
|
||||||
|
|
||||||
|
const store = require('./lib/store')
|
||||||
|
const bootstrap = require('./lib/bootstrap')
|
||||||
|
const i18n = require('./lib/i18n')
|
||||||
|
|
||||||
|
let win = null
|
||||||
|
let current = null // the store we are driving
|
||||||
|
let busy = false // one CLI call at a time
|
||||||
|
const prefsFile = () => path.join(app.getPath('userData'), 'prefs.json')
|
||||||
|
|
||||||
|
function loadPrefs () {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(prefsFile(), 'utf8'))
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePrefs (prefs) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(prefsFile()), { recursive: true })
|
||||||
|
fs.writeFileSync(prefsFile(), JSON.stringify(prefs, null, 2))
|
||||||
|
} catch { /* a lost preference is not worth an error dialog */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function send (channel, payload) {
|
||||||
|
if (win && !win.isDestroyed()) win.webContents.send(channel, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hooks = () => ({
|
||||||
|
onLog: (line) => send('store:log', line),
|
||||||
|
onLine: (event) => send('store:event', event)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** One CLI call at a time: the store writes files, and two writers would race. */
|
||||||
|
async function guarded (fn) {
|
||||||
|
if (busy) throw new Error('busy')
|
||||||
|
busy = true
|
||||||
|
send('store:busy', true)
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
send('store:busy', false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `--selftest` drives the window once and reports what rendered, so the UI has a
|
||||||
|
// check that does not need a pair of eyes. It is the only way a renderer error
|
||||||
|
// would otherwise be noticed: the main process log stays empty.
|
||||||
|
const SELFTEST = process.argv.includes('--selftest')
|
||||||
|
|
||||||
|
async function selftest () {
|
||||||
|
const result = await win.webContents.executeJavaScript(`(() => ({
|
||||||
|
cards: document.querySelectorAll('.card').length,
|
||||||
|
installed: document.querySelectorAll('.card.is-installed').length,
|
||||||
|
buttons: document.querySelectorAll('.card .actions button').length,
|
||||||
|
gateVisible: !document.getElementById('gate').hidden,
|
||||||
|
gateTitle: document.getElementById('gate-title').textContent,
|
||||||
|
appName: document.getElementById('app-name').textContent,
|
||||||
|
storeId: document.getElementById('store-id').textContent,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow () {
|
||||||
|
win = new BrowserWindow({
|
||||||
|
width: 1040,
|
||||||
|
height: 720,
|
||||||
|
minWidth: 760,
|
||||||
|
minHeight: 520,
|
||||||
|
backgroundColor: '#11151c',
|
||||||
|
title: 'WarpEngine Store',
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
webSecurity: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
win.loadFile(path.join(__dirname, 'renderer', 'index.html'))
|
||||||
|
|
||||||
|
// A renderer error is invisible from here otherwise.
|
||||||
|
win.webContents.on('console-message', (_event, level, message) => {
|
||||||
|
if (level >= 2 || SELFTEST) console.log(`[renderer] ${message}`)
|
||||||
|
})
|
||||||
|
win.webContents.on('render-process-gone', (_event, details) => {
|
||||||
|
console.log(`[renderer] gone: ${details.reason}`)
|
||||||
|
if (SELFTEST) app.exit(1)
|
||||||
|
})
|
||||||
|
if (SELFTEST) {
|
||||||
|
// The first list() has to finish before there is anything to look at.
|
||||||
|
win.webContents.once('did-finish-load', () => setTimeout(() => {
|
||||||
|
selftest().catch((err) => { console.log(`SELFTEST ERROR ${err.message}`); app.exit(1) })
|
||||||
|
}, 6000))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing in this app should ever navigate away or open a second window; a
|
||||||
|
// link the user clicks goes to their browser instead.
|
||||||
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
if (/^https:\/\//.test(url)) shell.openExternal(url)
|
||||||
|
return { action: 'deny' }
|
||||||
|
})
|
||||||
|
win.webContents.on('will-navigate', (event, url) => {
|
||||||
|
if (url !== win.webContents.getURL()) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (/^https:\/\//.test(url)) shell.openExternal(url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- IPC ------------------------------------------------------------------
|
||||||
|
|
||||||
|
ipcMain.handle('app:state', () => {
|
||||||
|
const prefs = loadPrefs()
|
||||||
|
const python = store.findPython()
|
||||||
|
current = store.findStore()
|
||||||
|
// 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
|
||||||
|
return {
|
||||||
|
locale: i18n.pick(prefs.locale || app.getLocale()),
|
||||||
|
languages: i18n.languages,
|
||||||
|
strings: i18n.dict(prefs.locale || app.getLocale()),
|
||||||
|
python: python ? python.version : null,
|
||||||
|
store: current ? { id: current.id, home: current.home } : null,
|
||||||
|
engine: engine ? { text: engine.text, ok: engine.ok } : null,
|
||||||
|
minEngine: store.MIN_ENGINE.join('.'),
|
||||||
|
defaultHome: store.defaultHome(),
|
||||||
|
version: app.getVersion()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('app:setLocale', (_event, locale) => {
|
||||||
|
const prefs = loadPrefs()
|
||||||
|
prefs.locale = i18n.pick(locale)
|
||||||
|
savePrefs(prefs)
|
||||||
|
return { locale: prefs.locale, strings: i18n.dict(prefs.locale) }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('store:list', () => guarded(() => store.list(current, hooks())))
|
||||||
|
ipcMain.handle('store:paths', () => guarded(() => store.paths(current, hooks())))
|
||||||
|
|
||||||
|
ipcMain.handle('store:sync', (_event, names) =>
|
||||||
|
guarded(() => store.sync(current, Array.isArray(names) ? names : [], hooks())))
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch what was installed.
|
||||||
|
*
|
||||||
|
* A hosted title is a URL, so it goes to the browser. A native one is whatever
|
||||||
|
* the store recorded: on macOS the app bundle through `open`, elsewhere the
|
||||||
|
* executable from its own directory — the same working directory the menu entry
|
||||||
|
* uses, because games load their assets relative to it.
|
||||||
|
*/
|
||||||
|
ipcMain.handle('store:launch', async (_event, game) => {
|
||||||
|
if (!game) return false
|
||||||
|
if (game.mode === 'web' && game.url) {
|
||||||
|
await shell.openExternal(game.url)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const target = game.menu_entry || game.exe
|
||||||
|
if (!target || !fs.existsSync(target)) return false
|
||||||
|
if (process.platform === 'darwin' && target.endsWith('.app')) {
|
||||||
|
spawn('open', [target], { detached: true, stdio: 'ignore' }).unref()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (process.platform === 'win32' || target.endsWith('.desktop')) {
|
||||||
|
const error = await shell.openPath(target)
|
||||||
|
if (!error) return true
|
||||||
|
}
|
||||||
|
const exe = game.exe || target
|
||||||
|
spawn(exe, [], { cwd: path.dirname(exe), detached: true, stdio: 'ignore' }).unref()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('app:openFolder', async (_event, dir) => {
|
||||||
|
if (!dir) return false
|
||||||
|
const error = await shell.openPath(dir)
|
||||||
|
return !error
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('app:openExternal', async (_event, url) => {
|
||||||
|
if (!/^https:\/\//.test(String(url))) return false
|
||||||
|
await shell.openExternal(String(url))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- lifecycle ------------------------------------------------------------
|
||||||
|
|
||||||
|
if (!app.requestSingleInstanceLock()) {
|
||||||
|
app.quit()
|
||||||
|
} else {
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
if (win) {
|
||||||
|
if (win.isMinimized()) win.restore()
|
||||||
|
win.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
createWindow()
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') app.quit()
|
||||||
|
})
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
dialog.showErrorBox('WarpEngine Store', String(reason && reason.message ? reason.message : reason))
|
||||||
|
})
|
||||||
|
}
|
||||||
Generated
+3598
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "warp-engine-desktop-gui",
|
||||||
|
"productName": "WarpEngine Store",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Graphical client for a WarpEngine desktop store: install the catalog into your own application menu.",
|
||||||
|
"license": "MIT",
|
||||||
|
"author": "Teletype Games <games@teletype.hu>",
|
||||||
|
"homepage": "https://git.teletypegames.org/stores/warp-engine-desktop-gui",
|
||||||
|
"main": "main.js",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron .",
|
||||||
|
"smoke": "node scripts/smoke.js",
|
||||||
|
"dist": "electron-builder",
|
||||||
|
"dist:mac": "electron-builder --mac",
|
||||||
|
"dist:win": "electron-builder --win",
|
||||||
|
"dist:linux": "electron-builder --linux",
|
||||||
|
"uitest": "electron . --selftest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^43.4.0",
|
||||||
|
"electron-builder": "^26.15.3"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "org.teletypegames.warpstore.gui",
|
||||||
|
"productName": "WarpEngine Store",
|
||||||
|
"files": [
|
||||||
|
"main.js",
|
||||||
|
"preload.js",
|
||||||
|
"lib/**/*",
|
||||||
|
"renderer/**/*"
|
||||||
|
],
|
||||||
|
"mac": {
|
||||||
|
"category": "public.app-category.games",
|
||||||
|
"target": [
|
||||||
|
"dmg",
|
||||||
|
"zip"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
"nsis",
|
||||||
|
"portable"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"category": "Game",
|
||||||
|
"target": [
|
||||||
|
"AppImage",
|
||||||
|
"deb"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"electron@43.4.0": true
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
'use strict'
|
||||||
|
// The whole surface the renderer gets. No Node, no fs, no child_process — just
|
||||||
|
// these calls and two event streams.
|
||||||
|
|
||||||
|
const { contextBridge, ipcRenderer } = require('electron')
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('storeApi', {
|
||||||
|
state: () => ipcRenderer.invoke('app:state'),
|
||||||
|
setLocale: (locale) => ipcRenderer.invoke('app:setLocale', locale),
|
||||||
|
|
||||||
|
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'),
|
||||||
|
launch: (game) => ipcRenderer.invoke('store:launch', game),
|
||||||
|
|
||||||
|
openFolder: (dir) => ipcRenderer.invoke('app:openFolder', dir),
|
||||||
|
openExternal: (url) => ipcRenderer.invoke('app:openExternal', url),
|
||||||
|
|
||||||
|
// Streams from the running CLI: `log` is a line a person can read, `event` is
|
||||||
|
// one of the store's JSON progress events.
|
||||||
|
onLog: (fn) => ipcRenderer.on('store:log', (_e, line) => fn(line)),
|
||||||
|
onEvent: (fn) => ipcRenderer.on('store:event', (_e, event) => fn(event)),
|
||||||
|
onBusy: (fn) => ipcRenderer.on('store:busy', (_e, value) => fn(value))
|
||||||
|
})
|
||||||
+321
@@ -0,0 +1,321 @@
|
|||||||
|
'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.
|
||||||
|
|
||||||
|
const api = window.storeApi
|
||||||
|
const el = (id) => document.getElementById(id)
|
||||||
|
|
||||||
|
let T = {} // the active string table
|
||||||
|
let games = []
|
||||||
|
let paths = null
|
||||||
|
let busy = false
|
||||||
|
let plan = null // { total, done } while a sync is running
|
||||||
|
|
||||||
|
// --- helpers --------------------------------------------------------------
|
||||||
|
|
||||||
|
function text (node, value) {
|
||||||
|
node.textContent = value == null ? '' : String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageUrl (game) {
|
||||||
|
if (!game.image_url) return null
|
||||||
|
if (/^https?:\/\//.test(game.image_url)) return game.image_url
|
||||||
|
const base = (paths && paths.store && paths.store.base_url) || ''
|
||||||
|
return base ? `${base}${game.image_url}` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function logLine (line) {
|
||||||
|
const box = el('log-lines')
|
||||||
|
const row = document.createElement('div')
|
||||||
|
row.className = 'log-line'
|
||||||
|
text(row, line)
|
||||||
|
box.appendChild(row)
|
||||||
|
while (box.childElementCount > 400) box.removeChild(box.firstChild)
|
||||||
|
box.scrollTop = box.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy (value) {
|
||||||
|
busy = value
|
||||||
|
for (const node of document.querySelectorAll('button')) {
|
||||||
|
if (node.id === 'log-toggle') continue
|
||||||
|
node.disabled = value
|
||||||
|
}
|
||||||
|
const progress = el('progress')
|
||||||
|
if (!value) {
|
||||||
|
progress.hidden = true
|
||||||
|
plan = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showProgress (label) {
|
||||||
|
const progress = el('progress')
|
||||||
|
progress.hidden = false
|
||||||
|
text(progress, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the card grid --------------------------------------------------------
|
||||||
|
|
||||||
|
function card (game) {
|
||||||
|
const node = document.createElement('article')
|
||||||
|
node.className = 'card'
|
||||||
|
if (game.installed) node.classList.add('is-installed')
|
||||||
|
|
||||||
|
const art = document.createElement('div')
|
||||||
|
art.className = 'art'
|
||||||
|
const src = imageUrl(game)
|
||||||
|
if (src) {
|
||||||
|
const img = document.createElement('img')
|
||||||
|
img.src = src
|
||||||
|
img.alt = ''
|
||||||
|
img.loading = 'lazy'
|
||||||
|
art.appendChild(img)
|
||||||
|
} else {
|
||||||
|
const glyph = document.createElement('span')
|
||||||
|
glyph.className = 'art-glyph'
|
||||||
|
text(glyph, game.title.slice(0, 1).toUpperCase())
|
||||||
|
art.appendChild(glyph)
|
||||||
|
}
|
||||||
|
node.appendChild(art)
|
||||||
|
|
||||||
|
const body = document.createElement('div')
|
||||||
|
body.className = 'body'
|
||||||
|
|
||||||
|
const title = document.createElement('h2')
|
||||||
|
text(title, game.title)
|
||||||
|
body.appendChild(title)
|
||||||
|
|
||||||
|
const meta = document.createElement('div')
|
||||||
|
meta.className = 'meta'
|
||||||
|
const mode = document.createElement('span')
|
||||||
|
mode.className = `badge badge-${game.mode}`
|
||||||
|
text(mode, game.mode === 'web' ? T.hosted : T.native)
|
||||||
|
mode.title = game.mode === 'web' ? T.hostedHint : T.nativeHint
|
||||||
|
meta.appendChild(mode)
|
||||||
|
const platform = document.createElement('span')
|
||||||
|
platform.className = 'badge badge-plain'
|
||||||
|
text(platform, game.platform)
|
||||||
|
meta.appendChild(platform)
|
||||||
|
const version = document.createElement('span')
|
||||||
|
version.className = 'version'
|
||||||
|
text(version, game.installed && game.installed_version
|
||||||
|
? `${game.installed_version} · ${T.installed}`
|
||||||
|
: game.version)
|
||||||
|
meta.appendChild(version)
|
||||||
|
body.appendChild(meta)
|
||||||
|
|
||||||
|
if (game.desc) {
|
||||||
|
const desc = document.createElement('p')
|
||||||
|
desc.className = 'desc'
|
||||||
|
text(desc, game.desc)
|
||||||
|
body.appendChild(desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = document.createElement('div')
|
||||||
|
actions.className = 'actions'
|
||||||
|
|
||||||
|
if (game.installed && !game.update_available) {
|
||||||
|
const play = document.createElement('button')
|
||||||
|
play.className = 'btn btn-primary'
|
||||||
|
text(play, game.mode === 'web' ? T.open : T.play)
|
||||||
|
play.addEventListener('click', () => api.launch(game))
|
||||||
|
actions.appendChild(play)
|
||||||
|
} else {
|
||||||
|
const install = document.createElement('button')
|
||||||
|
install.className = 'btn btn-primary'
|
||||||
|
text(install, game.update_available ? T.update : T.install)
|
||||||
|
install.addEventListener('click', () => runSync([game.name]))
|
||||||
|
actions.appendChild(install)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game.installed) {
|
||||||
|
const remove = document.createElement('button')
|
||||||
|
remove.className = 'btn btn-ghost'
|
||||||
|
text(remove, T.remove)
|
||||||
|
remove.addEventListener('click', () => runRemove(game.name))
|
||||||
|
actions.appendChild(remove)
|
||||||
|
}
|
||||||
|
|
||||||
|
body.appendChild(actions)
|
||||||
|
node.appendChild(body)
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid () {
|
||||||
|
const grid = el('grid')
|
||||||
|
grid.replaceChildren(...games.map(card))
|
||||||
|
grid.hidden = games.length === 0
|
||||||
|
const empty = el('empty')
|
||||||
|
empty.hidden = games.length !== 0
|
||||||
|
text(empty, T.noGames)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPaths () {
|
||||||
|
const box = el('log-paths')
|
||||||
|
box.replaceChildren()
|
||||||
|
if (!paths) return
|
||||||
|
const line = document.createElement('div')
|
||||||
|
line.className = 'paths-line'
|
||||||
|
text(line, `${T.paths}: ${paths.store_folder} · ${paths.menu_group}`)
|
||||||
|
box.appendChild(line)
|
||||||
|
|
||||||
|
for (const [label, dir] of [[T.openStoreFolder, paths.store_folder],
|
||||||
|
[T.openMenuFolder, paths.menu_group]]) {
|
||||||
|
const button = document.createElement('button')
|
||||||
|
button.className = 'btn btn-tiny'
|
||||||
|
text(button, label)
|
||||||
|
button.addEventListener('click', () => api.openFolder(dir))
|
||||||
|
box.appendChild(button)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- actions --------------------------------------------------------------
|
||||||
|
|
||||||
|
async function refresh () {
|
||||||
|
try {
|
||||||
|
const result = await api.list()
|
||||||
|
games = result.games || []
|
||||||
|
paths = result.paths || paths
|
||||||
|
renderGrid()
|
||||||
|
renderPaths()
|
||||||
|
for (const reason of result.skipped || []) logLine(`skipped ${reason}`)
|
||||||
|
} catch (err) {
|
||||||
|
logLine(String(err && err.message ? err.message : err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSync (names) {
|
||||||
|
try {
|
||||||
|
await api.sync(names || [])
|
||||||
|
} catch (err) {
|
||||||
|
logLine(String(err && err.message ? err.message : err))
|
||||||
|
}
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRemove (name) {
|
||||||
|
try {
|
||||||
|
await api.remove(name)
|
||||||
|
} catch (err) {
|
||||||
|
logLine(String(err && err.message ? err.message : err))
|
||||||
|
}
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- gate: no python, or no store yet -------------------------------------
|
||||||
|
|
||||||
|
function showGate (title, body, action, link) {
|
||||||
|
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)
|
||||||
|
const button = el('gate-action')
|
||||||
|
button.hidden = !action
|
||||||
|
if (action) {
|
||||||
|
text(button, action.label)
|
||||||
|
button.onclick = action.onClick
|
||||||
|
}
|
||||||
|
const anchor = el('gate-link')
|
||||||
|
anchor.hidden = !link
|
||||||
|
if (link) {
|
||||||
|
text(anchor, link.label)
|
||||||
|
anchor.onclick = (event) => { event.preventDefault(); api.openExternal(link.url) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideGate () {
|
||||||
|
el('gate').hidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- boot -----------------------------------------------------------------
|
||||||
|
|
||||||
|
function applyStrings (strings) {
|
||||||
|
T = strings
|
||||||
|
text(el('app-name'), T.appName)
|
||||||
|
text(el('sync-all'), T.syncAll)
|
||||||
|
text(el('refresh'), T.refresh)
|
||||||
|
text(el('log-toggle'), T.log)
|
||||||
|
renderPaths()
|
||||||
|
if (games.length) renderGrid()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot () {
|
||||||
|
const state = await api.state()
|
||||||
|
applyStrings(state.strings)
|
||||||
|
|
||||||
|
const select = el('locale')
|
||||||
|
select.replaceChildren(...state.languages.map((code) => {
|
||||||
|
const option = document.createElement('option')
|
||||||
|
option.value = code
|
||||||
|
option.textContent = code.toUpperCase()
|
||||||
|
if (code === state.locale) option.selected = true
|
||||||
|
return option
|
||||||
|
}))
|
||||||
|
select.addEventListener('change', async () => {
|
||||||
|
const next = await api.setLocale(select.value)
|
||||||
|
applyStrings(next.strings)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!state.python) {
|
||||||
|
showGate(T.noPythonTitle, T.noPythonBody, null,
|
||||||
|
{ label: T.pythonLink, url: 'https://www.python.org/downloads/' })
|
||||||
|
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([]) } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.engine && !state.engine.ok) {
|
||||||
|
showGate(T.oldEngineTitle,
|
||||||
|
`${T.oldEngineBody}\n\n${state.engine.text} → ${state.minEngine}`,
|
||||||
|
{ label: T.oldEngineAction, onClick: setUpStore })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
text(el('store-id'), state.store.id)
|
||||||
|
hideGate()
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
el('sync-all').addEventListener('click', () => runSync([]))
|
||||||
|
el('refresh').addEventListener('click', () => refresh())
|
||||||
|
el('log-toggle').addEventListener('click', () => {
|
||||||
|
const box = el('log-lines')
|
||||||
|
box.hidden = !box.hidden
|
||||||
|
el('log-toggle').setAttribute('aria-expanded', String(!box.hidden))
|
||||||
|
})
|
||||||
|
|
||||||
|
api.onLog(logLine)
|
||||||
|
api.onBusy(setBusy)
|
||||||
|
api.onEvent((event) => {
|
||||||
|
if (event.event === 'plan') {
|
||||||
|
plan = { total: event.count, done: 0 }
|
||||||
|
showProgress(`0 ${T.of} ${event.count}`)
|
||||||
|
} else if (event.event === 'begin' && plan) {
|
||||||
|
showProgress(`${plan.done + 1} ${T.of} ${plan.total} · ${event.title}`)
|
||||||
|
} else if (event.event === 'installed' && plan) {
|
||||||
|
plan.done += 1
|
||||||
|
logLine(`${event.title} — ${event.changed ? T.installed : T.upToDate}`)
|
||||||
|
} else if (event.event === 'failed') {
|
||||||
|
logLine(`${event.name}: ${T.failed} — ${event.error}`)
|
||||||
|
} else if (event.event === 'removed') {
|
||||||
|
logLine(`${event.name} — ${T.removed}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
boot()
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<!-- Nothing is loaded from the network except box art, and no inline code
|
||||||
|
runs: the app ships its own script and stylesheet. -->
|
||||||
|
<meta http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' https: data:; font-src 'self'; connect-src 'none'">
|
||||||
|
<title>WarpEngine Store</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="bar">
|
||||||
|
<div class="bar-title">
|
||||||
|
<span class="logo" aria-hidden="true">▚</span>
|
||||||
|
<span id="app-name">WarpEngine Store</span>
|
||||||
|
<span class="store-id" id="store-id"></span>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/* One dark theme, no assets: the box art is the only image the app loads. */
|
||||||
|
:root {
|
||||||
|
--bg: #11151c;
|
||||||
|
--panel: #182029;
|
||||||
|
--panel-2: #1e2732;
|
||||||
|
--line: #2a3440;
|
||||||
|
--ink: #e8eef5;
|
||||||
|
--ink-dim: #93a4b8;
|
||||||
|
--accent: #37b98a;
|
||||||
|
--accent-ink: #05130d;
|
||||||
|
--warn: #e0a44a;
|
||||||
|
--radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- top bar ------------------------------------------------------------ */
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.bar-title { display: flex; align-items: baseline; gap: 10px; font-weight: 700; }
|
||||||
|
.logo { color: var(--accent); font-size: 18px; }
|
||||||
|
.store-id {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
}
|
||||||
|
.bar-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.progress { color: var(--ink-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* --- controls ----------------------------------------------------------- */
|
||||||
|
.btn {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s, border-color .15s, transform .05s;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) { background: #26313e; border-color: #3a4757; }
|
||||||
|
.btn:active:not(:disabled) { transform: scale(.97); }
|
||||||
|
.btn:disabled { opacity: .45; cursor: default; }
|
||||||
|
.btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
|
||||||
|
.btn-primary:hover:not(:disabled) { background: #45cd9b; }
|
||||||
|
.btn-ghost { background: transparent; color: var(--ink-dim); }
|
||||||
|
.btn-tiny { padding: 3px 9px; font-size: 12px; font-weight: 500; }
|
||||||
|
.select {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
.link { color: var(--accent); cursor: pointer; text-decoration: underline; }
|
||||||
|
|
||||||
|
/* --- gate (no python, or no store yet) ---------------------------------- */
|
||||||
|
.gate {
|
||||||
|
margin: auto;
|
||||||
|
max-width: 520px;
|
||||||
|
padding: 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* --- the grid ----------------------------------------------------------- */
|
||||||
|
.grid {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
.empty { margin: auto; color: var(--ink-dim); }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.card.is-installed { border-color: #2f5a49; }
|
||||||
|
|
||||||
|
.art {
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
background: #0d1117;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.art img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.art-glyph { font-size: 44px; font-weight: 700; color: #263341; }
|
||||||
|
|
||||||
|
.body { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
|
||||||
|
.body h2 { font-size: 15px; margin: 0; }
|
||||||
|
.meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
.badge-app { color: var(--accent); border-color: #2f5a49; }
|
||||||
|
.badge-web { color: var(--warn); border-color: #5a4a2f; }
|
||||||
|
.version { font-size: 12px; color: var(--ink-dim); margin-left: auto; }
|
||||||
|
.desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.actions { display: flex; gap: 8px; margin-top: auto; }
|
||||||
|
|
||||||
|
/* --- log ---------------------------------------------------------------- */
|
||||||
|
.log {
|
||||||
|
flex: none;
|
||||||
|
background: var(--panel);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding: 8px 18px 10px;
|
||||||
|
}
|
||||||
|
.log-toggle {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0 0 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.log-lines {
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
background: #0d1117;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.log-line { white-space: pre-wrap; word-break: break-all; }
|
||||||
|
.log-paths { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.paths-line {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict'
|
||||||
|
// Drives the bridge without Electron: no window, no packaging, just the part
|
||||||
|
// that talks to the store. This is where an integration mistake shows up first,
|
||||||
|
// so it is the check to run after touching lib/store.js or the CLI.
|
||||||
|
//
|
||||||
|
// npm run smoke the store installed on this machine
|
||||||
|
// SMOKE_HOME=/path/to/store-home npm run smoke a sandbox store
|
||||||
|
|
||||||
|
const path = require('node:path')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const store = require('../lib/store')
|
||||||
|
const i18n = require('../lib/i18n')
|
||||||
|
|
||||||
|
function ok (label, value) {
|
||||||
|
console.log(` ok ${label}${value === undefined ? '' : `: ${value}`}`)
|
||||||
|
}
|
||||||
|
function bad (label, value) {
|
||||||
|
console.log(` FAIL ${label}${value === undefined ? '' : `: ${value}`}`)
|
||||||
|
process.exitCode = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main () {
|
||||||
|
console.log('warp-engine-desktop-gui smoke test')
|
||||||
|
|
||||||
|
const python = store.findPython()
|
||||||
|
if (python) ok('python', python.version)
|
||||||
|
else return bad('python', 'not found — the store cannot run')
|
||||||
|
|
||||||
|
for (const lang of i18n.languages) {
|
||||||
|
const dict = i18n.dict(lang)
|
||||||
|
const missing = Object.keys(i18n.STRINGS[i18n.FALLBACK]).filter((k) => !dict[k])
|
||||||
|
if (missing.length) bad(`strings:${lang}`, `missing ${missing.join(', ')}`)
|
||||||
|
else ok(`strings:${lang}`, `${Object.keys(dict).length} keys`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = null
|
||||||
|
if (process.env.SMOKE_HOME) {
|
||||||
|
const home = path.resolve(process.env.SMOKE_HOME)
|
||||||
|
target = {
|
||||||
|
engine: 'desktop',
|
||||||
|
id: path.basename(home).replace(/-desktop$/, ''),
|
||||||
|
home,
|
||||||
|
script: path.join(home, 'desktop_store.py'),
|
||||||
|
config: path.join(home, 'config.json')
|
||||||
|
}
|
||||||
|
for (const file of [target.script, target.config]) {
|
||||||
|
if (!fs.existsSync(file)) return bad('SMOKE_HOME', `${file} is missing`)
|
||||||
|
}
|
||||||
|
ok('store (SMOKE_HOME)', target.home)
|
||||||
|
} else {
|
||||||
|
const stores = store.findStores()
|
||||||
|
if (!stores.length) {
|
||||||
|
console.log(' skip no store installed — run the app once, or set SMOKE_HOME')
|
||||||
|
console.log(` it would be installed in ${store.defaultHome()}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target = stores[0]
|
||||||
|
ok('store found', `${target.id} in ${target.home}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = []
|
||||||
|
const paths = await store.paths(target, { onLog: (l) => logs.push(l) })
|
||||||
|
if (paths.os && paths.store_folder) ok('paths', `${paths.os} → ${paths.store_folder}`)
|
||||||
|
else bad('paths', JSON.stringify(paths))
|
||||||
|
|
||||||
|
const listing = await store.list(target, { onLog: (l) => logs.push(l) })
|
||||||
|
const games = listing.games || []
|
||||||
|
if (!games.length) return bad('list', 'no games came back')
|
||||||
|
|
||||||
|
const modes = games.reduce((acc, g) => {
|
||||||
|
acc[g.mode] = (acc[g.mode] || 0) + 1
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
ok('list', `${games.length} titles (${Object.entries(modes).map(([m, n]) => `${m}:${n}`).join(', ')})`)
|
||||||
|
|
||||||
|
const required = ['name', 'title', 'platform', 'version', 'mode', 'kind', 'installed', 'update_available']
|
||||||
|
const broken = games.filter((g) => required.some((k) => g[k] === undefined))
|
||||||
|
if (broken.length) bad('game shape', `${broken.length} entries miss a field`)
|
||||||
|
else ok('game shape', required.join(', '))
|
||||||
|
|
||||||
|
const hosted = games.filter((g) => g.mode === 'web')
|
||||||
|
if (hosted.length && !hosted.every((g) => /^https?:\/\//.test(g.url || ''))) {
|
||||||
|
bad('hosted urls', 'a web title has no usable url')
|
||||||
|
} else if (hosted.length) {
|
||||||
|
ok('hosted urls', hosted[0].url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const installed = games.filter((g) => g.installed)
|
||||||
|
ok('installed', `${installed.length} of ${games.length}`)
|
||||||
|
if (installed.length) {
|
||||||
|
const withTarget = installed.filter((g) => g.menu_entry || g.exe || g.url)
|
||||||
|
if (withTarget.length !== installed.length) bad('launch targets', 'an installed title has nothing to launch')
|
||||||
|
else ok('launch targets', 'every installed title has one')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logs.length) ok('stderr log', `${logs.length} lines (kept off stdout)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.log(` FAIL ${err && err.code ? err.code : 'error'}: ${err && err.message ? err.message : err}`)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user