@deploy-your-app/capacitor-update-manager
v0.5.1
Published
Capacitor OTA live updates for iOS and Android — ship JS/HTML/CSS without an app store review. Signed bundles, update channels, staged rollouts, auto-rollback, and analytics, powered by DeployYourApp (https://deployyour.app).
Maintainers
Readme
@deploy-your-app/capacitor-update-manager
Over-the-air (OTA) live updates for Capacitor apps. Ship JavaScript, HTML, and CSS changes to installed iOS and Android apps without going through app store review — signed bundles, update channels, percentage rollouts, automatic rollback, and update analytics.
🌐 deployyour.app
Website · Documentation · Pricing · Compare OTA platforms · Changelog · Dashboard
What this is
This is the Capacitor client SDK for DeployYourApp, a hosted OTA update, distribution, and diagnostics platform for Capacitor and Electron apps. The plugin checks for updates, downloads and verifies signed bundles, activates them, and rolls back automatically when a bundle fails to start. The server side — apps, channels, rollouts, devices, and analytics — lives in your DeployYourApp dashboard, so the plugin needs an account (every plan starts with a 30-day trial; see pricing).
Use it when you want to:
- Push a JavaScript/CSS bugfix to users the same day, instead of waiting on an app store review cycle — "hot code push" / live update for Capacitor
- Run
stagingandproductionupdate channels off one binary - Roll a release out to a percentage of devices, then roll it back instantly if something breaks
- Distribute internal corporate apps and keep them current
- See what version each device is actually running
The rest of the platform:
| Package | What it is |
|---------|-----------|
| @deploy-your-app/cli | dya — sets this plugin up, then bundles, signs, and deploys your updates |
| @deploy-your-app/electron-update-manager | The same live-update pipeline for Electron desktop apps |
Installation
npm install @deploy-your-app/capacitor-update-manager
npx cap syncRequires Capacitor 6+. No extra native setup is needed — CocoaPods / Gradle pick everything up from npx cap sync (the iOS pod pulls in ZIPFoundation automatically).
Tip: if you use the DeployYourApp CLI,
dya setupinstalls and configures this plugin for you interactively — including generating the init module below and registering it in your app's entry point. It never overwrites a source file it did not write: if the filename it wants is already yours, it picks the next free one (dya-update,dya-ota, …), registers that instead, and keeps using that name on later runs. ThepublicKeyit writes intocapacitor.config.jsonis derived from the signing private keydya deployuses, and setup refuses to write anything if the two disagree — or if a different public key is already embedded here, which is what a fresh clone of a shipped project looks like. Replacing that one takes typingreplace the signing key; pressing Enter keeps it and stops setup.
Quick Start
1. Configure (capacitor.config.js)
export default {
appId: 'com.yourcompany.yourapp',
plugins: {
DeployYourApp: {
appId: 'your-app-id', // from the DeployYourApp dashboard
channel: 'production',
publicKey: '-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----',
},
},
};That is the whole configuration — the plugin talks to deployyour.app by default.
publicKey is required. Signature verification is mandatory and has no fail-open path: without a public key the plugin refuses every download with SIGNATURE_REQUIRED. Generate the pair with dya keys generate; dya setup writes the public half into your Capacitor config for you.
updateUrl / statsUrl only need setting to point the app at a different DeployYourApp API — a local server during development, say. They are base URLs (e.g. http://localhost:3000); the plugin appends /api/update and /api/stats itself. A non-default API also needs its bundle storage host listed in allowedDownloadHosts.
2. Confirm each successful launch
import { DeployYourApp } from '@deploy-your-app/capacitor-update-manager';
// Call once your app has finished booting. If this is not called within
// `appReadyTimeout` after an update, the plugin rolls back automatically.
await DeployYourApp.notifyAppReady();If your web sources cannot resolve that bare import — a Quasar/Capacitor project keeps the plugin in src-capacitor/node_modules, which the web bundler does not see — use Capacitor's own seam and get the identical API:
import { registerPlugin } from '@capacitor/core';
const DeployYourApp = registerPlugin('DeployYourApp');Nothing in this package's JavaScript is load-bearing: every guarantee below holds at the native bridge boundary, so both forms behave the same. (Before 0.5.0 that was not true — see Migrating to 0.5.0.)
That's the whole integration. With the default autoUpdate: true, the plugin checks for updates on launch (and every checkInterval seconds), downloads new bundles in the background, verifies them, and activates them on the next app launch.
Quasar
Quasar generates its own main.ts, so the call belongs in a boot file — the framework's designated place for startup side effects — rather than in App.vue.
// src/boot/dya.ts
import { defineBoot } from '#q-app/wrappers';
import { DeployYourApp } from '@deploy-your-app/capacitor-update-manager';
export default defineBoot(async () => {
try {
await DeployYourApp.notifyAppReady();
} catch (err) {
// A boot file that throws aborts Quasar's whole boot chain.
console.warn('[DeployYourApp] notifyAppReady() failed:', err);
}
});#q-app/wrappers and defineBoot are the current names, introduced in @quasar/app-vite v2 and @quasar/app-webpack v4. On older versions use the previous path instead — import { boot } from 'quasar/wrappers' and export default boot(...). Copy whichever form your own quasar.config file uses; dya setup detects it from your package.json (falling back to what the config imports) and generates the matching file.
Then register it in quasar.config.ts:
boot: ['dya'],A Quasar project keeps the Capacitor project in src-capacitor/, which has its own package.json. Install this plugin in both — the web root so the import above resolves, and src-capacitor/ so npx cap sync picks up the native code. dya setup writes the boot file, the boot: [] entry, and both installs for you. If you already have a src/boot/dya.ts of your own, setup leaves it untouched and registers the boot file it did write under another name.
Other frameworks
Anywhere else, a side-effecting module imported once from your entry file does the same job:
// src/dya.js
import { DeployYourApp } from '@deploy-your-app/capacitor-update-manager';
DeployYourApp.notifyAppReady();// src/main.js — one line, after your other imports
import './dya';Manual update flow (optional)
Set autoUpdate: false to drive updates yourself:
const update = await DeployYourApp.checkForUpdate();
if (update.available) {
const { id } = await DeployYourApp.download({
url: update.url,
version: update.version,
checksum: update.checksum,
signature: update.signature,
});
// Activates the bundle and reloads the webview immediately.
await DeployYourApp.apply({ id });
}Configuration
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| appId | string | required | Your app ID from DeployYourApp |
| updateUrl | string | https://api.deployyour.app | Update server base URL (no path — /api/update is appended) |
| statsUrl | string | https://api.deployyour.app | Analytics server base URL (no path — /api/stats is appended) |
| allowedDownloadHosts | string[] | ['storage.deployyour.app'] | Extra hosts allowed to serve bundle downloads. The updateUrl and statsUrl hosts are always allowed. Bundles are fetched over https only |
| channel | string | 'production' | Update channel (persisted when changed via setChannel()) |
| autoUpdate | boolean | true | Check + download automatically; activate per applyMode |
| applyMode | string | 'whenIdle' | When auto-updates activate: 'whenIdle' / 'onLaunch' (next launch), 'immediate' (reload now), 'background' (download only, you call apply()). The server can override per-update; mandatory updates always apply immediately. |
| appReadyTimeout | number | 10000 | Ms to wait for notifyAppReady() before auto-rollback |
| checkInterval | number | 600 | Seconds between automatic update checks (0 disables repeat checks) |
| publicKey | string | required | RSA public key (PEM) for bundle signature verification. Without it every download fails with SIGNATURE_REQUIRED |
| analyticsEnabled | boolean | true | Enable batched analytics |
| analyticsBatchSize | number | 20 | Events before auto-flush |
| analyticsFlushInterval | number | 30 | Seconds between auto-flush |
| autoDeleteFailed | boolean | true | Auto-delete failed bundles |
| autoDeletePrevious | boolean | true | Auto-delete old bundles after a successful update |
| resetWhenUpdate | boolean | true | Reset to the built-in bundle when the native app version changes (store update) |
| directUpdate | string | — | Deprecated. Legacy alias for applyMode |
API Reference
Update Lifecycle
| Method | Description |
|--------|-------------|
| checkForUpdate(options?) | Check server for available updates ({ channel? }) |
| download(options) | Download, verify, policy-check, and store a bundle |
| apply(options) | Activate a downloaded bundle and reload the webview ({ id }) |
| notifyAppReady(options?) | Confirm the bundle loaded — prevents rollback. Rejects READY_TOKEN_MISMATCH if the calling document is not the bundle on trial |
| reset() | Delete all downloaded bundles and revert to the built-in bundle |
| reload() | Force reload the webview |
Bundle Management
| Method | Description |
|--------|-------------|
| getCurrentBundle() | Get active bundle info ({ id: 'builtin', ... } when none) |
| getNextBundle() | Get the bundle staged for next launch: { bundle: BundleInfo \| null } |
| listBundles() | List all downloaded bundles |
| deleteBundle({ id }) | Remove a stored bundle |
Channel Management
| Method | Description |
|--------|-------------|
| getChannel() | Get current update channel |
| setChannel({ channel }) | Switch update channel (persisted; used by the next check) |
Device Identity
| Method | Description |
|--------|-------------|
| getDeviceId() | Get stable device UUID |
| setCustomId({ customId }) | Set a custom device identifier (e.g. employee ID) |
Analytics
| Method | Description |
|--------|-------------|
| trackEvent({ name, properties? }) | Track a custom event |
| trackPageView({ path, title? }) | Track a page view |
| trackError({ message, stack?, fatal? }) | Track an error |
| flushAnalytics() | Flush buffered events immediately |
eventData is stored verbatim — see Data collected by this SDK before putting anything user-derived in it.
Version Info
| Method | Description |
|--------|-------------|
| getNativeVersion() | Get the native app version |
| getPluginVersion() | Get the plugin version |
| setVersionOverride({ version }) | Report a fake version to the update server (testing). Empty string clears it |
| getVersionOverride() | Get the active version override ('' when unset) |
Events
import { DeployYourApp, DYA_EVENTS } from '@deploy-your-app/capacitor-update-manager';
DeployYourApp.addListener(DYA_EVENTS.DOWNLOAD_PROGRESS, (data) => {
console.log(`Download: ${data.percent}%`);
});
DeployYourApp.addListener(DYA_EVENTS.UPDATE_AVAILABLE, (data) => {
console.log(`Update ${data.version} available`);
});| Event | Data | Description |
|-------|------|-------------|
| downloadProgress | { percent, bytesDownloaded, totalBytes } | Download progress (throttled to whole-percent changes) |
| updateAvailable | { version, message? } | A check found a new update |
| noUpdateAvailable | — | A check found nothing new |
| downloadComplete | { id, version } | Download finished (manual or auto) |
| downloadFailed | { message } | Download or auto-update error |
| updateApplied | { id, version } | Bundle activated |
| updateFailed | { message } | apply() failed |
| rollback | { from, to, reason, attempts? } | Auto-rollback triggered (reason: 'appReadyTimeout' or 'crashLoop'; attempts is the launch count for 'crashLoop') |
| appReady | — | notifyAppReady() confirmed |
| appReadyRejected | { bundleId, servedBundleId, message } | notifyAppReady() was refused because the calling document could not be shown to be running the bundle on trial; the call also rejects with READY_TOKEN_MISMATCH |
| appVersionChange | { previousVersion, currentVersion } | Native app version changed; reset to built-in (resetWhenUpdate) |
The events that can legitimately land during launch, before your listener code has run, are retained by Capacitor and delivered to the first listener that attaches: updateAvailable, downloadComplete, downloadFailed, updateApplied, updateFailed, rollback, appReadyRejected, and appVersionChange. Both platforms retain exactly these.
The remaining three — downloadProgress, noUpdateAvailable, and appReady — are delivered live and lost if nothing is listening at that moment. Register your listeners before calling checkForUpdate() (or before notifyAppReady(), for appReady) if you depend on them.
Error Codes
Rejected calls carry a stable code you can branch on:
MISSING_PARAMS, NETWORK_ERROR, PARSE_ERROR, DOWNLOAD_FAILED, CHECKSUM_MISMATCH, SIGNATURE_REQUIRED, SIGNATURE_INVALID, BUNDLE_NOT_FOUND, INVALID_URL, EXTRACTION_FAILED, ASSET_TYPE_REJECTED, MANIFEST_MISSING, MANIFEST_MISMATCH, READY_TOKEN_MISMATCH, STORAGE_ERROR, UNKNOWN.
| Code | Meaning |
|------|---------|
| SIGNATURE_REQUIRED | publicKey is not configured, or the server sent no signature. There is no unsigned install path |
| ASSET_TYPE_REJECTED | A bundle entry is not a web asset — its extension is outside the allowlist, or its leading bytes are a native executable's |
| MANIFEST_MISSING | The bundle carries no readable .dya-manifest.json, or one whose manifestVersion is not 1. Re-deploy with a current dya CLI |
| MANIFEST_MISMATCH | The extracted files and the manifest disagree: a wrong hash, a file with no manifest entry, a manifest entry with no file, or a files map that is absent or malformed in a manifest that otherwise parsed |
try {
await DeployYourApp.download(update);
} catch (err) {
if (err.code === 'CHECKSUM_MISMATCH') {
// corrupted download — retry
}
}Update Flow
- Check —
POST {updateUrl}/api/updatewith app ID, device ID, platform, versions - Download — Fetch the bundle ZIP with progress events
- Verify — SHA-256 checksum + RSA signature validation over the raw ZIP, before the archive is opened. Both are mandatory
- Extract under policy — Unzip into the app's bundle storage (Zip-Slip protected, size-capped), rejecting any file that is not a web asset
- Manifest check — Every extracted file must match
.dya-manifest.json, in both directions - Apply — Point the Capacitor webview at the new bundle and reload (immediately via
apply()/applyMode: 'immediate', or on the next launch otherwise) - Ready check —
notifyAppReady()must be called withinappReadyTimeout - Rollback — Auto-reverts to the previous bundle (or built-in) if the ready check never arrives
A bundle that crashes the app before appReadyTimeout can elapse would never
trip the timer at all. A persisted launch counter covers that case: after 3
consecutive launches without notifyAppReady(), the bundle is rolled back and a
rollback event with reason: 'crashLoop' is emitted.
Only one download runs at a time, and the currently active bundle can never be
re-downloaded over itself. Version strings are validated against
^[0-9A-Za-z.\-+]{1,64}$ before being used as directory names, and each bundle
is extracted to a staging directory that is swapped into place only after it is
verified to contain a servable index.html.
No hidden or debug surface
This plugin installs no gesture recognizers and no touch listeners, and it exposes no hidden or undocumented entry point. Every capability it has is a documented method or event in the tables above.
If your app wants a debug affordance — a hidden gesture to reach a diagnostics screen, for example — implement it in your own app code, where you control whether it ships and can document it for App Review. It does not belong in an update plugin that every install embeds. See App Store review.
Data collected by this SDK
You are the data controller for your end users. This SDK sends data to DeployYourApp on your behalf, and you must disclose it in your own privacy policy. Full detail, including app store questionnaire guidance, is at https://deployyour.app/privacy.
Sent on every update check (POST /api/update) and stored
| Field | Notes |
|-------|-------|
| deviceId | Random UUID v4 generated by the plugin on first run and stored locally. Not an IDFA, GAID, ANDROID_ID, MAC address, or hardware serial, and not derived from one. |
| platform | ios or android |
| nativeVersion | Version of the installed app binary |
| pluginVersion | This plugin's version |
| first seen / last seen | Set server-side on each check-in |
| channel subscriptions | Which update channels this device follows |
Transmitted but discarded by the server. The Android implementation also sends
osVersion, the device manufacturer and model, customId, currentBundleId, and
nativeBuild. The server validates against a strict schema that excludes these, so they are
stripped and never stored. Do not rely on them being available in the dashboard.
Never collected: geolocation, advertising IDs, contacts, photos, phone number, IMEI, installed-app lists, or your app's own data.
Analytics (POST /api/stats) — on by default
analyticsEnabled defaults to true. Events are buffered and flushed every 20 events or 30
seconds, and each stores eventType, eventData, bundleVersion, timestamp, and the
device ID.
The plugin emits its own events without you calling anything. While analyticsEnabled is
true, both platforms send these as the update lifecycle runs:
update_check, update_available, update_download_start, update_download_complete,
update_download_fail, update_verify_pass, update_verify_fail, update_apply,
update_app_ready, update_rollback
These carry update metadata only — bundle version, size, duration, error code — never end-user
data. Your own calls to trackEvent(), trackPageView(), and trackError() are sent on the
same channel. eventData is arbitrary JSON stored verbatim: whatever your app puts in it, we
store. A stack trace passed to trackError() containing a user's email means that email is
stored. Audit your call sites.
Turning it off
Disabling analytics stops analytics events and /api/stats requests. It does not stop update
checks: every /api/update request still includes the persistent deviceId so the service can
select and deliver updates for that installation.
// capacitor.config.js
export default {
plugins: {
DeployYourApp: {
appId: 'your-app-id',
analyticsEnabled: false, // no events sent; update checks still work
},
},
};To stop contacting our servers entirely, also set autoUpdate: false and checkInterval: 0
and never call checkForUpdate().
On-device storage
The plugin persists the device ID, selected channel, custom ID, and version override in
UserDefaults (iOS) / SharedPreferences (Android), and under the dya_* localStorage
keys on web. In the EU/UK, storing an identifier on a user's device engages ePrivacy consent
rules; update delivery is a plausible strict-necessity case, analytics generally is not.
What you need to disclose
Name DeployYourApp as a processor; describe the device identifier and the technical version fields; describe your own analytics events if you leave analytics on; state your legal basis and retention. The iOS SDK privacy manifest declares linked, non-tracking Device ID for App Functionality and Analytics, plus linked, non-tracking Product Interaction, Performance Data, and Other Diagnostic Data for Analytics. The latter categories cover default lifecycle durations, byte counts, and failure reasons. Those declarations describe this SDK's collection; they do not replace your host app's privacy manifest or App Store Connect answers. Keep those answers consistent with the SDK configuration and every other data flow in your shipped app. Retention, deletion, and export are covered in the linked doc; note that per-device deletion is not self-serve yet and goes through [email protected].
Security
- Bundles are downloaded over https only, from an allow-listed host — the
updateUrl/statsUrlhosts plusallowedDownloadHosts. Plain http is accepted only fromlocalhost, for local development. Rejections carrycode: 'INVALID_URL'. - Bundle integrity verified via SHA-256 checksum
- RSA signature verification is mandatory and ensures bundles came from your build pipeline (the CLI generates RSA-4096 keys; both platforms derive the modulus size from the key itself, so any RSA key size is accepted). There is no fail-open path: no
publicKey, no update - Bundles are not encrypted. They are plain, signed ZIPs, so anyone holding a bundle URL — App Review included — can see exactly what a bundle contains. See Bundle asset policy
- ZIP extraction is Zip-Slip protected with per-file (100 MB) and total (500 MB) size caps; symlink entries are skipped on iOS and neutralised on Android (see Bundle asset policy)
- Signing private keys never leave developer machines
Bundle asset policy
The native extractor on both platforms limits published bundles to allowlisted web-asset paths and rejects known executable headers before publication. See App Store review.
1. Extension allowlist. Every file entry must end in one of:
html htm css js mjs cjs json map webmanifest
svg png jpg jpeg gif webp avif ico bmp
woff woff2 ttf otf eot
mp3 mp4 webm ogg wav m4a
txt xml csv md
wasmOnly the final extension counts, so bundle.js.map is allowed and
evil.js.dylib is not. A file with no extension is rejected. Directory entries
are exempt — they have no extension, and requiring one would reject every
nested folder.
Symlink entries differ by platform. On iOS they are skipped and never
written. On Android they cannot be identified: a ZIP records symlink-ness in
the central directory's external attributes, and java.util.zip.ZipEntry
exposes no accessor for it. Such an entry is therefore extracted as an ordinary
file whose content is the link target path — never as a link, so it cannot
redirect a later write out of the bundle directory. It must then pass the
extension allowlist and the manifest hash check like any other file, and a
manifest generated from a real build tree does not list it, so it is rejected
rather than installed.
2. Executable magic-byte rejection. The first four bytes of every extracted
file are checked regardless of its name, so a native binary renamed to app.js
is still refused:
| Leading bytes | Format |
|---------------|--------|
| FE ED FA CE | Mach-O |
| FE ED FA CF | Mach-O |
| CE FA ED FE | Mach-O |
| CF FA ED FE | Mach-O |
| CA FE BA BE | Mach-O fat binary or Java class |
| BE BA FE CA | Mach-O fat binary |
| CA FE BA BF | 64-bit Mach-O fat binary |
| BF BA FE CA | 64-bit Mach-O fat binary |
| 7F 45 4C 46 | ELF |
| 64 65 78 0A | Android DEX |
| 4D 5A | PE executable |
3. Per-file manifest. Every bundle carries a .dya-manifest.json at its
root, written by dya deploy:
{
"manifestVersion": 1,
"bundleVersion": "1.4.0",
"createdAt": "2026-08-04T00:00:00.000Z",
"files": { "index.html": "<sha256 hex>", "assets/app.js": "<sha256 hex>" }
}The check is bidirectional: every manifest entry must exist on disk with the
declared hash, and every extracted file must appear in the manifest. A
one-way check would let an attacker add a file or remove one. The manifest
itself is the only file exempt from appearing in files — it cannot list its
own hash.
The CLI excludes a source .dya-manifest.json and source symlinks from both
hashing and archiving, then writes exactly one generated manifest after the
directory walk. The ZIP's non-directory file entries are therefore exactly
the manifest files set plus that generated manifest; the upload service
rejects archives with anything other than one root manifest.
manifestVersion is validated: anything other than 1 is rejected with
MANIFEST_MISSING, naming the version found and the version expected. A
manifest the app cannot interpret is treated as one it does not have.
A bundle produced by an older CLI has no manifest and is rejected with
MANIFEST_MISSING. There is no fallback path; re-deploy with a current dya.
These byte and filename checks are limited technical evidence about which files can be published. They cannot guarantee App Store compliance or determine whether web code changes reviewed functionality. Apply the operational release boundary in the App Review guidance, and keep store metadata, review notes, and privacy answers accurate for every available release.
Native release validation
The repository's Native release validation workflow runs the Android JUnit
and iOS XCTest suites against exact Capacitor 6, 7, and 8 releases. Each matrix entry packs
this npm package, installs that tarball into a clean Capacitor consumer, syncs
the native platform, and compiles a representative app. The iOS gate also
checks the built App.app for PrivacyInfo.xcprivacy.
Run the same smoke gates on a machine with the corresponding native toolchain:
pnpm test:native:android -- 6.2.1
pnpm test:native:ios -- 6.2.1The scripts reject versions outside the supported Capacitor 6-8 range.
Platform Support
- iOS — Swift implementation (iOS 13+, ZIPFoundation for extraction)
- Android — Kotlin implementation
- Web — Partial: analytics, channel/device identity, and version override work (backed by
localStorage); update download/apply are unavailable andcheckForUpdate()always reports no update
For Windows, macOS, and Linux desktop apps, use the sibling package
@deploy-your-app/electron-update-manager
— same signed-bundle pipeline, same dashboard, same dya deploy.
Links
| | | |---|---| | 🌐 Website | https://deployyour.app | | 📚 Documentation | https://deployyour.app/docs — plugin setup, configuration, update lifecycle, App Store review | | 💰 Pricing | https://deployyour.app/pricing — 30-day trial on every plan | | 🔍 Compare | https://deployyour.app/compare | | 📊 Dashboard | https://app.deployyour.app | | 📝 Changelog | https://deployyour.app/changelog | | ✉️ Support | [email protected] |
Built and maintained by DeployYourApp. MIT licensed.
