Files
peardata/electron/main.cjs
T
Raven Scott 4b5b78892f
CI / test (push) Successful in 2m45s
Release rolling / release (push) Successful in 11m36s
FIX: QVAC Updates
2026-07-30 15:23:43 -04:00

379 lines
10 KiB
JavaScript

/**
* PearData desktop client — Electron shell.
*
* - Electron owns the window chrome
* - Renderer loads index.html over localhost; GUI code is require()'d as
* electron/app.bundle.cjs (esbuild CJS of app.js + local modules)
* - Pear polyfill provides config.storage, exit, teardown for UI code
*
* Dev: npm run start:client (builds bundle then launches)
* Pack: npm run make:client
*/
'use strict'
const { app, BrowserWindow, ipcMain, Menu } = require('electron')
const path = require('path')
const fs = require('fs')
const http = require('http')
const pkg = require('../package.json')
const appName = pkg.productName || pkg.name || 'PearData'
// ---- CLI flags ----
const argv = app.isPackaged ? process.argv.slice(1) : process.argv.slice(2)
let storageOverride = null
let noSandbox = false
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--storage') storageOverride = argv[++i]
if (argv[i] === '--no-sandbox') noSandbox = true
}
if (noSandbox || process.platform === 'linux') {
app.commandLine.appendSwitch('no-sandbox')
}
if (storageOverride) {
app.setPath('userData', storageOverride)
}
const userData = () => app.getPath('userData')
const storageDir = () => path.join(userData(), 'storage')
/** @type {import('http').Server|null} */
let staticServer = null
/** @type {number} */
let staticPort = 0
function ensureDir(p) {
fs.mkdirSync(p, { recursive: true })
}
/**
* Minimal static file server rooted at the app directory.
* index.html is rewritten so the GUI loads via require('…/electron/app.bundle.cjs').
*/
function startStaticServer(rootDir) {
const bundlePath = path.join(rootDir, 'electron', 'app.bundle.cjs')
if (!fs.existsSync(bundlePath)) {
throw new Error(
`electron/app.bundle.cjs not found at ${bundlePath}.\n` +
`Run: npm run build:client-bundle\n` +
`(packaged builds run this in the forge prePackage hook.)`
)
}
return new Promise((resolve, reject) => {
const mime = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.cjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json',
'.webmanifest': 'application/manifest+json',
'.xml': 'application/xml',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.map': 'application/json',
}
const server = http.createServer((req, res) => {
try {
const u = new URL(req.url || '/', 'http://127.0.0.1')
let rel = decodeURIComponent(u.pathname)
if (rel === '/') rel = '/index.html'
const filePath = path.normalize(path.join(rootDir, rel))
if (!filePath.startsWith(rootDir)) {
res.writeHead(403)
res.end('Forbidden')
return
}
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
res.writeHead(404)
res.end('Not found')
return
}
const ext = path.extname(filePath).toLowerCase()
if (rel === '/index.html' || filePath.endsWith(`${path.sep}index.html`)) {
let html = fs.readFileSync(filePath, 'utf8')
html = html.replace(
/<script\s+type=["']module["']\s+src=["'][^"']*app\.js["']\s*>\s*<\/script>/i,
''
)
const boot = `
<script>
(function () {
var bundlePath = ${JSON.stringify(bundlePath)};
console.log('[peardata] loading GUI via require:', bundlePath);
try {
require(bundlePath);
} catch (err) {
console.error('[peardata] failed to load app.bundle.cjs', err);
var el = document.createElement('pre');
el.style.cssText = 'color:#f88;padding:2rem;white-space:pre-wrap;font:14px monospace';
el.textContent = 'PearData failed to start:\\n' + (err && err.stack ? err.stack : err);
document.body.appendChild(el);
}
})();
</script>`
if (html.includes('</body>')) {
html = html.replace('</body>', boot + '\n</body>')
} else {
html += boot
}
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache',
})
res.end(html)
return
}
res.writeHead(200, {
'Content-Type': mime[ext] || 'application/octet-stream',
'Cache-Control': 'no-cache',
'Access-Control-Allow-Origin': '*',
})
fs.createReadStream(filePath).pipe(res)
} catch (err) {
res.writeHead(500)
res.end(String(err && err.message ? err.message : err))
}
})
server.listen(0, '127.0.0.1', () => {
const addr = server.address()
staticPort = typeof addr === 'object' && addr ? addr.port : 0
staticServer = server
resolve(staticPort)
})
server.on('error', reject)
})
}
function buildPreloadPath() {
return path.join(__dirname, 'preload.cjs')
}
function resolveAppIcon() {
const appRoot = app.isPackaged ? app.getAppPath() : path.resolve(__dirname, '..')
const candidates = [
path.join(appRoot, 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png'),
path.join(appRoot, 'build', 'icon.png'),
]
for (const p of candidates) {
if (fs.existsSync(p)) return p
}
return undefined
}
/** Shared window chrome: hiddenInset (macOS) / frameless (win/linux) + pear-ctrl. */
function windowChromeOpts() {
const isDarwin = process.platform === 'darwin'
return isDarwin
? {
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 16, y: 13 },
}
: {
frame: false,
}
}
function defaultWebPreferences() {
return {
preload: buildPreloadPath(),
nodeIntegration: true,
contextIsolation: false,
sandbox: false,
spellcheck: false,
webSecurity: true,
}
}
/**
* Point QVAC at a filesystem worker entry when packaged.
* Bare cannot load plugins from inside app.asar; forge ships qvac/ next to resources.
*/
function configureQvacWorkerEnv() {
if (process.env.QVAC_WORKER_PATH) return
const appRoot = app.isPackaged ? app.getAppPath() : path.resolve(__dirname, '..')
const resourcesPath =
typeof process.resourcesPath === 'string' ? process.resourcesPath : null
const candidates = [
path.join(appRoot, 'qvac', 'worker.entry.mjs'),
path.join(appRoot, 'qvac', 'worker.bundle.js'),
resourcesPath && path.join(resourcesPath, 'app', 'qvac', 'worker.entry.mjs'),
resourcesPath && path.join(resourcesPath, 'app.asar.unpacked', 'qvac', 'worker.entry.mjs'),
resourcesPath && path.join(resourcesPath, 'qvac', 'worker.entry.mjs'),
].filter(Boolean)
for (const p of candidates) {
if (fs.existsSync(p)) {
process.env.QVAC_WORKER_PATH = p
console.log('[peardata] QVAC_WORKER_PATH=', p)
return
}
}
}
function createWindow() {
const icon = resolveAppIcon()
const win = new BrowserWindow({
width: pkg.pear?.gui?.width || 1280,
height: pkg.pear?.gui?.height || 860,
minWidth: pkg.pear?.gui?.minWidth || 900,
minHeight: pkg.pear?.gui?.minHeight || 560,
backgroundColor: pkg.pear?.gui?.backgroundColor || '#0b1020',
title: appName,
...(icon ? { icon } : {}),
autoHideMenuBar: process.platform !== 'darwin',
...windowChromeOpts(),
webPreferences: defaultWebPreferences(),
show: false,
})
win.once('ready-to-show', () => win.show())
const url = `http://127.0.0.1:${staticPort}/index.html`
win.loadURL(url).catch((err) => {
console.error('Failed to load UI:', err)
app.quit()
})
win.webContents.on('did-fail-load', (_e, code, desc) => {
console.error('did-fail-load', code, desc)
})
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
return win
}
// IPC for Pear polyfill
ipcMain.on('peardata:get-pear-config', (evt) => {
evt.returnValue = {
storage: storageDir(),
name: appName,
version: pkg.version,
platform: process.platform,
}
})
ipcMain.handle('peardata:exit', () => {
app.quit()
})
// QVAC runs in main — never load @qvac/sdk in the renderer (blanks the UI)
try {
require('./qvac-service.cjs').registerIpc(ipcMain)
console.log('[peardata] QVAC main-process service registered')
} catch (err) {
console.warn('[peardata] QVAC service unavailable:', err?.message || err)
}
function windowFromEvent(evt) {
try {
return BrowserWindow.fromWebContents(evt.sender)
} catch {
return null
}
}
function focusedWindow() {
return BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0] || null
}
ipcMain.handle('peardata:window-minimize', (evt) => {
;(windowFromEvent(evt) || focusedWindow())?.minimize()
})
ipcMain.handle('peardata:window-maximize', (evt) => {
const win = windowFromEvent(evt) || focusedWindow()
if (!win) return
if (win.isMaximized()) win.unmaximize()
else win.maximize()
})
ipcMain.handle('peardata:window-close', (evt) => {
;(windowFromEvent(evt) || focusedWindow())?.close()
})
ipcMain.handle('peardata:window-is-maximized', (evt) => {
return Boolean((windowFromEvent(evt) || focusedWindow())?.isMaximized())
})
const teardownFns = []
ipcMain.on('peardata:teardown-register', () => {
// Renderer registers via preload bridge; actual teardown on before-quit
})
app.whenReady().then(async () => {
ensureDir(storageDir())
const appRoot = app.isPackaged ? app.getAppPath() : path.resolve(__dirname, '..')
// Worker path for Bare spawn (main process) before any QVAC IPC
configureQvacWorkerEnv()
const dockIcon = resolveAppIcon()
if (dockIcon && process.platform === 'darwin' && app.dock) {
try {
app.dock.setIcon(dockIcon)
} catch {
// ignore
}
}
if (process.platform !== 'darwin') {
Menu.setApplicationMenu(null)
}
await startStaticServer(appRoot)
process.env.PEARDATA_STORAGE = storageDir()
process.env.PEARDATA_APP_NAME = appName
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
app.on('before-quit', async () => {
if (staticServer) {
try {
staticServer.close()
} catch {
// ignore
}
}
for (const fn of teardownFns) {
try {
await fn()
} catch {
// ignore
}
}
})
const gotLock = app.requestSingleInstanceLock()
if (!gotLock) {
app.quit()
} else {
app.on('second-instance', () => {
const wins = BrowserWindow.getAllWindows()
if (wins[0]) {
if (wins[0].isMinimized()) wins[0].restore()
wins[0].focus()
}
})
}