375 lines
12 KiB
JavaScript
375 lines
12 KiB
JavaScript
/**
|
|
* 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, Menu } = 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<void> }|null} */
|
|
let holesailControl = null
|
|
/** @type {{ close?: () => Promise<void>, 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',
|
|
'.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'
|
|
// 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(
|
|
/<script\s+type=["']module["']\s+src=["'][^"']*app\.js["']\s*>\s*<\/script>/i,
|
|
''
|
|
)
|
|
const boot = `
|
|
<script>
|
|
(function () {
|
|
var bundlePath = ${JSON.stringify(bundlePath)};
|
|
console.log('[peardock] loading GUI via require:', bundlePath);
|
|
try {
|
|
require(bundlePath);
|
|
} catch (err) {
|
|
console.error('[peardock] 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 = 'peardock 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)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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 resolveAppIcon() {
|
|
const appRoot = app.isPackaged ? app.getAppPath() : path.resolve(__dirname, '..')
|
|
// Packaged: electron-packager embeds platform icon; still set window icon for Linux/Win.
|
|
const candidates = [
|
|
path.join(appRoot, 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png'),
|
|
path.join(appRoot, 'build', 'icon.png'),
|
|
path.join(appRoot, 'assets', 'logo', 'peardock-icon-256.png'),
|
|
]
|
|
for (const p of candidates) {
|
|
if (fs.existsSync(p)) return p
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function createWindow() {
|
|
const icon = resolveAppIcon()
|
|
const win = new BrowserWindow({
|
|
width: pkg.pear?.gui?.width || 1280,
|
|
height: pkg.pear?.gui?.height || 800,
|
|
backgroundColor: pkg.pear?.gui?.backgroundColor || '#0f1117',
|
|
title: appName,
|
|
...(icon ? { icon } : {}),
|
|
// Hide the default Electron menu bar (File/Edit/View/…) on Linux/Windows.
|
|
// macOS keeps the system menu bar; non-darwin has no app menu (set in whenReady).
|
|
autoHideMenuBar: process.platform !== 'darwin',
|
|
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, '..')
|
|
|
|
// Dock / taskbar icon (packaged macOS uses .icns from forge; this covers dev + Linux/Win)
|
|
const dockIcon = resolveAppIcon()
|
|
if (dockIcon && process.platform === 'darwin' && app.dock) {
|
|
try {
|
|
app.dock.setIcon(dockIcon)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
// Electron's default app menu (File, Edit, View, Window, Help) is fine on macOS
|
|
// (system menu bar) but draws an unwanted menubar inside the window on Linux/Windows.
|
|
// Remove it there so only the peardock UI chrome is visible.
|
|
if (process.platform !== 'darwin') {
|
|
Menu.setApplicationMenu(null)
|
|
}
|
|
|
|
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()
|
|
}
|
|
})
|
|
}
|