@otakit/capacitor-updater
v2.3.1
Published
Capacitor plugin for OTA updates
Maintainers
Readme
@otakit/capacitor-updater
Capacitor OTA updater plugin for OtaKit.
What it does
- fetches the latest manifest for its release lane from the CDN
- downloads and verifies OTA bundles
- stages updates safely
- applies staged bundles on cold start, resume, or manual command
- uses
notifyAppReady()as the health handshake - rolls back automatically if a newly applied bundle does not prove healthy
The plugin is intentionally small:
- three automatic lifecycle entry points: runtime, launch, resume
- one shared set of update primitives: check, download, apply
- one rollback safety loop
There is no built-in splash or overlay manager in this model.
Hosted config
plugins: {
OtaKit: {
appId: "YOUR_OTAKIT_APP_ID",
appReadyTimeout: 10000,
// Optional:
// channel: "staging",
// runtimeVersion: "2026.04",
// launchPolicy: "apply-staged",
// resumePolicy: "shadow",
// runtimePolicy: "immediate",
// checkInterval: 600000,
}
}Advanced overrides for self-hosting or custom trust only:
cdnUrlingestUrlserverUrlmanifestKeysallowInsecureUrls
Hosted OtaKit already points at the managed ingest service and CDN and already trusts the managed manifest signing keys.
Policies
The plugin has three automatic events:
runtimeCold start where the currentruntimeVersionlane has not been resolved yet.launchNormal cold start after runtime is already resolved.resumeApp returning from background.
Each event uses the same policy names:
type OtaKitPolicy = 'off' | 'shadow' | 'apply-staged' | 'immediate';Semantics:
shadowcheck + download, never apply on that eventapply-stagedapply a staged bundle if one already exists, otherwise behave likeshadowimmediatecheck + download + apply
Recommended defaults:
launchPolicy = 'apply-staged';
resumePolicy = 'shadow';
runtimePolicy = 'immediate';That means:
- fresh install or new
runtimeVersion: catch up immediately - later cold starts: apply already staged content if present, otherwise download the next update in the background
- resumes: periodically check and stage in the background
If an app wants full JS control:
launchPolicy = 'off';
resumePolicy = 'off';
runtimePolicy = 'off';Force-immediate releases
A release (or revert) can be marked force immediate in the dashboard or
with otakit release --force-immediate. The flag is baked into the signed
manifest; when a device's automatic flow sees it, shadow and apply-staged
events escalate to the immediate behavior for that release: download, apply,
and reload on that event.
Bounds to keep in mind:
- It is "immediate on the next event/check", not push — delivery is bounded by
lifecycle events and
checkInterval(static CDN, no server→device channel). offpolicies never fetch a manifest, so they never see the flag.offstays the device-owned kill switch, and the manual APIs are unchanged.- Trial and rollback still apply: a forced bundle that never calls
notifyAppReady()rolls back like any other. - It reloads the app under the user, possibly mid-task. Use it for broken releases, not routine rollouts.
Check interval
checkInterval defaults to 10 minutes and only applies to background resume
checks:
resumePolicy: "shadow"resumePolicy: "apply-staged"when there is no staged bundle to apply
Set checkInterval to 0 or a negative value to disable resume throttling.
It does not throttle:
- launch handling
- runtime handling
immediate- manual JS APIs
Runtime model
The plugin keeps three important pointers:
currentthe bundle the WebView is serving nowfallbackthe last known-good bundle used for rollbackstageda downloaded bundle waiting to be activated
Bundle lifecycle:
download -> pending -> trial -> success
|
+-> error -> rollbackIf a bundle is applied and never calls notifyAppReady():
- timeout triggers rollback while the app is running
- or the next cold start detects the still-trial bundle and rolls back before boot continues
The last failed applied bundle is persisted so the plugin does not immediately download and apply the same broken release again.
On top of this per-device rollback, a release published with the auto-revert flag is reverted fleet-wide by the server when too many devices report rollbacks within a 24-hour window, so remaining devices never download the broken bundle.
Automatic flow
For the normal hosted path, most apps only need:
await OtaKit.notifyAppReady();The plugin handles checking, staging, applying, rollback, and runtime-lane catch-up based on the configured policies.
Loading screen recommendation
OtaKit does not manage a splash screen or loading overlay for you.
Recommended startup order:
- keep a native splash screen or fullscreen loading view visible
- finish your normal app bootstrap
- call
notifyAppReady() - hide the splash screen or loading view
For React and Next.js apps, treat this as part of the default setup, not an optional polish step.
Manual APIs
The manual surface maps to the same internal engine:
const state = await OtaKit.getState();
const check = await OtaKit.check();
const download = await OtaKit.download();
await OtaKit.notifyAppReady();check() returns:
type CheckResult =
| { kind: 'no_update' }
| { kind: 'already_staged'; latest: LatestVersion }
| { kind: 'update_available'; latest: LatestVersion };download() returns:
type DownloadResult = { kind: 'no_update' } | { kind: 'staged'; bundle: BundleInfo };update() uses the same native immediate-flow operation as automatic
"immediate" policies:
await OtaKit.update();That keeps the manual convenience path atomic inside the native plugin instead
of splitting it into separate download() and apply() calls.
apply() and successful update() are terminal operations:
- on success they reload the WebView
- they do not resolve back into the old JS context
- call
notifyAppReady()from normal startup after the reloaded app boots
Events
The plugin emits lifecycle events alongside the pull APIs:
OtaKit.addListener('updateAvailable', (latest) => {}); // newer bundle found, before download
OtaKit.addListener('updateStaged', ({ bundle }) => {}); // downloaded + verified + staged
OtaKit.addListener('updateApplied', ({ bundle }) => {}); // new bundle confirmed healthy
OtaKit.addListener('downloadFailed', (failure) => {}); // non-terminal download/verify error
OtaKit.addListener('rollback', (failure) => {}); // applied bundle reverted (notify timeout)
OtaKit.removeAllListeners();Events fire only while the app process is alive — there is no buffering or replay. Reconcile on startup:
- a bundle staged in a previous session:
getState().staged - a startup rollback (app restarted before
notifyAppReady()): it happens before any JS runs, so it never reaches a listener — checkgetLastFailure()
apply() reloads the WebView and destroys the JS context, so updateApplied
fires in the reloaded bundle. Attach updateApplied/rollback listeners
early in app startup, not in the restart click handler.
Download and stage are atomic in this plugin — there is a single
updateStaged event, not separate "downloaded" and "staged" events.
The headline pattern — background download via shadow policies, prompt to
restart:
const state = await OtaKit.getState();
if (state.staged) showRestartPrompt(state.staged);
OtaKit.addListener('updateStaged', ({ bundle }) => showRestartPrompt(bundle));Example manual flow
const check = await OtaKit.check();
if (check.kind === 'update_available') {
const result = await OtaKit.download();
if (result.kind === 'staged') {
await OtaKit.apply();
}
}Or use the one-shot helper:
await OtaKit.update();After the app reloads and starts again, call:
await OtaKit.notifyAppReady();Runtime channel switching
setChannel() overrides the configured channel at runtime — for example a
"Join beta" toggle in settings — without rebuilding the app:
// Opt into the beta channel; takes effect on the next check/download cycle.
await OtaKit.setChannel({ channel: 'beta' });
// Back to the channel from capacitor.config.ts (or the base channel).
await OtaKit.setChannel({ channel: null });
const { channel, source } = await OtaKit.getChannel();
// source is 'override' after setChannel(), 'config' otherwiseThe override is persisted across launches and slots into channel resolution
as: explicit call argument → persisted override → config channel → base.
setChannel() itself never checks, downloads, or reloads anything — call
OtaKit.download() / OtaKit.update() afterwards if you want the switch to
take effect immediately.
Channel names must match ^[A-Za-z0-9._-]{1,64}$, must not contain .., and
must not be the reserved names base or default (matching server-side
validation). Invalid names reject without persisting.
Limitations to be aware of:
- Channels are public CDN paths. A channel name is not a secret and this cannot enforce private distribution — anyone who guesses the name can fetch that channel's manifest.
- The backend is not aware of the switch. There is no server-side record of which device is on which channel; the switch is purely client-side.
- The channel must exist and have a release. Switching to a channel with
no published manifest yields
no_updateuntil something is released there.
Compatibility lanes
channelanswers "who should get this rollout?"runtimeVersionanswers "which native app shell can safely run this bundle?"
Use channels for rollout tracks such as beta, staging, or production.
Use runtimeVersion when a new store build creates a new compatibility
boundary and you do not want devices on that native shell to keep receiving
older OTA bundles.
Trust model
The plugin does not just download arbitrary zips from a URL.
- it fetches the latest manifest for the current app + channel + runtime lane
- it verifies the manifest signature when keys are configured
- it compares the manifest with current, staged, and last-failed local state
- it downloads only when a newer usable bundle exists
- it verifies the downloaded object against the manifest
sha256 - if the bundle is encrypted, it decrypts it (AES-256-GCM; the tag authenticates the plaintext)
- it stages and later applies the bundle
Bundle encryption (optional)
Bundles can be end-to-end encrypted so that object storage and the CDN only
ever hold ciphertext. Generate a key with otakit generate-encryption-key,
put it in CI as OTAKIT_ENCRYPTION_KEY (the CLI then encrypts uploads), and
ship the same key in the app:
plugins: {
OtaKit: {
// Inject from an env var at build time — never commit the key.
bundleKeys: [{ kid: process.env.OTAKIT_ENCRYPTION_KID!, key: process.env.OTAKIT_ENCRYPTION_KEY! }],
},
},Per upload the CLI generates a random data key (DEK), encrypts the zip with AES-256-GCM, and wraps the DEK under your app key; the wrapped DEK and nonces travel in the signed manifest. The server never sees the key and cannot decrypt your bundles.
Threat model — confidentiality, not DRM. The decryption key ships inside the app binary, so a determined attacker who reverse-engineers the app can extract it — true of every client-side encryption scheme. What it protects against: leaked or guessed CDN URLs, bucket misconfiguration, and casual inspection of stored objects. Update forgery is independently blocked by the ES256 manifest signature — which is also why encryption should only be used with manifest signing enabled (hosted default): the encryption parameters are covered by the signature.
Operational rules:
- Rollout order: ship a store build containing
bundleKeysbefore releasing encrypted bundles. Installed apps without the key cannot decrypt and will stay on their current version (they keep running; the failure is recorded asdownload_errortelemetry and, if you listen for it, thedownloadFailedevent — it does not appear ingetLastFailure(), which reports rollbacks only). - Rotation:
bundleKeysis an array — ship old + new keys together during a transition, then drop the old key in a later store build. - Key custody: back the key up. Losing it means installed apps cannot receive updates until a store build ships a new key.
- Decryption failures are never fatal: they behave like download failures — the running bundle is untouched and nothing unverified is ever applied.
- Memory: decryption buffers the bundle in memory (roughly 2–3× the bundle size at peak). Keep encrypted bundles comfortably under ~100 MB, especially for low-end Android devices.
Source areas
src/definitions.ts: public types and configsrc/index.ts: Capacitor registration and JS wrappersrc/web.ts: web fallback implementationios/Sources/UpdaterPlugin/*: iOS implementationandroid/src/main/java/com/otakit/updater/*: Android implementation
