365 lines
9.7 KiB
JavaScript
365 lines
9.7 KiB
JavaScript
/**
|
|
* 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()
|
|
})
|