Integrate Pear OTA into an existing Electron app
Add over-the-air updates to an app you already have: install pear-runtime, mint an upgrade link, add a dedicated updater worker, and wire the main-process apply/relaunch flow.
This guide is for an app you already have—an existing Electron (or plain Node/Bare) codebase you want to keep, just wired for peer-to-peer over-the-air updates. Starting a brand-new app instead? See Start from a template. Still calling the removed global Pear API (pear run)? That's a different migration—see Migrate from pear run to Pear OTA. Building a React Native or Expo app? See Integrate Pear OTA into an existing mobile app instead.
"Integrating for updates" does not mean adopting a new project shape. It means keeping your app's own structure exactly as it is, adding pear-runtime (Pear OTA) as a dependency, pointing it at a pear:// link, and wiring a handful of call sites so that pear.updater's updating/updated events reach your UI and pear.updater.applyUpdate() actually swaps the app drive and restarts the process. Nothing about your renderer, your build tooling, or your existing peer-to-peer code (if you have any yet) needs to change.
Need the pear CLI? Install it from install.pears.com, or prefix any command below with npx. See Install & upgrade for details.
What this guide does NOT cover
- Removing a legacy
global.Pearintegration. If your app still calls the removedpear runruntime or the ambientPearglobal, that's a different starting point—follow Migrate from pear run to Pear OTA instead. - The stage → provision → multisig release cascade. This guide mints and uses a throwaway development link so you can run the app locally. The production release flow—stage, provision, and multisig sign-off—is Deploy your application's job.
- Mobile apps. For React Native or Expo, see Integrate Pear OTA into an existing mobile app.
Before you begin
- An existing Electron (or plain Node/Bare) app you want to add updates to.
- To follow every step exactly, clone this guide's example app and start from its "before" state; to wire your own app instead, apply the same steps against your project. The reference implementation is
integrate-pear-ota-into-an-existing-app—a plain Electron app with nopear-runtimedependency, no updater worker, and noupgradelink yet. - Node v22.17+ and npm v10.9+.
- The
pearCLI installed (see the callout above).
What you're adding
| Layer | What you add |
|---|---|
| Dependencies | pear-runtime plus the modules the updater worker requires |
| Config | version and upgrade fields in package.json |
| Updater worker | A dedicated Bare worker that owns the PearRuntime instance |
| Main-process wiring | Spawn the worker, relay its events, expose apply/relaunch over IPC |
| UI affordance (optional) | A button or banner driven by the updating/updated events |
Steps
Install the dependencies
pear-runtime is the embeddable Pear OTA library. The updater worker added two steps from now is a copy of upstream's hello-pear-worker, so its own dependencies have to resolve from your project too—and your main process needs framed-stream (to frame the worker pipe) and which-runtime (to branch the relaunch by platform):
npm install pear-runtime hyperswarm corestore framed-stream graceful-goodbye bare-path bare-storage which-runtimePrefer not to vendor the worker? npm install hello-pear-worker pulls the same worker as a package, with all of the above as its own transitive dependencies, and your workers/main.js becomes a one-line require('hello-pear-worker'). This guide vendors the file instead so every line is visible and editable.
Nothing else changes yet—your app has no upgrade link and no PearRuntime instance until the later steps wire them up.
First-time package.json setup
Mint a link to develop against. pear touch creates a fresh pear:// link backed by its own Hypercore:
pear touch
# pear://qxenz5wmspmryjc13m9yzsqj1conqotn8fb4ocbufwtz9mtbqq5oAdd version and upgrade to package.json. pear-runtime only swaps in an update when a build's version is higher than the one currently installed, and upgrade is the link it polls:
{
"version": "1.0.0",
"upgrade": "pear://qxenz5wmspmryjc13m9yzsqj1conqotn8fb4ocbufwtz9mtbqq5o",
...
}Both fields are required before anything in the following steps can run: the main process reads them straight out of package.json and hands them to the worker as positional arguments, and upgrade is a required PearRuntime option.
This is a development link, minted for you alone—nothing is seeding it yet, so no peer will ever see an update pushed to it. Production link selection (a stage, provision, or multisig link) is Deploy your application's job, not this guide's.
Before wiring the runtime up for real, it's worth confirming the two fields resolve the way pear-runtime expects them to. Starting from the example app's own package.json (already at 1.0.0, with no upgrade field yet):
npm pkg set version=1.0.0 upgrade=pear://qxenz5wmspmryjc13m9yzsqj1conqotn8fb4ocbufwtz9mtbqq5oThen a small script that reads them back exactly the way the updater worker's argv() will—via require('./package.json'):
const { version, upgrade } = require('./package.json')
console.log('version:', version)
console.log('upgrade:', upgrade)Run it with Node:
node check-config.jsIt prints both fields back:
version: 1.0.0
upgrade: pear://qxenz5wmspmryjc13m9yzsqj1conqotn8fb4ocbufwtz9mtbqq5oThat's the entire contract the updater worker depends on—no PearRuntime instance, no network, just the two fields it reads out of package.json.
Add a dedicated updater worker
Run the updater in its own Bare worker, separate from whatever worker (if any) already carries your app's peer-to-peer logic. Reshape into a production app uses the same split for exactly this reason: it "embeds the pear-runtime OTA updater in its own Bare worker, so update traffic never blocks the chat." The update poll, the Corestore replication it drives, and the swarm connection it maintains all happen off to the side—your main worker and your renderer never wait on any of it.
Every hello-pear-* template ships the same workers/main.js. The file below is the canonical body, shared verbatim across all of them:
const PearRuntime = require('pear-runtime') // pear-runtime on desktop; pear-mobile on mobile
const Hyperswarm = require('hyperswarm')
const Corestore = require('corestore')
const goodbye = require('graceful-goodbye')
const FramedStream = require('framed-stream')
const path = require('bare-path')
const dir = require('bare-storage')
const { isBareKit } = require('which-runtime')
// mobile doesn't have the executable path (argv[0])
// and the worker entry path (argv[1]) in the workers argv's
// ... to reuse the same worker in all platforms this logic is needed
const argv = (index) => Bare.argv[index + (isBareKit ? 0 : 2)]
const updaterConfig = {
updates: argv(0) !== 'false',
version: argv(1),
upgrade: argv(2),
name: argv(3),
dir: argv(4) || dir.persistent(), // argv[4] is undefined in mobile
app: argv(5) // argv[5] is undefined in mobile
}
const pipe = new FramedStream(Bare.IPC)
const store = new Corestore(path.join(updaterConfig.dir, 'pear-runtime', 'corestore'))
const swarm = new Hyperswarm()
const pear = new PearRuntime({ ...updaterConfig, swarm, store })
pear.updater.on('error', console.error)
if (updaterConfig.updates !== false) {
swarm.on('connection', (connection) => store.replicate(connection))
swarm.join(pear.updater.drive.core.discoveryKey, {
client: true,
server: false
})
}
console.log('Application storage:', pear.storage)
pear.updater.on('updating', () => pipe.write('updating'))
pear.updater.on('updated', () => pipe.write('updated'))
pear.on('minver-required', () => pipe.write('minver-required')) // for mobile store update notification
goodbye(async () => {
await swarm.destroy()
await pear.close()
await store.close()
})
pipe.on('data', async (data) => {
const message = data.toString()
if (message === 'pear:applyUpdate') {
await pear.ready()
await pear.updater.applyUpdate()
pipe.write('pear:updateApplied')
} else console.log(message)
})
pipe.write('Hello from worker')Reading it top to bottom: argv() reads the positional arguments the host process passes it (with an index offset for Bare Kit / mobile, where argv[0] and argv[1] aren't the executable and worker paths the way they are under plain Bare). Those six arguments become the PearRuntime options—updates, version, upgrade, name, dir, and app. The worker builds its own Corestore and Hyperswarm, hands them to PearRuntime via the store/swarm options, and—unless updates is false—joins the update drive's discovery key so it can replicate. It relays updating, updated, and minver-required over the IPC pipe as plain strings. The one message it acts on coming back is 'pear:applyUpdate'—on receipt it awaits pear.updater.applyUpdate() and replies 'pear:updateApplied'; anything else it just logs.
Copy this file into your project as workers/main.js (or wherever your build already spawns workers from), unchanged. It takes its entire configuration from Bare.argv, so the only thing you customize is what the host process passes it—that's the next step.
Non-Electron apps. If you're wiring a plain Node or Bare app rather than Electron, skip the Wire the main process and Expose applyUpdate and appAfterUpdate on the preload bridge steps below—there's no separate main process or preload bridge to wire. Instantiate PearRuntime directly in your single entrypoint, attach the pear.updater updating/updated listeners there, call pear.updater.applyUpdate() when you're ready to swap, and restart your own process yourself. There's no app.relaunch() equivalent outside Electron—re-exec with process.execPath and process.argv, or hand the restart to your process manager.
Wire the main process
Two responsibilities live in the main process: spawning the updater worker with the right configuration, and relaying the worker's events to the renderer over IPC.
The configuration comes from two places. version, upgrade, name, and productName are read straight out of package.json (the fields set two steps ago); updates is a runtime flag, so the main process parses it off its own argv. pear-chat does it in a parseArgs helper, but the whole contract is one boolean—default true, flipped by a --no-updates flag:
const { name, productName, version, upgrade } = require('../package.json')
// `--no-updates` is what the "Run it locally" step below passes to boot the
// app with the updater constructed but never swarming.
const updates = !process.argv.includes('--no-updates')With those in hand, pear-chat's electron/main.js spawns the worker and relays its events:
// Updater worker. pear-runtime + the updater swarm/store run in bare, mirroring
// hello-pear-electron. The main process only spawns it and relays messages.
function getUpdaterPipe () {
if (updaterPipe) return updaterPipe
const dir = getStorageDir()
const appPath = getAppPath()
const extension = isLinux ? '.AppImage' : isMac ? '.app' : '.msix'
const worker = PearRuntime.run(require.resolve('..' + updaterSpecifier), [
updates,
version,
upgrade,
productName + extension,
dir,
appPath
])
const pipe = new FramedStream(worker)
function onData (data) {
const message = data.toString()
if (message === 'updating') sendToAll('pear:event:updating', 'updating')
else if (message === 'updated') sendToAll('pear:event:updated', 'updated')
}
function onStderr (data) {
process.stderr.write(data)
}
function onBeforeQuit () {
pipe.destroy()
}
pipe.on('data', onData)
worker.stderr.on('data', onStderr)
worker.once('exit', () => {
app.removeListener('before-quit', onBeforeQuit)
pipe.removeListener('data', onData)
worker.stderr.removeListener('data', onStderr)
updaterPipe = null
})
app.on('before-quit', onBeforeQuit)
updaterPipe = pipe
return pipe
}PearRuntime.run() is a static method, so the main process calls it directly without needing its own PearRuntime instance—it only needs the worker's entrypoint and the positional arguments the worker's argv() expects, in order: updates, version, upgrade, name (here productName plus a per-platform extension, since applyUpdate() uses it to find the bundled app on disk), dir, and app. The returned duplex is wrapped in the same FramedStream framing the worker uses on its side, and its 'updating'/'updated' strings are relayed to every window as pear:event:updating/pear:event:updated.
Two ipcMain.handle calls give the renderer an awaitable bridge into that worker. The first asks the worker to apply the staged update and waits for its acknowledgement before resolving:
ipcMain.handle('pear:applyUpdate', () => {
const pipe = getUpdaterPipe()
return new Promise((resolve) => {
function onData (data) {
if (data.toString() === 'pear:updateApplied') {
pipe.removeListener('data', onData)
resolve()
}
}
pipe.on('data', onData)
pipe.write('pear:applyUpdate')
})
})The renderer never calls pear.updater.applyUpdate() itself—it can't; the PearRuntime instance lives in the worker, not the renderer or even the main process. Instead, pear:applyUpdate writes 'pear:applyUpdate' down the pipe and only resolves its promise once the worker's 'pear:updateApplied' reply comes back, so the renderer gets a genuine "the swap finished" signal to act on.
The second handler is what actually restarts the app once the swap is done:
ipcMain.handle('app:afterUpdate', () => {
if (isLinux && process.env.APPIMAGE) {
app.relaunch({
execPath: process.env.APPIMAGE,
args: [
'--appimage-extract-and-run',
...process.argv.slice(1).filter((arg) => arg !== '--appimage-extract-and-run')
]
})
} else if (!isWindows) {
app.relaunch()
}
app.quit()
})Relaunching a packaged .AppImage on Linux needs execPath pointed at the APPIMAGE environment variable and the --appimage-extract-and-run flag—plain app.relaunch() on its own restarts the temporary FUSE-mounted copy, not the file that was just updated on disk. Lines 235–242 handle that case. Everywhere else, app.relaunch() is enough, except on Windows: this code quits without an explicit relaunch call there, leaving the MSIX-installed binary's own restart path to bring the app back. Either way, app.quit() runs unconditionally last.
Expose applyUpdate and appAfterUpdate on the preload bridge
The renderer runs with contextIsolation: true, so it never touches ipcRenderer directly—the preload script is the only bridge across. pear-chat's electron/preload.js exposes the two handlers from the previous step, plus a subscribe/unsubscribe helper for the relayed events (the excerpt is the middle of one contextBridge.exposeInMainWorld('bridge', { ... }) call):
applyUpdate: () => ipcRenderer.invoke('pear:applyUpdate'),
appAfterUpdate: () => ipcRenderer.invoke('app:afterUpdate'),
onPearEvent: (name, listener) => {
const wrap = (evt, eventName) => listener(eventName)
ipcRenderer.on('pear:event:' + name, wrap)
return () => ipcRenderer.removeListener('pear:event:' + name, wrap)
},bridge.applyUpdate() and bridge.appAfterUpdate() are thin ipcRenderer.invoke() wrappers around the two handlers from the previous step. bridge.onPearEvent(name, listener) subscribes to pear:event:<name>—call it with 'updating' and 'updated'—and returns an unsubscribe function, so a component can clean up its listener when it unmounts.
Wire a UI affordance (optional)
Nothing requires a visible update indicator—updates apply whenever you call bridge.applyUpdate(), on whatever schedule fits your app. Most apps show something anyway. A minimal version, in a renderer script (the example app ships an empty <div id="update-banner" hidden> for it to drive), built on the bridge from the previous step:
const banner = document.getElementById('update-banner')
bridge.onPearEvent('updating', () => {
banner.textContent = 'Downloading update…'
banner.hidden = false
})
bridge.onPearEvent('updated', () => {
banner.textContent = 'Update ready'
banner.hidden = false
banner.onclick = async () => {
await bridge.applyUpdate()
await bridge.appAfterUpdate()
}
})updating fires as soon as a newer version starts downloading; updated fires once it has finished staging and is safe to apply. Waiting for a click—rather than applying the moment updated fires—avoids swapping the app out from under a user mid-task. Apply whenever suits your app, including immediately.
Run it locally
Start the app with updates disabled, to confirm everything constructs cleanly before any network activity happens:
npm start -- --no-updatesThat flag is the one the main process parses in the Wire the main process step. The updater worker still runs new PearRuntime({ ...updaterConfig, swarm, store })—it just never joins the swarm, because updates: argv(0) !== 'false' evaluates to false. If the worker logs Application storage: <path> and the app window opens with no unhandled error, the wiring is in place. Nothing here has talked to a peer yet.
Verify the wiring (local smoke test)
At this point you've confirmed the wiring compiles and boots—not that an update actually flows end to end. Checklist:
- The app boots with
--no-updatesand the updater worker'snew PearRuntime(...)call doesn't throw (watch for theApplication storage:log line from the worker). - The
pear.updater.on('updating', ...)andpear.updater.on('updated', ...)listeners attach without an unhandled rejection. - The apply/relaunch control—your button, or a manual
bridge.applyUpdate()call from devtools—reachesipcMain.handle('pear:applyUpdate', ...)andipcMain.handle('app:afterUpdate', ...)without an IPC "no handler registered" error.
For the real end-to-end proof—two running versions, an actual staged update, and the updated event firing over the network—run through Confirm stage updates in the deployment guide. This guide stops at "the wiring is in place and inert"; it does not repeat that live two-version walkthrough.
See also
pear-runtimereference—the full options list (dir,upgrade,name,version,app,updates,storage,store,swarm,bundled,delay,skipUpdate) and theupdaterevents.- Configuration—the
package.jsonversionandupgradefields. - Migrate from pear run to Pear OTA—for apps still calling the removed global
PearAPI. - Deploy your application—the stage → provision → multisig production release flow this guide's development link stands in for.
- Reshape into a production app—builds the same updater-worker split from scratch, alongside a chat worker.
- Start from the hello-pear-electron template—if you'd rather start from the finished template than integrate into an existing app.
- Pear desktop application architecture—the conceptual picture behind splitting updates, storage, and workers.
- Troubleshoot desktop releases—tuning
delayso updates aren't invisible during testing. - Integrate Pear OTA into an existing mobile app—the React Native/Expo counterpart to this guide.