# Integrate Pear OTA into an existing Electron app





<Callout type="info">
  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](/p2p/getting-started/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](/pear/how-to/operate-an-app/migration). Building a React Native or Expo app? See [Integrate Pear OTA into an existing mobile app](/pear/how-to/operate-an-app/integrate-pear-ota/mobile) instead.
</Callout>

"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/reference/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.

<Callout type="info">
  **Need the `pear` CLI?** Install it from **[install.pears.com](https://install.pears.com)**, or prefix any command below with `npx`. See [Install & upgrade](/pear/reference/pear/cli#install) for details.
</Callout>

## What this guide does NOT cover [#what-this-guide-does-not-cover]

<Callout type="warn">
  * **Removing a legacy `global.Pear` integration.** If your app still calls the removed `pear run` runtime or the ambient `Pear` global, that's a different starting point—follow [Migrate from pear run to Pear OTA](/pear/how-to/operate-an-app/migration) 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](/pear/how-to/operate-an-app/manual-deployment/deployment)'s job.
  * **Mobile apps.** For React Native or Expo, see [Integrate Pear OTA into an existing mobile app](/pear/how-to/operate-an-app/integrate-pear-ota/mobile).
</Callout>

## Before you begin [#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`](https://github.com/holepunchto/pear-docs/tree/published/examples/how-to/operate-an-app/integrate-pear-ota-into-an-existing-app)—a plain Electron app with no `pear-runtime` dependency, no updater worker, and no `upgrade` link yet.
* Node v22.17+ and npm v10.9+.
* The `pear` CLI installed (see the callout above).

## What you're adding [#what-youre-adding]

| Layer                    | What you add                                                                                               |
| ------------------------ | ---------------------------------------------------------------------------------------------------------- |
| Dependencies             | [`pear-runtime`](https://github.com/holepunchto/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 [#steps]

<Steps>
  <Step>
    ### Install the dependencies [#install-the-dependencies]

    [`pear-runtime`](https://github.com/holepunchto/pear-runtime) is the embeddable [Pear OTA](/pear/reference/pear/runtime) library. The updater worker added two steps from now is a copy of upstream's [`hello-pear-worker`](https://github.com/holepunchto/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):

    ```sh skip="install-instruction"
    npm install pear-runtime hyperswarm corestore framed-stream graceful-goodbye bare-path bare-storage which-runtime
    ```

    <Callout type="info">
      Prefer 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.
    </Callout>

    Nothing else changes yet—your app has no `upgrade` link and no `PearRuntime` instance until the later steps wire them up.
  </Step>

  <Step>
    ### First-time `package.json` setup [#first-time-packagejson-setup]

    Mint a link to develop against. `pear touch` creates a fresh `pear://` link backed by its own [Hypercore](/p2p/reference/building-blocks/hypercore):

    ```sh skip="release-flow"
    pear touch
    # pear://qxenz5wmspmryjc13m9yzsqj1conqotn8fb4ocbufwtz9mtbqq5o
    ```

    Add `version` and `upgrade` to `package.json`. [`pear-runtime`](/pear/reference/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:

    ```json skip="release-flow"
    {
      "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](/pear/reference/pear/runtime#options).

    <Callout type="warn">
      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](/pear/how-to/operate-an-app/manual-deployment/deployment#1-set-the-upgrade-link)'s job, not this guide's.
    </Callout>

    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):

    ```sh example=integrate-pear-ota-config step=setup
    npm pkg set version=1.0.0 upgrade=pear://qxenz5wmspmryjc13m9yzsqj1conqotn8fb4ocbufwtz9mtbqq5o
    ```

    Then 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`:

    ```js file=<rootDir>/examples/how-to/operate-an-app/integrate-pear-ota-into-an-existing-app/check-config.js title="check-config.js" skip="example-import"
    const { version, upgrade } = require('./package.json')
    console.log('version:', version)
    console.log('upgrade:', upgrade)
    ```

    Run it with Node:

    ```sh example=integrate-pear-ota-config step=run process=config expect="version: 1.0.0"
    node check-config.js
    ```

    It prints both fields back:

    ```text skip="sample-output"
    version: 1.0.0
    upgrade: pear://qxenz5wmspmryjc13m9yzsqj1conqotn8fb4ocbufwtz9mtbqq5o
    ```

    That'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).
  </Step>

  <Step>
    ### Add a dedicated updater worker [#add-a-dedicated-updater-worker]

    Run the updater in its **own** [Bare worker](/pear/explanation/workers), separate from whatever worker (if any) already carries your app's peer-to-peer logic. [Reshape into a production app](/p2p/getting-started/build-a-peer-to-peer-chat/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.

    The `hello-pear-electron` and `hello-pear-react-native` templates ship this same worker 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:

    ```js file=<rootDir>/examples/getting-started/hello-pear-electron/workers/main.js title="workers/main.js" skip="example-import"
    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', (err) => pipe.write('updater-error ' + err.message))

    if (updaterConfig.updates !== false) {
      swarm.on('connection', (connection) => store.replicate(connection))
      swarm.join(pear.updater.drive.core.discoveryKey, {
        client: true,
        server: false
      })
    }

    // An append on the app's drive doesn't check for an update straight away: the
    // updater defers it by a random delay of up to an hour (see PearRuntimeUpdater)
    // so that a released fleet doesn't hit the seeder all at once. Forward the
    // delay so the app can say it is waiting rather than look inert — without it,
    // a staged update is indistinguishable from a broken updater for up to an hour.
    pear.updater.on('update-scheduled', (delay) => pipe.write('update-scheduled ' + delay))
    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') {
        pipe.write('unknown-message ' + message)
        return
      }

      try {
        await pear.ready()
        await pear.updater.applyUpdate()
        pipe.write('pear:updateApplied')
      } catch (err) {
        pipe.write('pear:updateFailed ' + err.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.

    <Callout type="warn">
      **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](/pear/how-to/operate-an-app/build-and-package/build-desktop-distributables) covers packaging in full; skim it before you package a build you intend to swap into.
    </Callout>

    <Callout type="info">
      **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.
    </Callout>
  </Step>

  <Step>
    ### Wire the main process [#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`](https://github.com/holepunchto/pear-docs/tree/published/examples/getting-started/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:

    ```js skip="snippet-illustration"
    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:

    ```js file=<rootDir>/examples/getting-started/pear-chat/electron/main.js#L64-L69 title="electron/main.js" skip="example-import"
    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:

    ```js skip="snippet-illustration"
    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()`](/pear/reference/pear/runtime#running-workers) 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:

    ```js skip="snippet-illustration"
    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:

    ```js file=<rootDir>/examples/getting-started/pear-chat/electron/main.js#L215-L244 title="electron/main.js" skip="example-import"
    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.

    <Callout type="info">
      **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.
    </Callout>

    The second handler is what actually restarts the app once the swap is done:

    ```js file=<rootDir>/examples/getting-started/pear-chat/electron/main.js#L249-L262 title="electron/main.js" lineNumbers {2-9} skip="example-import"
    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.
  </Step>

  <Step>
    ### Expose `applyUpdate` and `appAfterUpdate` on the preload bridge [#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:

    ```js skip="snippet-illustration"
    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.
  </Step>

  <Step>
    ### Wire a UI affordance (optional) [#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:

    ```js skip="snippet-illustration"
    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.
  </Step>

  <Step>
    ### Run it locally [#run-it-locally]

    Start the app with updates disabled, to confirm everything constructs cleanly before any network activity happens:

    ```sh skip="desktop-gui"
    npm start -- --no-updates
    ```

    That 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.
  </Step>
</Steps>

## Verify the wiring (local smoke test) [#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-updates` and the updater worker's `new 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—reaches `ipcMain.handle('pear:applyUpdate', ...)` and `ipcMain.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](/pear/how-to/operate-an-app/manual-deployment/deployment#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 [#see-also]

* [`pear-runtime` reference](/pear/reference/pear/runtime)—the full options list (`dir`, `upgrade`, `name`, `version`, `app`, `updates`, `storage`, `store`, `swarm`, `bundled`, `delay`, `skipUpdate`) and the `updater` events.
* [Build desktop distributables](/pear/how-to/operate-an-app/build-and-package/build-desktop-distributables)—packaging a build so `applyUpdate()` can actually find and swap it.
* [Configuration](/pear/reference/pear/configuration#packagejson)—the `package.json` `version` and `upgrade` fields.
* [Migrate from pear run to Pear OTA](/pear/how-to/operate-an-app/migration)—for apps still calling the removed global `Pear` API.
* [Deploy your application](/pear/how-to/operate-an-app/manual-deployment/deployment)—the stage → provision → multisig production release flow this guide's development link stands in for.
* [Reshape into a production app](/p2p/getting-started/build-a-peer-to-peer-chat/reshape-into-a-production-app)—builds the same updater-worker split from scratch, alongside a chat worker.
* [Start from the hello-pear-electron template](/p2p/getting-started/from-a-template/start-from-hello-pear-electron)—if you'd rather start from the finished template than integrate into an existing app.
* [Pear desktop application architecture](/pear/explanation/pear-desktop-architecture)—the conceptual picture behind splitting updates, storage, and workers.
* [Troubleshoot desktop releases](/pear/how-to/operate-an-app/manual-deployment/troubleshoot-desktop-releases#tune-pearruntime-delay-for-live-ota-visibility)—tuning `delay` so updates aren't invisible during testing.
* [Integrate Pear OTA into an existing mobile app](/pear/how-to/operate-an-app/integrate-pear-ota/mobile)—the React Native/Expo counterpart to this guide.
