Mobile OTA Boot Control
The pear-runtime-react-native Expo config plugin: how it chooses between a native build's bundled JS and a downloaded Pear OTA update.
Mobile OTA Boot Control
Mobile OTA boot control is provided by the pear-runtime-react-native package, an Expo config plugin. It owns exactly one thing: patching a React Native app's generated native projects so a release build boots either the JS bundle shipped in the binary or a newer one downloaded via Pear Mobile OTA.
pear-runtime-react-native is MVP and experimental—expect the API to keep moving.
It has no runtime API and nothing to import in app code. The runtime side—storage, the Bare worklet, the update flow—is pear-mobile. Install both together:
npm install pear-runtime-react-native pear-mobile react-native-bare-kit
npx expo install expo-build-propertiesExpo setup
The plugin requires Expo and Prebuild—there's no autolinking path and no runtime patching, only generate-then-patch. Register it in app.json:
{
"expo": {
"plugins": [
"pear-runtime-react-native",
[
"expo-build-properties",
{ "android": { "minSdkVersion": 29 } }
]
],
"ios": { "bundleIdentifier": "com.example.app" },
"android": { "package": "com.example.app" }
}
}Then generate the native projects:
npx expo prebuildThe plugin edits two functions: bundleURL() in AppDelegate.swift, and the jsBundleFilePath passed into ExpoReactHostFactory.getDefaultReactHost() in MainApplication.kt. Re-run with --clean to regenerate already-patched files.
For a project without Expo, the same logic has to be applied by hand—see Plain React Native below.
Boot control
A release build loads the downloaded OTA update only if the version in the OTA's package.json is newer than the installed app version; otherwise it loads the bundle shipped in the binary. Debug builds always load from Metro—OTA behavior can only be verified in a release build.
| Platform | OTA folder | Compared against |
|---|---|---|
| iOS | <Application Support>/pear-runtime/ota | CFBundleShortVersionString |
| Android | <filesDir>/pear-runtime/ota | the package versionName |
Comparison is plain SemVer 2.0.0, no extra rules. Both sides must be valid SemVer—anything the spec rejects counts as not newer, so a bad version string just means the app keeps booting the binary's bundle rather than erroring.
An OTA may change JavaScript and bundled worker code only when that code stays compatible with the exact native modules, native ABI, configuration, and assets shipped in the installed store build.
Version synchronization
package.json's version is the single source of truth for four values that all have to move together: the OTA manifest version this plugin compares, Expo's version, CFBundleShortVersionString, and versionName. An app.config.js keeps the native side in sync, since Expo writes its version into both native projects during prebuild:
const app = require('./app.json')
const { version } = require('./package.json')
module.exports = { ...app.expo, version }Versions must be valid SemVer and strictly monotonic across native and OTA releases combined: every OTA version must be greater than the installed native version and every previously published OTA, and every new native release must be greater than every previously published OTA. Never reuse or decrease a published version.
Two independent gates enforce different halves of that rule, and they're worth keeping apart:
pear.jsonupdates.minver, checked bypear-mobilebefore a payload is downloaded and applied. It stops an OTA from reaching a native build too old for it—the forward direction.- Boot control, in this package, runs on every launch and compares only the installed OTA manifest version against the native version. It never reads
pear.json, sominverplays no part in choosing which bundle boots.
Runtime activation
Applying an update writes the new bundle and manifest into pear-runtime/ota, but doesn't switch the running app over—activation happens at the next native bundle selection, and the two platforms differ on when that is:
- iOS re-reads
bundleURL()on reload, so a JavaScript reload can pick up a freshly applied OTA. - Android captures
jsBundleFilePathwhen the React host is created and caches it, so a JavaScript reload generally reuses the old path—the update takes effect after a full process restart.
Treat a full restart as the requirement on both platforms.
What the plugin will and won't touch
The plugin only rewrites the two functions named above, wrapping what it writes in a marker comment (pear-runtime-react-native OTA v3 / ... end). A later prebuild reads that marker before touching anything:
| What it finds | What it does |
|---|---|
| No marker | Links the plugin's code. |
| This version's marker | Leaves it alone. |
| Another version's marker, present | Replaces the whole block. |
| Another version's marker, removed | Warns, changes nothing. |
Editing the generated code is fine as long as the marker comment stays—removing it hands ownership of that block to the project, and prebuild will only warn that a newer version wasn't linked (a clean --clean prebuild replaces it either way).
Adopting this plugin in a project that already has its own code in bundleURL() or getDefaultReactHost() overwrites it on the next prebuild—the plugin can't tell a hand-written implementation from Expo's generated one. An app should have exactly one owner of bundle selection; this conflicts with anything else that also claims it, including Expo Updates or another OTA system.
Plain React Native
The plugin only works with Expo—it runs as a config mod, and the Android patch looks for ExpoReactHostFactory. Without Expo, apply the same logic by hand:
- iOS: override
bundleURL()inAppDelegate. Return<Application Support>/pear-runtime/ota/app.bundleif it exists and itspackage.jsonversionis newer thanCFBundleShortVersionString, otherwise return the shippedmain.jsbundle. - Android: override
getJSBundleFile()on theReactNativeHost. Return<filesDir>/pear-runtime/ota/app.bundleunder the same condition, otherwisenull.
The Swift and Kotlin the plugin generates is available for reference in lib/ota-templates.js.
See also
- Pear Mobile OTA—the runtime half: the Bare worklet, storage, and update flow this plugin's boot control activates.
- Start from the hello-pear-react-native template—this plugin already wired into a working template's
app.json. pear-runtime-react-nativeon GitHub—full README, including deployment sequencing.