Files
warp-engine-client/main.js
T
mr.zeroandClaude Opus 5 e635a032ea Sign the macOS bundle, or it arrives "damaged"
The first release could not be opened: macOS said "WarpEngine Store is damaged
and can't be opened. You should move it to the Bin."

Not a wording problem — an integrity one. electron-builder found no signing
identity and skipped signing, so the bundle kept only the linker's ad-hoc
signature on its main executable, with no resource seal. `codesign --verify` said
"code has no resources but signature indicates they must be present", and
Gatekeeper reports that as damaged and offers no way past it, unlike an
un-notarised app which can at least be approved.

`scripts/after-pack.js` now signs the bundle itself during packaging. Measured on
a copy unzipped from the artifact with the quarantine flag set by hand:

  before  code has no resources but signature indicates they must be present
  after   valid on disk; satisfies its Designated Requirement

and the identifier is ours rather than `Electron`. `syspolicy_check` is down to
its expected "adhoc signed" warning. A downloaded copy still has to be approved —
that is Gatekeeper policy for anything un-notarised, and notarisation needs a paid
Developer ID — so the README and the release notes lead with the one command that
does it.

Two smaller things the failure turned up:

- The self-test was passing silently. With a copy of the app already open, the
  second process lost the single-instance lock and exited 0 with no output, which
  reads exactly like success. It now uses its own user-data directory and skips
  the lock, and it caught a real launch failure immediately afterwards.
- The README claimed right-click ▸ Open was enough. It was not, and I had not
  checked it — replaced with what the measurements support.

v1.0.0's attachments are withdrawn rather than left downloadable.

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

250 lines
8.5 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,
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 (!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))
})
}