CI
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* 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<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',
|
||||
'.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 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Pear OTA for peardock-client (Electron).
|
||||
* Uses pear-runtime-updater + Corestore + Hyperswarm directly — NOT pear-runtime —
|
||||
* so we avoid packaging bare-sidecar (~370MB) and stay fast to asar.
|
||||
*/
|
||||
'use strict'
|
||||
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const otaConfig = require('../lib/ota-config.cjs')
|
||||
|
||||
/**
|
||||
* @returns {Promise<{ enabled: boolean, upgrade: string|null, close: () => Promise<void> }>}
|
||||
*/
|
||||
async function startClientOta(opts = {}) {
|
||||
const pkg = require('../package.json')
|
||||
const cfg = otaConfig.buildUpdaterOptions('client', {
|
||||
pkg,
|
||||
dir: path.join(app.getPath('userData'), 'pear-runtime-data'),
|
||||
app: getClientAppPath(),
|
||||
name: otaConfig.artifactName('client'),
|
||||
})
|
||||
|
||||
const broadcast =
|
||||
opts.broadcast ||
|
||||
((channel, data) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) win.webContents.send(channel, data)
|
||||
}
|
||||
})
|
||||
|
||||
if (!cfg.enabled) {
|
||||
console.log('[peardock-ota] disabled or no upgrade link')
|
||||
return { enabled: false, upgrade: null, close: async () => {} }
|
||||
}
|
||||
|
||||
if (!app.isPackaged && !process.env.PEARDOCK_OTA_FORCE) {
|
||||
console.log('[peardock-ota] skipped in unpackaged dev (PEARDOCK_OTA_FORCE=1 to test)')
|
||||
return { enabled: false, upgrade: cfg.upgrade, close: async () => {} }
|
||||
}
|
||||
|
||||
fs.mkdirSync(cfg.dir, { recursive: true })
|
||||
|
||||
let PearRuntimeUpdater
|
||||
let Corestore
|
||||
let Hyperswarm
|
||||
try {
|
||||
PearRuntimeUpdater = require('pear-runtime-updater')
|
||||
Corestore = require('corestore')
|
||||
Hyperswarm = require('hyperswarm')
|
||||
} catch (err) {
|
||||
console.warn('[peardock-ota] deps missing:', err.message)
|
||||
return { enabled: false, upgrade: cfg.upgrade, close: async () => {} }
|
||||
}
|
||||
|
||||
const store = new Corestore(path.join(cfg.dir, 'corestore'))
|
||||
await store.ready()
|
||||
|
||||
const updater = new PearRuntimeUpdater({
|
||||
dir: cfg.dir,
|
||||
version: cfg.version,
|
||||
upgrade: cfg.upgrade,
|
||||
name: cfg.name,
|
||||
app: cfg.app,
|
||||
updates: true,
|
||||
store,
|
||||
})
|
||||
await updater.ready()
|
||||
|
||||
const keyPair = await store.createKeyPair('peardock-client-ota')
|
||||
const swarm = new Hyperswarm({ keyPair })
|
||||
swarm.on('connection', (c) => store.replicate(c))
|
||||
swarm.join(updater.drive.core.discoveryKey, { client: true, server: false })
|
||||
|
||||
console.log(
|
||||
`[peardock-ota] listening version=${cfg.version} name=${cfg.name} app=${cfg.app}`
|
||||
)
|
||||
|
||||
updater.on('error', (err) => {
|
||||
console.warn('[peardock-ota] error', err?.message || err)
|
||||
broadcast('peardock:ota', { type: 'error', message: String(err?.message || err) })
|
||||
})
|
||||
updater.on('update-scheduled', (delay) => {
|
||||
broadcast('peardock:ota', { type: 'scheduled', delayMs: delay })
|
||||
})
|
||||
updater.on('updating', () => {
|
||||
console.log('[peardock-ota] downloading update…')
|
||||
broadcast('peardock:ota', { type: 'updating' })
|
||||
})
|
||||
updater.on('updated', async () => {
|
||||
console.log('[peardock-ota] update ready, applying…', updater.nextVersion)
|
||||
broadcast('peardock:ota', { type: 'updated', nextVersion: updater.nextVersion })
|
||||
try {
|
||||
await updater.applyUpdate()
|
||||
broadcast('peardock:ota', { type: 'applied' })
|
||||
if (process.platform === 'linux' && process.env.APPIMAGE) {
|
||||
app.relaunch({
|
||||
execPath: process.env.APPIMAGE,
|
||||
args: [
|
||||
'--appimage-extract-and-run',
|
||||
...process.argv.slice(1).filter((a) => a !== '--appimage-extract-and-run'),
|
||||
],
|
||||
})
|
||||
} else {
|
||||
app.relaunch()
|
||||
}
|
||||
app.exit(0)
|
||||
} catch (err) {
|
||||
console.error('[peardock-ota] apply failed', err)
|
||||
broadcast('peardock:ota', { type: 'error', message: String(err.message || err) })
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
upgrade: cfg.upgrade,
|
||||
close: async () => {
|
||||
try {
|
||||
await swarm.destroy()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await updater.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await store.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getClientAppPath() {
|
||||
if (process.env.PEARDOCK_OTA_APP) return process.env.PEARDOCK_OTA_APP
|
||||
if (process.platform === 'linux' && process.env.APPIMAGE) return process.env.APPIMAGE
|
||||
if (process.platform === 'win32') return process.execPath
|
||||
if (process.platform === 'darwin' && app.isPackaged) {
|
||||
return path.join(process.resourcesPath, '..', '..')
|
||||
}
|
||||
return process.execPath
|
||||
}
|
||||
|
||||
module.exports = { startClientOta, getClientAppPath }
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Preload + Pear polyfill for the full peardock GUI under Electron.
|
||||
* Runs with contextIsolation: false / nodeIntegration: true so app.js ESM
|
||||
* can import HyperDHT packages (pear-electron UI equivalent).
|
||||
*/
|
||||
'use strict'
|
||||
|
||||
const { ipcRenderer } = require('electron')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
|
||||
const cfg = ipcRenderer.sendSync('peardock:get-pear-config') || {}
|
||||
const storage =
|
||||
cfg.storage ||
|
||||
process.env.PEARDOCK_STORAGE ||
|
||||
path.join(os.homedir(), '.config', 'peardock', 'storage')
|
||||
|
||||
try {
|
||||
fs.mkdirSync(storage, { recursive: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const teardownHooks = []
|
||||
|
||||
/** Pear surface used by peardock index/holesailLocal/UI */
|
||||
const Pear = {
|
||||
config: {
|
||||
storage,
|
||||
name: cfg.name || 'peardock',
|
||||
version: cfg.version || '0.0.0',
|
||||
},
|
||||
// alias some runtimes use
|
||||
app: {
|
||||
storage,
|
||||
name: cfg.name || 'peardock',
|
||||
},
|
||||
exit(code = 0) {
|
||||
try {
|
||||
ipcRenderer.invoke('peardock:exit', code)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
teardown(fn) {
|
||||
if (typeof fn === 'function') teardownHooks.push(fn)
|
||||
},
|
||||
constructor: {
|
||||
IPC: null,
|
||||
UI: null,
|
||||
CUTOVER: false,
|
||||
},
|
||||
}
|
||||
|
||||
globalThis.Pear = Pear
|
||||
// Also on window for any non-module scripts
|
||||
try {
|
||||
window.Pear = Pear
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// pear-ctrl custom element is a no-op stub outside pear platform chrome
|
||||
try {
|
||||
if (typeof customElements !== 'undefined' && !customElements.get('pear-ctrl')) {
|
||||
class PearCtrl extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.style.display = 'none'
|
||||
}
|
||||
}
|
||||
customElements.define('pear-ctrl', PearCtrl)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
for (const fn of teardownHooks) {
|
||||
try {
|
||||
const r = fn()
|
||||
if (r && typeof r.then === 'function') r.catch(() => {})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// OTA status from main (pear-runtime)
|
||||
try {
|
||||
const { ipcRenderer } = require('electron')
|
||||
ipcRenderer.on('peardock:ota', (_evt, data) => {
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('peardock-ota', { detail: data }))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (data?.type === 'updating') {
|
||||
console.log('[peardock] OTA downloading update…')
|
||||
} else if (data?.type === 'updated') {
|
||||
console.log('[peardock] OTA update ready', data.nextVersion)
|
||||
} else if (data?.type === 'applied') {
|
||||
console.log('[peardock] OTA applied — relaunching')
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
console.log('[peardock] Pear polyfill ready, storage=', storage)
|
||||
Reference in New Issue
Block a user