Files
warp-engine-client/main.js
T
mr.zeroandClaude Opus 5 cb8a28b156 Ask a registry which store to install
The client had our store's config URL compiled into it, which meant a second store
— or anybody else's site — needed a release of this app. It now asks
`GET /api/stores` and installs what the site offers: one record and there is
nothing to decide, several and the setup screen shows a picker.

From a record the client works the rest out. `storeRepositoryUrl` gives the
`config.json` to read, and that file stays the authority on how the store behaves;
`catalogUrl` and `name` override its `store.base_url` and `store.name`, because the
registry is what says which catalog a store is *for*. The store id — which names
the store home and the folder games land in — comes from the repository name, so
`ttg-desktop-store` becomes `ttg`.

A 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. That was worth having
rather than an error, and it is tested.

The registry address is now the single thing about a particular site left in the
client, and `STORES_API` overrides it — which is how this was tested, against a
local endpoint serving the same payload the site returns, with one store that has a
config and one that has not. Both installed; the engine listed all ten titles with
the synthesised config.

`npm run uitest` now passes on either outcome — the grid when a store is present,
the setup gate with a populated picker when there is none — and it reports both, so
the gate cannot silently regress into an empty screen. Run with the registry
unreachable it produces the retry gate, and fails, which is the honest verdict: a
client that cannot reach the registry cannot set anything up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 13:13:36 +02:00

269 lines
9.4 KiB
JavaScript

'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')
// A test run must never be swallowed by a copy the user already has open: it gets
// its own user-data directory and skips the single-instance lock. Without this the
// second process exits silently with status 0, which reads as a passing test.
if (SELFTEST) {
app.setPath('userData', path.join(app.getPath('temp'), 'warpstore-gui-selftest'))
}
async function selftest () {
const result = await win.webContents.executeJavaScript(`(() => ({
cards: document.querySelectorAll('.card').length,
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,
gateChoices: [...document.getElementById('gate-select').options].map((o) => o.text),
gateAction: document.getElementById('gate-action').textContent,
appName: document.getElementById('app-name').textContent,
storeId: document.getElementById('store-id').textContent,
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))
// 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')
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('.'),
registryUrl: bootstrap.REGISTRY_URL,
storeRoot: store.storeRoots()[0],
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())))
// The stores this client can install, from the site's registry rather than from
// anything baked in here. A separate call because it needs the network: the first
// window paints without waiting for it.
ipcMain.handle('store:registry', async () => {
try {
return { stores: await bootstrap.registry() }
} catch (err) {
return { stores: [], error: err.message, url: bootstrap.REGISTRY_URL }
}
})
ipcMain.handle('store:bootstrap', (_event, chosen) => guarded(async () => {
if (!chosen) throw new Error('no store was chosen')
const home = bootstrap.homeFor(chosen, store.storeRoots()[0])
const result = await bootstrap.install(home, chosen, { onLog: (line) => send('store:log', line) })
current = { engine: 'desktop', ...result }
return { id: current.id, home: current.home, name: chosen.name }
}))
/**
* 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 (!SELFTEST && !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))
})
}