This commit is contained in:
Raven Scott
2026-07-11 11:39:59 -04:00
parent fa99bd8c6e
commit 2526ebb99b
23 changed files with 10113 additions and 158 deletions
+335
View File
@@ -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()
}
})
}
+148
View File
@@ -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 }
+110
View File
@@ -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)