@deploy-your-app/capacitor-update-manager
v0.2.1
Published
Live update and analytics plugin for CapacitorJS apps — part of the DeployYourApp platform
Maintainers
Readme
@deploy-your-app/capacitor-update-manager
Capacitor native plugin for live OTA updates, analytics, and corporate app shell features, powered by DeployYourApp.
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.
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();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 + decrypts 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 () => {
await DeployYourApp.notifyAppReady();
});#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,
sessionKey: update.sessionKey,
});
// 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 | — | RSA public key (PEM) for bundle signature verification |
| encryptionPrivateKey | string | — | RSA private key (PEM) for E2E bundle decryption, embedded in the app binary |
| 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) |
| enableTestGesture | boolean | false | Enable three-finger tap gesture (for corporate shell) |
| 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, decrypt, and store a bundle |
| apply(options) | Activate a downloaded bundle and reload the webview ({ id }) |
| notifyAppReady() | Confirm the bundle loaded — prevents rollback |
| 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, or 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 |
| appVersionChange | { previousVersion, currentVersion } | Native app version changed; reset to built-in (resetWhenUpdate) |
| testGestureTrigger | { timestamp } | Three-finger tap detected |
Only two events are retained by Capacitor and delivered to a listener that attaches after they fire: updateAvailable and rollback — the two that can legitimately land during launch, before your listener code has run. Both platforms retain exactly these two.
Every other event in the table above is delivered live and is 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_INVALID, BUNDLE_NOT_FOUND, INVALID_URL, EXTRACTION_FAILED, DECRYPTION_FAILED, STORAGE_ERROR, UNKNOWN.
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 (when
publicKeyis configured) - Decrypt — AES-256-GCM session key unwrapped with the RSA private key from
encryptionPrivateKey(E2E mode only) - Extract — Unzip into the app's bundle storage (Zip-Slip protected, size-capped)
- 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.
Corporate App Shell
When enableTestGesture is enabled, a three-finger tap triggers the testGestureTrigger event. This is used by the DeployYourApp corporate app shell to return from a full-screen corporate app back to the main shell.
- iOS:
UITapGestureRecognizerwithnumberOfTouchesRequired = 3 - Android:
OnTouchListenerdetecting 3 pointers within 500 ms
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
// 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. For store privacy labels the baseline is Device or Other ID, collected and linked to the installation, used for App Functionality — not tracking. 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 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)
- Optional AES-256-GCM end-to-end encryption ("DYA1" format) — the decryption key ships only inside your compiled app
- ZIP extraction is Zip-Slip protected with per-file (100 MB) and total (500 MB) size caps; symlinks are skipped
- Signing/encryption private keys never leave developer machines
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
