Pear Docs

Integrate Pear OTA into an existing mobile app

Add over-the-air updates to a React Native or Expo app you already have: install pear-mobile, bundle a Bare worklet, wire the updater and minver gate, and handle the platform-specific restart.

This guide is for a React Native or Expo app you already have, adding Pear Mobile OTA to it. For the desktop/Electron equivalent, see Integrate Pear OTA into an existing Electron app. Starting from scratch instead? Start from the hello-pear-react-native template clones a finished boilerplate with all of this already wired.

pear-mobile and pear-runtime-react-native are MVP and experimental—expect the API to keep moving.

Integrating for updates means five things land in your app: the pear-mobile dependency, a bundled Bare worklet that runs your peer-to-peer/update logic off the JS thread, the updater wiring on the React Native side (updating / updated / applyUpdate()), the mobile-only minver gate that stops an OTA from reaching a native build too old for it, and a boot-control patch so a release build knows whether to boot the bundle shipped in the binary or a newer one downloaded over the air. None of this touches your existing screens or navigation—it's additive.

What this guide does NOT cover

This page wires the runtime and the updater UI only. It does not cover the full mobile release ceremony—staging a payload, provisioning it, and multisig-gating production. That reuses Deploy your application's stage/provision/multisig model with a different build step, documented for mobile in the upstream hello-pear-react-native README. Read Version management in that same README before you ship a real release—it carries the mobile-specific sequencing rules for how native and OTA version numbers must interleave.

It also does not cover the raw bare-kit primitive on its own—a bare Worklet and an IPC channel with no updater, storage, or boot control attached. See Embed Bare in a React Native app for that lower layer.

Before you begin

  • An existing React Native or Expo app, with its own screens and navigation already in place.
  • bare-pack and react-native-bare-kit available—pear-mobile runs your worklet through the latter, and you'll bundle for it with the former.
  • The Pear CLI installed, to mint an upgrade link with pear touch.

Need the pear CLI? Install it from install.pears.com, or prefix any command below with npx. See Install & upgrade for details.

What you're adding

PieceAdded via
Dependenciespear-mobile, plus the modules the JS thread and the bundled worker require
Worker bundling stepbare-pack, producing a .bundle file your JS thread imports
Updater worklet codenew PearRuntime(opts) inside the worklet, wrapping pear.updater
JS-thread launcher wiringPearRuntime.run(filename, bundle, argv) in your view component
minver-required handlerpear.on('minver-required', ...) on the worklet side, surfaced to the UI as a store-update prompt
Boot control patchpear-runtime-react-native (Expo) or its hand-applied equivalent

Install the dependencies

npm install pear-mobile react-native-bare-kit framed-stream b4a

pear-mobile is the runtime, react-native-bare-kit the worklet host it runs on, and framed-stream/b4a are what the JS thread uses to frame and decode the worklet's IPC duplex.

The worker added two steps from now brings its own requires, which have to resolve from this same package.json—bare-pack resolves them at pack time, from node_modules, not at runtime on the device:

npm install hyperswarm corestore graceful-goodbye bare-path bare-storage which-runtime

bare-pack, which packs that worker into a bundle three steps from now, isn't one of the worker's own dependencies—install it separately as a dev dependency:

npm install --save-dev bare-pack

pear-mobile resolves to a different export depending on which side of the worklet boundary requires it: a static PearRuntime.run(filename, bundle, argv) launcher from the React Native/Expo JS thread, and the same PearRuntime class shape as desktop's pear-runtime from inside the worklet. See Two entry points, one package for the mechanics.

First-time package.json and pear.json setup

Mint a real upgrade link and set the app's starting version:

pear touch
{
  "version": "1.0.0",
  "upgrade": "pear://qxenz5wmspmryjc13m9yzsqj1conqotn8fb4ocbufwtz9mtbqq5o"
}

Mobile has one more field, in a separate top-level pear.json next to package.json: updates.minver, the compatibility floor (see The minver gate). Give it a real value now—your current native version—rather than leaving it unset, so the gate has something meaningful to compare against from the first release:

{
  "updates": {
    "minver": "1.0.0"
  }
}

The gate is not read locally: pear-mobile reads /pear.json out of the incoming update drive and compares that payload's updates.minver against the version argv the JS thread passed when it started the worklet—see Start the worklet from the JS thread three steps from now. So this file is the floor you are declaring for the payload you are about to publish, not a check on the build you are running—and it travels inside the payload, which means it can't be retrofitted onto one that's already staged.

Bump updates.minver whenever a later release changes the native/OTA contract—an OTA payload that assumes native code, ABI, or config a given binary doesn't have must be gated off from that binary; set it to that release's own version. Leaving it at its previous value is correct for any release that doesn't change that contract.

"Native version" only holds until the first OTA activates. version is read from ../package.json inside the JS bundle that's currently executing—on a fresh native install that's the native build's own version, but once an OTA has activated, the running bundle is the OTA's bundle, and its baked-in version is the OTA's version, not the original binary's. The minver floor is really compared against whichever JS version last booted, not literally "the native binary" once your app has taken at least one OTA. Keep this in mind when reasoning about what a given minver bump actually gates.

Add the updater worker

This is the same workers/main.js every hello-pear-* template that has a worker ships, and the same one the Electron guide uses—one file that owns the PearRuntime instance and nothing else. Keep it dedicated to the updater: if your app has (or grows) its own peer-to-peer logic, put that in a second worker so update traffic never blocks it.

Unlike on desktop, how you bring this file in matters here, because the specifier that picks pear-mobile over pear-runtime lives in a package's imports map, not in Node's normal resolution:

npm install hello-pear-worker

workers/main.js becomes a one-line require('hello-pear-worker'). That package's own imports map is what redirects its internal require('pear-runtime') to pear-mobile on iOS/Android/simulator—see Two entry points, one package. This is the path to reach for first on mobile: skip it and bare-pack fails at pack time with MODULE_NOT_FOUND: Cannot find module 'pear-runtime', because nothing in a bare app resolves pear-runtime to anything on iOS or Android.

Prefer to see (and edit) every line instead? Vendor the file—but you take over the imports map yourself, in your own package.json, before it will pack:

{
  "imports": {
    "pear-runtime": {
      "ios": "pear-mobile",
      "android": "pear-mobile",
      "simulator": "pear-mobile",
      "default": "pear-runtime"
    }
  }
}

Either way—package or vendored copy—here's what's actually running:

Everything peer-to-peer lives in workers/main.js, which runs in Bare (not Node). The host passes the runtime configuration as positional arguments; the worker reads them (with an argv helper for cross-platform compatibility) and constructs the pear-runtime instance:

workers/main.js
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 })

This is where you add your Corestore cores, join Hyperswarm topics, and run your protocols. Use pear.storage as the storage root so your data lands in the same per-app directory Pear manages—see Storage and distribution. For why the logic belongs in a worker rather than the UI, see Workers.

Upstream ships this worker as the hello-pear-worker package. hello-pear-electron inlines its source into workers/main.js, so the file you clone is this code under a comment that records the worker's source URL and version. hello-pear-react-native ships workers/main.js as just require('hello-pear-worker'). The code above is the worker inlined so you can see what it does; write your own peer-to-peer logic in workers/main.js the same way.

For this guide, don't add your own peer-to-peer logic to this file the way the callout above suggests for a from-scratch app—keep it dedicated to updates, as the intro to this step said, and start a second worker for anything else.

Nothing in the worker's own code needs to change for mobile beyond the imports map above. One more place does the rest of the platform-specific work for you: lines 8–13 in the canonical copy branch on isBareKit from which-runtime to pick the right offset into Bare.argv—mobile's worklet argv has no executable-path or entry-path slot the way a spawned desktop process does, so the same file produces a correct config object on both sides without edits.

Bundle the worker

There's no filesystem for a mobile worklet to load a file from at runtime, so the worker has to be packed into a bundle and imported as a JS module. bare-pack does the packing; target every platform you ship to in one combined bundle:

npx bare-pack --linked --host ios --host android --out src/worker.bundle.js workers/main.js

--linked is required here—iOS and Android link native addons ahead of time rather than loading them from disk, so a bundle built without it fails on device. See Bundle a Bare app for the rest of the flags. Wire this behind an npm script (the hello-pear-react-native template calls its version bundle:bare) so it's easy to remember.

Nothing rebuilds this automatically, and a stale bundle fails silently. The app still boots and looks normal—it's just running whatever worker code was packed last time. Re-run the bundle command after every change to workers/main.js or anything it imports, every time, before you reload the app.

Start the worklet from the JS thread

In the component that owns your app's lifetime, start the worklet with the bundle's content (not a path—there's no disk to load from) and wrap the IPC duplex it returns in a framing layer so messages arrive as discrete chunks instead of a raw byte stream:

src/App.tsx
    const IPC = PearRuntime.run('/worker.bundle', bundle, [
      (!__DEV__).toString(),
      version,
      upgrade,
      appName
    ])
    const pipe = new FramedStream(IPC)
    pipeRef.current = pipe

updatesEnabled, version, upgrade, and the product name are passed as argv—the same four positional values workers/main.js's argv() helper reads back out on the other side. There is no instance on this side of the boundary; PearRuntime.run is static. See Starting the worklet (React Native side) for the full signature.

This repo's copy of the example keeps the framed pipe in a ref so the rest of the component can write to it—that ref is the only way to trigger an apply:

src/App.tsx
  const applyUpdate = useCallback(() => {
    pipeRef.current?.write('pear:applyUpdate')
  }, [])

Call that from whatever affordance you show when an update is ready. The worker turns 'pear:applyUpdate' into pear.updater.applyUpdate() and answers on the same pipe.

Upstream's hello-pear-react-native holds that same write in useState rather than a ref, so a fresh clone of the template looks slightly different here. This repo's copy moved it to a ref to clear a react-hooks set-state-in-effect lint error; the behaviour is identical either way.

Wire the update and minver events

Everything from the worklet arrives on the same pipe, as plain Uint8Array chunks—decode with b4a.toString(data) rather than data.toString(), which yields a comma-joined byte list. Route each message to UI state:

src/App.tsx
    pipe.on('data', (data) => {
      const parsed = b4a.toString(data)

      if (parsed === 'updating') {
        setStatus('updating')
        return
      }

      if (parsed === 'updated') {
        setStatus('updated')
        return
      }

      if (parsed === 'minver-required') {
        setStatus('incompatible')
        return
      }

      if (parsed.startsWith('pear:updateFailed')) {
        shouldReload.current = false
        setError(parsed.slice('pear:updateFailed '.length) || 'Update failed')
        setStatus('failed')
        return
      }

      if (parsed === 'pear:updateApplied') {
        if (shouldReload.current) {
          reloadAppAsync('Pear update applied').catch((err) => {
            setError(`Reload failed: ${err instanceof Error ? err.message : String(err)}`)
            setStatus('failed')
          })
          return
        }

        setStatus('')
        return
      }
    })

updating and updated mirror desktop's pear.updater events one-to-one—show progress, then an "apply" affordance once updated arrives. pear:updateApplied and pear:updateFailed are the two replies to the 'pear:applyUpdate' write from the previous step: the first is the cue to restart (see the last step), the second carries an error message to surface.

minver-required is mobile-only: it means an update is sitting on the swarm, but the running native build is older than the OTA's declared floor, so nothing was downloaded. There's nothing to retry here—the fix is a new binary. Treat it as a terminal state and prompt the user to update from the App Store or Play Store, the same way this template does with its `Update available on the ${Platform.OS === 'ios' ? 'App Store' : 'Play Store'}` message. See The minver gate for why this exists.

Add boot control

A release build has to decide, on every launch, whether to boot the JS bundle shipped inside the binary or a newer one your app already downloaded and staged via pear-mobile. That decision is what pear-runtime-react-native patches in: bundleURL() on iOS, and the jsBundleFilePath passed into getDefaultReactHost() on Android, plus a small generated Expo module on Android that repeats the check on every reload. Install it and register it as an Expo config plugin, then run expo prebuild to apply the patch—see Mobile OTA Boot Control for the exact install command and app.json entry.

If your app isn't on Expo, there's no config-plugin path—the same bundleURL() / getJSBundleFile() override has to be applied by hand once, in your native project. Plain React Native has the condition both platforms check (a newer version in the downloaded OTA's package.json than the installed app version) and links the exact Swift and Kotlin the plugin generates, for reference.

Skip this step and updates still download and stage correctly—they just never boot, because nothing on the native side ever looks for them.

Handle the restart

applyUpdate() stages the new bundle on disk, but activation isn't immediate: it happens at the next native bundle selection, and Runtime activation documents when that is. With pear-runtime-react-native 3.0.1 or later, both platforms repeat the selection on every JS reload, so a reload after the apply boots the staged bundle. iOS re-reads bundleURL() on reload, and on Android the generated modules/pear-runtime-reload module repeats the version check.

The template does the JS-level reload, with no Platform.OS branch, on pear:updateApplied—an Expo reloadAppAsync() call (the reloadAppAsync branch in the excerpt in Wire the update and minver events). One call, both platforms, no branching. What that buys depends on how the native side was set up:

  • Expo, with the plugin at 3.0.1 or later—the reload activates the update on iOS and Android. On Android it relies on the generated reload module, so regenerate the native projects with npx expo prebuild --clean after upgrading the package.
  • Plugin 3.0.0 or earlier—Android captures jsBundleFilePath when the React host is created, so the same JS-only reload reuses the old path and the new bundle boots on the next cold start. Either upgrade the plugin, or accept next-launch activation and tell the user the update applies on next open.
  • Plain React Native on Android—the generated reload module requires Expo, so the project needs its own bundle loader that repeats the check on reload, or a full process restart after the apply.

Either way, don't branch the call per platform—branch only the message you show the user, if you show one.

Verify the wiring

  • The worklet starts with no error event—pear.updater.on('error', console.error) on the worker side stays silent. Neither the worker nor the template's App.tsx logs the worker's 'Hello from worker' liveness message, so temporarily add a console.log in the pipe's 'data' handler if you want to confirm it arrives. console.log inside the worklet goes to the system log, not the Metro console: use xcrun simctl spawn booted log stream on iOS or adb logcat on Android.
  • The bundle is freshly rebuilt after any change to the worker or something it imports—rerun bare-pack and confirm the timestamp on worker.bundle.js moved, since a stale bundle gives no error and just runs old code.
  • updating → updated fire in a release build (not a dev/Metro build—those always load from Metro, so the update path isn't exercised) when a newer version is staged.
  • Raising a payload's pear.json updates.minver above the currently-running JS bundle's own version (see the callout in First-time package.json and pear.json setup) actually produces the minver-required path—a store-update prompt in the UI—rather than a silent download.
  • The boot-control patch is present after expo prebuild (or your hand-applied equivalent): look for the pear-runtime-react-native OTA v4 marker comment in AppDelegate.swift and MainApplication.kt, and for the generated modules/pear-runtime-reload folder on Android.
  • After applyUpdate(), the reload boots the new bundle on both a release iOS build and a release Android build—not just one of them. If Android keeps the old bundle until the app is relaunched, the native projects came from an older plugin version.

See also

Last updated on

Was this helpful?

On this page