/** * peardock desktop client — Electron shell (full Pear GUI app). * * Architecture (hello-pear-electron + peardock pear-electron layout): * - Electron owns the window chrome * - Holesail local control runs in main (holesailBareControl.cjs) * - Renderer loads index.html over localhost; GUI code is require()'d as * electron/app.bundle.cjs (esbuild CJS of app.js + local modules) so HyperDHT * etc. resolve via Node from node_modules — same graph as pear run * - 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, session } = require('electron') const path = require('path') const fs = require('fs') const os = require('os') const http = require('http') const pkg = require('../package.json') const appName = pkg.productName || pkg.name || 'peardock' // ---- CLI flags (paparam optional) ---- 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 (argv[i] === '--no-updates') process.env.PEARDOCK_UPDATES = '0' } 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 /** @type {{ close?: () => Promise }|null} */ let holesailControl = null /** @type {{ close?: () => Promise, enabled?: boolean }|null} */ let otaHandle = null function ensureDir(p) { fs.mkdirSync(p, { recursive: true }) } /** * Minimal static file server rooted at the app directory. * Serves the full peardock UI tree (index.html, css, assets, …). * * index.html is rewritten so the GUI loads via: * require('…/electron/app.bundle.cjs') * (esbuild CJS of app.js + local modules; npm packages stay external). * * Do not use import(file://app.js): Chromium cannot fetch file:// from http:// * and cannot resolve bare npm imports; Node ESM in the renderer crashes. */ 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', '.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' // block path escape 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() // Full GUI bootstrap: require() esbuild CJS bundle (nodeIntegration) if (rel === '/index.html' || filePath.endsWith(`${path.sep}index.html`)) { let html = fs.readFileSync(filePath, 'utf8') // Remove browser ESM entry (cannot resolve npm packages over HTTP) html = html.replace( /\s*<\/script>/i, '' ) const boot = ` ` if (html.includes('')) { html = html.replace('', boot + '\n') } 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) }) } /** * Inject Pear polyfill + bootstrap Node ESM load of app.js before other scripts. * Pear-electron uses pear-bridge/script-linker; here Electron provides Node and we * polyfill the small Pear surface peardock needs. */ function buildPreloadPath() { return path.join(__dirname, 'preload.cjs') } async function startHolesailControl() { ensureDir(storageDir()) const statePath = path.join(storageDir(), 'peardock-holesail-local.json') try { // Prefer CJS control (works under Node main with native addons when available) const bareControl = require('../client/holesailBareControl.cjs') const start = bareControl.start || bareControl.default?.start if (typeof start !== 'function') throw new Error('holesailBareControl missing start()') holesailControl = await start({ statePath }) console.log(`[peardock] Holesail local control at ${holesailControl.baseUrl}`) return holesailControl } catch (err) { console.error('[peardock] Holesail local control failed (tunnels UI may be limited):', err.message || err) // Write a stub endpoint file so UI can detect absence cleanly try { fs.writeFileSync( statePath, JSON.stringify({ error: String(err.message || err), at: new Date().toISOString() }, null, 2) ) } catch { // ignore } return null } } function createWindow() { const win = new BrowserWindow({ width: pkg.pear?.gui?.width || 1280, height: pkg.pear?.gui?.height || 800, backgroundColor: pkg.pear?.gui?.backgroundColor || '#0a0c10', title: appName, webPreferences: { preload: buildPreloadPath(), // Full peardock UI imports HyperDHT etc. as ESM node packages — same as pear UI. nodeIntegration: true, contextIsolation: false, sandbox: false, spellcheck: false, webSecurity: true, }, 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) }) return win } // IPC for Pear polyfill ipcMain.on('peardock:get-pear-config', (evt) => { evt.returnValue = { storage: storageDir(), name: appName, version: pkg.version, } }) ipcMain.handle('peardock:exit', () => { app.quit() }) const teardownFns = [] ipcMain.on('peardock:teardown-register', () => { // Renderer registers via preload bridge; actual teardown on before-quit }) app.whenReady().then(async () => { ensureDir(storageDir()) // App root: project root in dev; Electron app path (app.asar or app/) when packaged const appRoot = app.isPackaged ? app.getAppPath() : path.resolve(__dirname, '..') await startStaticServer(appRoot) await startHolesailControl() // Expose storage path for renderer polyfill via process.env (nodeIntegration) process.env.PEARDOCK_STORAGE = storageDir() process.env.PEARDOCK_APP_NAME = appName createWindow() // Pear P2P OTA (package.json upgrade / upgradeClient) try { const { startClientOta } = require('./ota.cjs') otaHandle = await startClientOta() if (otaHandle?.enabled) { console.log('[peardock] Pear OTA updates enabled for peardock-client') } } catch (err) { console.warn('[peardock] OTA init failed:', err.message || err) } 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 (e) => { try { await otaHandle?.close?.() } catch { // ignore } try { await holesailControl?.close?.() } catch { // ignore } if (staticServer) { try { staticServer.close() } catch { // ignore } } for (const fn of teardownFns) { try { await fn() } catch { // ignore } } }) // Identity for single-instance 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() } }) }