diff --git a/ctx/shared.js b/ctx/shared.js index 0621315ff..df5a8bb32 100644 --- a/ctx/shared.js +++ b/ctx/shared.js @@ -31,6 +31,7 @@ module.exports = class Context { ctx.options = pkg?.pear || pkg?.holepunch || {} ctx.name = pkg?.pear?.name || pkg?.holepunch?.name || pkg?.name || null ctx.type = pkg?.pear?.type || (/\.(c|m)?js$/.test(ctx.main) ? 'terminal' : 'desktop') + ctx.links = pkg?.pear?.links || null ctx.dependencies = [ ...(pkg?.dependencies ? Object.keys(pkg.dependencies) : []), ...(pkg?.devDependencies ? Object.keys(pkg.devDependencies) : []), @@ -56,9 +57,9 @@ module.exports = class Context { } static configFrom (ctx) { - const { id, key, alias, env, cwd, options, checkpoint, flags, dev, tier, stage, storage, trace, name, main, dependencies, args, channel, release, link, linkData, dir } = ctx + const { id, key, links, alias, env, cwd, options, checkpoint, flags, dev, tier, stage, storage, trace, name, main, dependencies, args, channel, release, link, linkData, dir } = ctx const pearDir = PLATFORM_DIR - return { id, key, alias, env, cwd, options, checkpoint, flags, dev, tier, stage, storage, trace, name, main, dependencies, args, channel, release, link, linkData, dir, pearDir } + return { id, key, links, alias, env, cwd, options, checkpoint, flags, dev, tier, stage, storage, trace, name, main, dependencies, args, channel, release, link, linkData, dir, pearDir } } update (state) { diff --git a/examples/desktop/app.js b/examples/desktop/app.js index 5c102dac2..8ffbbe7b8 100644 --- a/examples/desktop/app.js +++ b/examples/desktop/app.js @@ -23,3 +23,8 @@ document.getElementById('platformLength').innerText = platform.length document.getElementById('appKey').innerText = app.key document.getElementById('appFork').innerText = app.fork document.getElementById('appLength').innerText = app.length + +if (config.args.includes('--worker-demo')) { + global.pipe = Pear.worker.run(config.links.worker) + console.info('Pipe Duplex Stream available as `pipe`') +} diff --git a/examples/desktop/package.json b/examples/desktop/package.json index 3bcec2528..c6678c14a 100644 --- a/examples/desktop/package.json +++ b/examples/desktop/package.json @@ -9,6 +9,9 @@ "type": "module", "pear": { "name": "deskex", + "links": { + "worker": "pear://456cup6hg5pwpekmygzhbxyxgtpknk66mbccohcuo795dj6biueo" + }, "gui": { "width": 900, "height": 500, diff --git a/examples/terminal/index.js b/examples/terminal/index.js index f0bc28f3e..d6703c141 100644 --- a/examples/terminal/index.js +++ b/examples/terminal/index.js @@ -17,3 +17,17 @@ const out = `${grn} ▅ ▄▄▄▄▆▆▆▆ ` console.log('\n\x1b[s\x1b[J' + out) + +const pipe = Pear.worker.pipe() +const isWorker = pipe !== null +if (isWorker) { + pipe.on('data', (data) => { + const str = data.toString() + console.log('parent:', str) + if (str === 'hello') console.log('world') + if (str === 'exit') { + console.log('exiting') + Pear.exit() + } + }) +} diff --git a/gui/gui.js b/gui/gui.js index efc2c7f7c..872b272f4 100644 --- a/gui/gui.js +++ b/gui/gui.js @@ -7,7 +7,9 @@ const path = require('path') const { isMac, isLinux } = require('which-runtime') const IPC = require('pear-ipc') const ReadyResource = require('ready-resource') +const Worker = require('../lib/worker') const constants = require('../lib/constants') + const kMap = Symbol('pear.gui.map') const kCtrl = Symbol('pear.gui.ctrl') @@ -1352,6 +1354,8 @@ class PearGUI extends ReadyResource { }, connect: tryboot }) + this.worker = new Worker() + this.pipes = new Freelist() this.ipc.once('close', () => this.close()) electron.ipcMain.on('id', async (event) => { @@ -1418,18 +1422,50 @@ class PearGUI extends ReadyResource { electron.ipcMain.handle('versions', (evt, ...args) => this.versions(...args)) electron.ipcMain.handle('restart', (evt, ...args) => this.restart(...args)) + electron.ipcMain.on('workerRun', (evt, link) => { + const pipe = this.worker.run(link) + const id = this.pipes.alloc(pipe) + pipe.on('close', () => { + this.pipes.free(id) + evt.reply('workerPipeClose') + }) + pipe.on('data', (data) => { evt.reply('workerPipeData', data) }) + pipe.on('end', () => { evt.reply('workerPipeData', null) }) + pipe.on('error', (err) => { evt.reply('pipeError', err.stack) }) + }) + + electron.ipcMain.on('workerPipeId', (evt) => { + evt.returnValue = this.pipes.nextId() + return evt.returnValue + }) + + electron.ipcMain.on('workerPipeClose', (evt, id) => { + const pipe = this.pipes.from(id) + if (!pipe) return + pipe.destroy() + }) + + electron.ipcMain.on('workerPipeWrite', (evt, id, data) => { + const pipe = this.pipes.from(id) + if (!pipe) { + console.error('Unexpected workerPipe error (unknown id)') + return + } + pipe.write(data) + }) + // DEPRECATED - assess to remove from Sep 2024 - electron.ipcMain.on('preferences', (event) => { + electron.ipcMain.on('preferences', (evt) => { const preferences = this.preferences() - preferences.on('data', (data) => event.reply('preferences', data)) - preferences.on('end', () => event.reply('preferences', null)) + preferences.on('data', (data) => evt.reply('preferences', data)) + preferences.on('end', () => evt.reply('preferences', null)) }) electron.ipcMain.handle('setPreference', (evt, ...args) => this.setPreference(...args)) electron.ipcMain.handle('getPreference', (evt, ...args) => this.getPreference(...args)) - electron.ipcMain.on('iteratePreferences', (event) => { + electron.ipcMain.on('iteratePreferences', (evt) => { const iteratePreferences = this.iteratePreferences() - iteratePreferences.on('data', (data) => event.reply('iteratePreferences', data)) - iteratePreferences.on('end', () => event.reply('iteratePreferences', null)) + iteratePreferences.on('data', (data) => evt.reply('iteratePreferences', data)) + iteratePreferences.on('end', () => evt.reply('iteratePreferences', null)) }) } @@ -1643,4 +1679,39 @@ class PearGUI extends ReadyResource { iteratePreference () { return this.ipc.iteratePreference() } } +class Freelist { + alloced = [] + freed = [] + + nextId () { + return this.freed.length === 0 ? this.alloced.length : this.freed[this.freed.length - 1] + } + + alloc (item) { + const id = this.freed.length === 0 ? this.alloced.push(null) - 1 : this.freed.pop() + this.alloced[id] = item + return id + } + + free (id) { + this.freed.push(id) + this.alloced[id] = null + } + + from (id) { + return id < this.alloced.length ? this.alloced[id] : null + } + + emptied () { + return this.freed.length === this.alloced.length + } + + * [Symbol.iterator] () { + for (const item of this.alloced) { + if (item === null) continue + yield item + } + } +} + module.exports = PearGUI diff --git a/gui/preload.js b/gui/preload.js index dc91e2b25..be908af48 100644 --- a/gui/preload.js +++ b/gui/preload.js @@ -5,6 +5,7 @@ const { EventEmitter } = require('events') const Iambus = require('iambus') const ReadyResource = require('ready-resource') const electron = require('electron') +const Worker = require('../lib/worker') module.exports = class PearGUI extends ReadyResource { constructor ({ API, ctx }) { @@ -31,6 +32,7 @@ module.exports = class PearGUI extends ReadyResource { constructor (ipc, ctx, onteardown) { super(ipc, ctx, onteardown) this[Symbol.for('pear.ipc')] = ipc + this.worker = new Worker({ ipc }) this.media = { status: { microphone: () => ipc.getMediaAccessStatus({ id, media: 'microphone' }), @@ -285,6 +287,27 @@ class IPC { return stream } + workerRun (link) { + const id = electron.ipcRenderer.sendSync('workerPipeId') + electron.ipcRenderer.send('workerRun', link) + const stream = new streamx.Duplex({ + write (data, cb) { + electron.ipcRenderer.send('workerPipeWrite', id, data) + cb() + } + }) + electron.ipcRenderer.on('workerPipeError', (e, stack) => { + stream.emit('error', new Error('Worker PipeError (from electron-main): ' + stack)) + }) + electron.ipcRenderer.on('workerClose', () => { stream.destroy() }) + stream.once('close', () => { + electron.ipcRenderer.send('workerPipeClose', id) + }) + + electron.ipcRenderer.on('workerPipeData', (e, data) => { stream.push(data) }) + return stream + } + ref () {} unref () {} diff --git a/lib/api.js b/lib/api.js index 4b43f1e0a..f76d76c5e 100644 --- a/lib/api.js +++ b/lib/api.js @@ -1,5 +1,7 @@ 'use strict' +const Worker = require('./worker') const teardown = global.Bare ? require('./teardown') : Function.prototype +const program = global.Bare || global.process class API { #ipc = null @@ -8,7 +10,7 @@ class API { #teardowns = null #refs = 0 config = null - argv = global.Bare ? global.Bare.argv : process.argv + argv = program.argv constructor (ipc, ctx, onteardown = teardown) { this.#ipc = ipc @@ -16,6 +18,7 @@ class API { this.#refs = 0 this.key = this.#ctx.key?.z32 || 'dev' this.config = ctx.config + this.worker = new Worker({ ref: () => this.#ref(), unref: () => this.#unref() }) this.#teardowns = new Promise((resolve) => { this.#unloading = resolve }) onteardown(() => this.#unload()) } @@ -87,7 +90,7 @@ class API { return this.#teardowns } - exit = (code) => Bare.exit(code) + exit = (code) => program.exit(code) // DEPRECATED - assess to remove from Sep 2024 #preferences = null diff --git a/lib/constants.js b/lib/constants.js index 1f02e2b4d..27f940a0e 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -4,14 +4,14 @@ const { platform, arch, isWindows, isLinux } = require('which-runtime') const { pathToFileURL, fileURLToPath } = require('url-file-url') const sodium = require('sodium-native') const b4a = require('b4a') +const CHECKOUT = require('../checkout') +const { ERR_COULD_NOT_INFER_MODULE_PATH } = require('./errors') const BIN = 'by-arch/' + platform + '-' + arch + '/bin/' const url = module.url || electronModuleURL() const mount = new URL('..', url) -const CHECKOUT = require('../checkout') -const { ERR_COULD_NOT_INFER_MODULE_PATH } = require('./errors') const LOCALDEV = CHECKOUT.length === null const swapURL = mount.pathname.endsWith('.bundle/') ? new URL('..', mount) : mount diff --git a/lib/errors.js b/lib/errors.js index 4136fafab..47b614bd6 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -79,6 +79,10 @@ function ERR_UNABLE_TO_FETCH_MANIFEST (msg) { return new PearError(msg, 'ERR_CONNECTION', ERR_UNABLE_TO_FETCH_MANIFEST) } +function ERR_INVALID_PACKAGE_JSON (msg) { + return new PearError(msg, 'ERR_INVALID_PACKAGE_JSON', ERR_INVALID_PACKAGE_JSON) +} + function ERR_UNKNOWN_GC_RESOURCE (msg) { return new PearError(msg, 'ERR_UNKNOWN_GC_RESOURCE', ERR_UNKNOWN_GC_RESOURCE) } @@ -103,6 +107,7 @@ module.exports = { ERR_INVALID_APPLICATION_STORAGE, ERR_PACKAGE_JSON_NOT_FOUND, ERR_UNABLE_TO_FETCH_MANIFEST, + ERR_INVALID_PACKAGE_JSON, ERR_UNKNOWN_GC_RESOURCE, ERR_ASSERTION } diff --git a/lib/sidecar.js b/lib/sidecar.js index e04d98b78..1ea9ec191 100644 --- a/lib/sidecar.js +++ b/lib/sidecar.js @@ -45,6 +45,7 @@ const { ERR_PLATFORM_ERROR, ERR_TRACER_FAILED, ERR_SHIFT_STORAGE_ERROR, + ERR_INVALID_PACKAGE_JSON, ERR_PERMISSION_REQUIRED, ERR_UNKNOWN_GC_RESOURCE } = require('./errors') @@ -844,7 +845,7 @@ class Engine extends ReadyResource { const currentVersion = bundle.version await ctx.initialize({ bundle, dryRun }) const z32 = hypercoreid.encode(bundle.drive.key) - await this.trust({ z32 }) + await this.trust({ z32 }, client) const type = ctx.manifest.pear?.type || 'desktop' const terminalBare = type === 'terminal' if (terminalBare) bare = true @@ -1061,7 +1062,6 @@ class Engine extends ReadyResource { const type = full ? 'full' : 'latest' const showChangelog = display(changelog) || full ? type : false - const blank = '[ No Changelog ]' const parsed = showChangelog === 'latest' ? (await clog.parse(contents).at(0)?.[1]) || blank @@ -1127,9 +1127,26 @@ class Engine extends ReadyResource { yield * client.userData.messages(pattern) } - async trust ({ z32 } = {}) { + async trust ({ z32 } = {}, client) { const trusted = new Set((await preferences.get('trusted')) || []) trusted.add(z32) + let pkg = null + try { + pkg = JSON.parse(await client.userData.bundle.drive.get('/package.json')) + } catch (err) { + if (err instanceof SyntaxError) throw ERR_INVALID_PACKAGE_JSON('Package.json parsing error, invalid JSON') + console.error('Unexpected error while attempting trust', err) + return await preferences.set('trusted', Array.from(trusted)) + } + if (typeof pkg?.pear?.links === 'object' && pkg.pear.links !== null) { + for (const link of Object.values(pkg.pear.links)) { + try { + trusted.add(hypercoreid.encode(hypercoreid.decode(link))) + } catch { + console.error('Invalid link encountered when attempting trust', { link }) + } + } + } return await preferences.set('trusted', Array.from(trusted)) } @@ -1320,6 +1337,9 @@ class Engine extends ReadyResource { app.linker = linker app.bundle = appBundle + // app is trusted, refresh trust for any updated configured link keys: + await this.trust({ z32: ctx.key.z32 }, client) + if (this.swarm) appBundle.join(this.swarm) try { diff --git a/lib/worker.js b/lib/worker.js new file mode 100644 index 000000000..77e7c0162 --- /dev/null +++ b/lib/worker.js @@ -0,0 +1,54 @@ +'use strict' +const { isElectronRenderer, isWindows, isBare } = require('which-runtime') +const fs = isBare ? require('bare-fs') : require('fs') +const { spawn } = isBare ? require('bare-subprocess') : require('child_process') +const Pipe = isBare + ? require('bare-pipe') + : class Pipe extends require('net').Socket { constructor (fd) { super({ fd }) } } +const constants = require('./constants') +const noop = Function.prototype +const program = global.Bare || global.process + +class Worker { + #pipe = null + #ref = null + #unref = null + #ipc = null + constructor ({ ref = noop, unref = noop, ipc = null } = {}) { + this.#ref = ref + this.#unref = unref + this.#ipc = ipc + } + + run (link) { + if (isElectronRenderer) return this.#ipc.workerRun(link) + const sp = spawn(constants.RUNTIME, ['run', link], { + stdio: ['inherit', 'inherit', 'inherit', 'pipe'], + windowsHide: true + }) + this.#ref() + sp.once('exit', () => this.#unref()) + const pipe = sp.stdio[3] + return pipe + } + + pipe () { + if (this.#pipe) return this.#pipe + const fd = 3 + try { + const isWorker = isWindows ? fs.fstatSync(fd).isFIFO() : fs.fstatSync(fd).isSocket() + if (isWorker === false) return null + } catch { + return null + } + const pipe = new Pipe(fd) + this.#pipe = pipe + pipe.once('close', () => { + // allow close event to propagate between processes before exiting: + setImmediate(() => program.exit()) + }) + return pipe + } +} + +module.exports = Worker