@otakit/capacitor-updater
v3.0.0
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.
Reliability update compatibility
These native reliability changes require rebuilding and distributing the iOS/Android app. An OTA web update cannot replace the native plugin.
- Android WebView: applying an update requires support for
WebViewFeature.DOCUMENT_START_SCRIPT. Update Android System WebView on devices without that capability. A confirmed installed app can still launch, and a new update stays staged, but it cannot activate without the readiness guard. Include older supported WebView versions in your native rollout tests. - Readiness: keep calling
OtaKit.notifyAppReady()with no arguments after startup succeeds. Native code binds the call to its WebView document. The defaultappReadyTimeoutremains 10 seconds and now counts cumulative foreground time; backgrounding pauses the remaining budget. Process exit before readiness still triggers rollback on the next launch. - Encrypted bundles: the downloaded encrypted object must fit within 128 MiB
and a smaller device-dependent memory budget. Oversized objects fail before
decryption with
insufficient_memory_for_encrypted_bundle, preserving the current installation. Previously attempted large encrypted bundles may now be rejected; reduce their assets before rollout. - Signing configuration: explicit
manifestKeysvalues must be arrays. A string, object, number, boolean, or null fails closed. Omitted keys or an empty array retain the hosted defaults or existing self-hosted unsigned mode. - Self-hosted telemetry: deploy nullable event-context columns, then the
updated ingest Worker and Tinybird endpoints, before releasing the native SDK.
Old ingest Workers discard context and reject the new
check_erroraction.
Use a native canary rollout to check startup, rollback, background/resume behavior, and encrypted downloads on physical low-memory devices before widening adoption.
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();JavaScript listener events fire only while the app process is alive and have 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.
Server telemetry uses a separate durable retry queue with stable event IDs. It is limited to 256 events, seven days, and 8 KiB per event; storage failures and process termination between a state commit and event creation can still lose telemetry. Suspended/terminated apps resume delivery when their process runs again.
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: authenticated decryption still uses whole-object buffers. Admission is limited to the smaller of 128 MiB and one quarter of currently available process memory after reserving 32 MiB. This is a conservative estimate, not a reservation or an OOM guarantee. Test representative physical devices and keep encrypted assets small.
- iOS Simulator: admission uses host physical RAM as an estimate because the process-memory API can report zero in simulator apps. The same reserve, factor, and 128 MiB ceiling apply. Simulator decryption does not validate physical-device memory pressure or establish that the host has that much memory free.
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
