/** * Flying Jib desktop client — Electron shell (peardock pattern). * * pear run is deprecated / broken for UI on current Pear CLI; this owns the window. * Bare App + gui-control run in a bare-runtime child; renderer talks over loopback HTTP. * * Dev: npm start */ 'use strict' // Pear / pear-electron often leave this set; it turns Electron into Node and breaks ipcMain. if (process.env.ELECTRON_RUN_AS_NODE) { delete process.env.ELECTRON_RUN_AS_NODE } const { app, BrowserWindow, ipcMain, Menu } = require('electron') const path = require('path') const fs = require('fs') const http = require('http') const { spawn } = require('child_process') const pkg = require('../package.json') const appName = pkg.productName || pkg.name || 'Flying Jib' function resolveRootDir() { if (!app.isPackaged) return path.resolve(__dirname, '..') const appPath = app.getAppPath() // Prefer unpacked tree for Bare spawn (asarUnpack in forge.config.cjs). const unpacked = appPath.replace(/app\.asar$/i, 'app.asar.unpacked') if (unpacked !== appPath && fs.existsSync(unpacked)) return unpacked return appPath } /** Static UI can be served from asar; Bare backend needs the unpacked root. */ function resolveStaticRoot() { if (!app.isPackaged) return path.resolve(__dirname, '..') return app.getAppPath() } let rootDir = resolveRootDir() // Unpackaged Electron defaults userData to "Electron" — keep storage under our name. app.setName(appName) app.setPath('userData', path.join(app.getPath('appData'), appName)) let staticServer = null let staticPort = 0 /** @type {import('child_process').ChildProcess|null} */ let backend = null /** @type {import('electron').BrowserWindow|null} */ let mainWindow = null function ensureDir(p) { fs.mkdirSync(p, { recursive: true }) } function storageDir() { return path.join(app.getPath('userData'), 'storage') } function statePath() { return path.join(storageDir(), 'gui-control.json') } function readControlMeta() { try { const p = statePath() if (!fs.existsSync(p)) return null const meta = JSON.parse(fs.readFileSync(p, 'utf8')) if (meta?.baseUrl && meta?.token) return meta } catch { // ignore } return null } function startStaticServer() { const staticRoot = resolveStaticRoot() const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': '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' } return new Promise((resolve, reject) => { const server = http.createServer((req, res) => { try { const u = new URL(req.url || '/', 'http://127.0.0.1') if (u.pathname === '/api/control-meta') { const meta = readControlMeta() const body = JSON.stringify(meta || { pending: true }) res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache', 'Access-Control-Allow-Origin': '*' }) res.end(body) return } let rel = decodeURIComponent(u.pathname) if (rel === '/') rel = '/index.html' const filePath = path.normalize(path.join(staticRoot, rel)) if (!filePath.startsWith(staticRoot)) { 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() res.writeHead(200, { 'Content-Type': mime[ext] || 'application/octet-stream', 'Cache-Control': 'no-cache' }) 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 resolveBareBin() { rootDir = resolveRootDir() const candidates = [ path.join(rootDir, 'node_modules', 'bare-runtime', 'bin', 'bare'), path.join(process.resourcesPath || '', 'bare-runtime', 'bin', 'bare'), path.join(process.resourcesPath || '', 'bare') ] for (const bare of candidates) { if (process.platform === 'win32') { const cmd = bare + '.cmd' if (fs.existsSync(cmd)) return cmd const exe = bare + '.exe' if (fs.existsSync(exe)) return exe } if (bare && fs.existsSync(bare)) return bare } return null } function startBackend() { ensureDir(storageDir()) // Clear stale control file so UI waits for this session try { fs.unlinkSync(statePath()) } catch { // ignore } const bareBin = resolveBareBin() const entry = path.join(rootDir, 'electron', 'backend.mjs') const env = { ...process.env, FJ_STORAGE: storageDir() } if (bareBin) { backend = spawn(bareBin, [entry], { cwd: rootDir, env, stdio: ['ignore', 'pipe', 'pipe'] }) } else { console.warn('[flying-jib] bare-runtime missing; falling back to node for backend') const nodeBin = process.platform === 'win32' ? 'node.exe' : 'node' backend = spawn(nodeBin, [entry], { cwd: rootDir, env, stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' }) } backend.stdout?.on('data', (d) => process.stdout.write(d)) backend.stderr?.on('data', (d) => process.stderr.write(d)) backend.on('exit', (code, signal) => { console.error(`[flying-jib] backend exited code=${code} signal=${signal}`) backend = null }) return backend } function windowChromeOpts() { const isDarwin = process.platform === 'darwin' return isDarwin ? { titleBarStyle: 'hiddenInset', trafficLightPosition: { x: 16, y: 13 } } : { frame: false } } function waitForControl(timeoutMs = 15000) { const start = Date.now() return new Promise((resolve, reject) => { const tick = () => { const meta = readControlMeta() if (meta?.baseUrl) { resolve(meta) return } if (Date.now() - start > timeoutMs) { reject(new Error('Timed out waiting for gui-control.json (Bare backend)')) return } setTimeout(tick, 100) } tick() }) } function createWindow() { const win = new BrowserWindow({ width: pkg.pear?.gui?.width || 1180, height: pkg.pear?.gui?.height || 820, backgroundColor: pkg.pear?.gui?.backgroundColor || '#0c1a22', title: appName, autoHideMenuBar: process.platform !== 'darwin', ...windowChromeOpts(), webPreferences: { preload: path.join(__dirname, 'preload.cjs'), // Preload defines pear-ctrl + Pear.config on the page (peardock-style). // UI discovery uses /api/control-meta — no Node in renderer. nodeIntegration: false, contextIsolation: false, sandbox: false, spellcheck: false }, show: false }) win.once('ready-to-show', () => win.show()) const url = `http://127.0.0.1:${staticPort}/index.html` console.log(`[flying-jib] loading UI ${url}`) win.loadURL(url).catch((err) => { console.error('[flying-jib] failed to load UI:', err) }) win.webContents.on('did-fail-load', (_e, code, desc, validatedURL, isMainFrame) => { if (!isMainFrame) return console.error('[flying-jib] did-fail-load', code, desc) setTimeout(() => { if (!win.isDestroyed()) win.loadURL(url).catch(() => {}) }, 500) }) mainWindow = win return win } function stopBackend() { if (!backend || backend.killed) return try { backend.kill('SIGTERM') } catch { // ignore } backend = null } ipcMain.on('fj:get-pear-config', (event) => { event.returnValue = { storage: storageDir(), name: appName, version: pkg.version, platform: process.platform } }) ipcMain.handle('fj:exit', (_e, code) => { app.exit(code | 0) }) function windowFromEvent(evt) { try { return BrowserWindow.fromWebContents(evt.sender) } catch { return null } } function focusedWindow() { return BrowserWindow.getFocusedWindow() || mainWindow || BrowserWindow.getAllWindows()[0] || null } ipcMain.handle('fj:window-minimize', (evt) => { ;(windowFromEvent(evt) || focusedWindow())?.minimize() }) ipcMain.handle('fj:window-maximize', (evt) => { const win = windowFromEvent(evt) || focusedWindow() if (!win) return if (win.isMaximized()) win.unmaximize() else win.maximize() }) ipcMain.handle('fj:window-close', (evt) => { ;(windowFromEvent(evt) || focusedWindow())?.close() }) ipcMain.handle('fj:window-is-maximized', (evt) => { return Boolean((windowFromEvent(evt) || focusedWindow())?.isMaximized()) }) app.whenReady().then(async () => { if (process.platform !== 'darwin') { Menu.setApplicationMenu(null) } ensureDir(storageDir()) startBackend() const port = await startStaticServer() console.log(`[flying-jib] static UI at http://127.0.0.1:${port}`) try { const meta = await waitForControl() console.log(`[flying-jib] control ready at ${meta.baseUrl}`) } catch (err) { console.error('[flying-jib]', err.message || err) } createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) app.on('window-all-closed', () => { stopBackend() if (staticServer) { try { staticServer.close() } catch { // ignore } } if (process.platform !== 'darwin') app.quit() }) app.on('before-quit', () => { stopBackend() })