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 the same way the main process will, right before handing them to the worker as positional arguments—the worker itself never touches package.json directly, it only reads Bare.argv:
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 config depends on before any PearRuntime instance or network activity exists—just the two fields, read the same way the main process will read them once it exists (next step but one).
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 this same workers/main.js, as a one-line require('hello-pear-worker'). The file below is that worker inlined, so you can see (and, if you vendor it as this guide does, edit) every line:
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') {
try {
await pear.ready()
await pear.updater.applyUpdate()
pipe.write('pear:updateApplied')
} catch (err) {
pipe.write('pear:updateFailed ' + (err.message || 'unknown error'))
}
} 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', or 'pear:updateFailed <message>' if the apply throws; 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.
Packaging matters for the swap, not just the wiring. applyUpdate() looks for the new build under by-arch/<platform>-<arch>/app/<name> inside the staged release—name is the fourth positional argument above, and it names the artifact inside the release—then swaps that over the path it was given as app, the sixth argument. Get either wrong and the swap can't find its source or can't find what to replace. That means: your packaged artifact's asar archive must stay disabled—the worker is a real Bare binary spawned from a file path on disk, and asar hides that path—and Snap or Flatpak builds can't apply an OTA swap at all, because their install mount is read-only (they update via their own stores instead). Build desktop distributables covers packaging in full; skim it before you package a build you intend to swap into.
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
Three responsibilities live in the main process: deriving where the worker's config comes from, spawning it and relaying its events to the renderer, and exposing apply/relaunch 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 parses a fuller set of dev flags in its own parseArgs helper, but the updates contract itself is one boolean—default true, flipped by a --no-updates flag:
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
const PearRuntime = require('pear-runtime')
const FramedStream = require('framed-stream')
const { isLinux, isMac, isWindows } = require('which-runtime')
const { name, productName, version, upgrade } = require('../package.json')
const appName = productName ?? name
const extension = isLinux ? '.AppImage' : isMac ? '.app' : '.msix'
// `--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')applyUpdate() finds the staged build under by-arch/<host>/app/<name> and swaps it in for the app's own path on disk, so it needs to know that path. pear-chat's getAppPath() derives it per platform—self-contained, so it's worth quoting directly rather than re-deriving:
function getAppPath () {
if (!app.isPackaged) return null
if (isLinux && process.env.APPIMAGE) return process.env.APPIMAGE
if (isWindows) return process.execPath
return path.join(process.resourcesPath, '..', '..')
}pear-chat also namespaces its storage directory to support a --storage dev flag for running multiple instances side by side—skip that here and just use Electron's own per-app data directory, which PearRuntime is happy to nest its own pear-runtime/ and app-storage/ subfolders under:
let updaterPipe = null
function sendToAll (name, data) {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(name, data)
}
}
function getUpdaterPipe () {
if (updaterPipe) return updaterPipe
const worker = PearRuntime.run(require.resolve('../workers/main.js'), [
updates,
version,
upgrade,
appName + extension,
app.getPath('userData'),
getAppPath()
])
const pipe = new FramedStream(worker)
worker.stdout.pipe(process.stdout)
worker.stderr.pipe(process.stderr)
function onData (data) {
const message = data.toString()
if (message === 'updating') sendToAll('pear:event:updating', 'updating')
else if (message === 'updated') sendToAll('pear:event:updated', 'updated')
}
pipe.on('data', onData)
// Without this, a crashed worker leaves a dead pipe cached and every later
// getUpdaterPipe() hands it back.
worker.once('exit', () => {
pipe.removeListener('data', onData)
updaterPipe = null
})
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, dir, and app. The returned duplex doubles as the worker's stdout/stderr streams and is wrapped in the same FramedStream framing the worker uses on its side for the IPC pipe; its 'updating'/'updated' strings are relayed to every window as pear:event:updating/pear:event:updated.
Nothing calls getUpdaterPipe() yet—do that once, after the window is up, so the worker actually starts:
app.whenReady().then(() => {
createWindow()
getUpdaterPipe()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})With the worker running, two ipcMain.handle calls give the renderer an awaitable bridge into it. The first asks the worker to apply the staged update and waits for its acknowledgement before resolving—or rejecting, if the worker reports pear:updateFailed or the pipe closes because the worker died mid-apply:
ipcMain.handle('pear:applyUpdate', () => {
const pipe = getUpdaterPipe()
return new Promise((resolve, reject) => {
function done (err) {
pipe.removeListener('data', onData)
pipe.removeListener('close', onClose)
if (err) reject(err)
else resolve()
}
function onData (data) {
const message = data.toString()
if (message === 'pear:updateApplied') done(null)
else if (message.startsWith('pear:updateFailed')) {
done(new Error(message.slice('pear:updateFailed '.length) || 'Update failed'))
}
}
// A worker that dies mid-apply never replies; without this the promise
// would stay pending forever.
function onClose () {
done(new Error('updater worker exited before replying'))
}
pipe.on('data', onData)
pipe.once('close', onClose)
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 settles once the worker's reply comes back, so the renderer gets a genuine "the swap finished" (or "it failed, here's why") signal to act on.
What this handler still doesn't cover. It settles three ways—applied, failed, or the pipe closing under it—so a worker that throws or dies no longer strands the promise. Two gaps remain. A worker that stays alive but never replies leaves it pending forever; add a timeout if that worries you, sized above a slow MSIX install rather than a fast fsx.swap. And the messages aren't request-correlated, so if two applies are ever in flight at once, one reply settles both—the reply carries no id to match it to a request. Neither bites while the UI keeps a single apply in flight, which is exactly what the next step does.
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. The highlighted lines above 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 (triggered by the swap applyUpdate() already performed) 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. This app's electron/preload.js only needs three properties: thin wrappers around the previous step's two handlers, plus a subscribe/unsubscribe helper for the relayed events. pear-chat's own electron/preload.js builds the same three properties into a larger contextBridge.exposeInMainWorld('bridge', { ... }) call that also covers its multi-worker and clipboard features; here's the minimal version:
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('bridge', {
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—applyUpdate() now rejects if the underlying ipcMain.handle call rejected, so await it inside a try/catch. 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 () => {
banner.onclick = null // one apply in flight at a time
try {
await bridge.applyUpdate()
await bridge.appAfterUpdate()
} catch (err) {
banner.textContent = 'Update failed: ' + err.message
}
}
})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. Clearing banner.onclick before awaiting keeps a second click from firing a second pear:applyUpdate while the first is still in flight.
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. Watch the terminal npm start is running in (worker.stdout.pipe(process.stdout) sends the worker's own output there): this guide's vendored workers/main.js logs Application storage: <path> once pear.ready() resolves. If that line appears and the app window opens with no thrown 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 the worker's piped-through stdout/stderr in your terminal, not devtools—the worker runs in Bare, not the renderer). getUpdaterPipe()only runs once: closing and reopening a window (activate) doesn't spawn a second worker process.- 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.- Build desktop distributables—packaging a build so
applyUpdate()can actually find and swap it. - 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.