@svssdeva/cap-silent-update
v0.1.0
Published
Self-hosted silent live updates for Capacitor apps. Zero cloud, SHA-256 integrity, trial + rollback. Android only today; iOS planned.
Downloads
78
Readme
@svssdeva/cap-silent-update
Self-hosted silent live updates for Capacitor apps. No accounts, no cloud vendor, no SDKs phoning home — you host the bundle on your own S3/CDN and the plugin installs it on the next cold boot. Integrity via SHA-256, trial + automatic rollback on crash.
Status
| Platform | Status |
| -------- | -------------------------------------------- |
| Android | Working. Used in production. |
| iOS | Plugin surface present, methods unimplemented. Planned. |
| Web | Unimplemented — gate calls behind Capacitor.isNativePlatform(). |
Why another update plugin?
| | This plugin | Capgo Live Updates | Capawesome Live Updates |
| --- | --- | --- | --- |
| Self-hosted | Yes | Optional (default: Capgo cloud) | Cloud only |
| Account required | None | Capgo | Capawesome |
| Runtime deps | Zero | @capgo/capacitor-updater | @capawesome/capacitor-live-update |
| Code size | ~15 kB minified | Larger | Larger |
| Signing | Scaffolded (ed25519 planned) | Bring-your-own | Cloud-managed |
| License | MIT | MIT | Commercial |
This plugin is deliberately small. You bring the hosting, the manifest format is one JSON object, and the only native dependency is Capacitor itself.
Install
npm install @svssdeva/cap-silent-update
npx cap syncAndroid setup
Call SilentUpdatePlugin.prepareBoot(this) from your MainActivity.onCreate before super.onCreate(...). This runs the cold-start state machine: promote a staged bundle to trial, or roll back a failed trial to factory.
package com.example.myapp;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
import com.svssdeva.silentupdate.SilentUpdatePlugin;
public class MainActivity extends BridgeActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
SilentUpdatePlugin.prepareBoot(this);
super.onCreate(savedInstanceState);
}
}Capacitor auto-discovers the plugin via Gradle — no manual registerPlugin(...) call needed.
iOS setup
Installing the pod keeps the JS surface stable. Every method currently rejects with unimplemented. Gate iOS calls defensively:
import { Capacitor } from '@capacitor/core';
import { SilentUpdate } from '@svssdeva/cap-silent-update';
if (Capacitor.getPlatform() === 'android') {
await SilentUpdate.checkManifest({ url: '...' });
}Usage
import { SilentUpdate } from '@svssdeva/cap-silent-update';
// On every successful boot — MUST be called to confirm the trial bundle.
await SilentUpdate.notifyReady();
// Check for an update (cheap: single HTTP GET of your manifest).
const manifest = await SilentUpdate.checkManifest({
url: 'https://cdn.example.com/ota/manifest.json',
});
const state = await SilentUpdate.getState();
if (manifest.version === state.currentVersion) return;
// Download + verify + stage. Takes effect on next cold start.
await SilentUpdate.downloadUpdate({
url: manifest.url,
version: manifest.version,
checksum: manifest.checksum,
signature: manifest.signature,
});
// If your manifest says force:true, apply without waiting for a restart.
if (manifest.force) {
await SilentUpdate.applyNow();
}Progress events
The plugin emits a single updateProgress event with a stage discriminator. Use this to drive a progress bar on force-apply or user-initiated checks.
import type { UpdateProgressEvent } from '@svssdeva/cap-silent-update';
const handle = await SilentUpdate.addListener('updateProgress', (ev: UpdateProgressEvent) => {
switch (ev.stage) {
case 'download':
console.log(`${ev.percent}% (${ev.bytesWritten}/${ev.totalBytes})`);
break;
case 'verify':
console.log('Verifying checksum…');
break;
case 'unzip':
console.log('Extracting…');
break;
case 'ready':
console.log(`Bundle ${ev.version} staged.`);
break;
}
});
// Remember to detach.
await handle.remove();Progress events are throttled to at most one every 200 ms (or every 1% change) for the download stage. verify, unzip, and ready are emitted once each.
Manifest schema
The server-side manifest is one JSON object. The plugin only reads these fields:
{
"version": "1.2.0",
"url": "https://cdn.example.com/ota/bundles/1.2.0.zip",
"checksum": "a1b2c3…",
"force": false,
"min_native_version": "1.1",
"signature": "optional-ed25519-hex-reserved-for-future-use"
}| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| version | string | yes | Opaque to the plugin. Used to name the on-disk bundle dir. |
| url | string | yes | Absolute URL to the bundle zip. |
| checksum | string | yes | Hex-encoded SHA-256 of the zip bytes. |
| force | boolean | no | When true, consumer should call applyNow() after download. |
| min_native_version | string | no | Semver-ish. Consumer decides gating — plugin only passes it through. |
| signature | string | no | Reserved; plugin currently ignores. |
Bundle layout
The zip must expand to a Capacitor-compatible webroot: an index.html at the zip root plus whatever assets it references. That's usually the output of your web build tool (dist/, build/, www/, etc.).
Example upload script
A reference script used by the plugin author is at examples/ota-push.sh in the repo. It builds, zips, uploads to S3, and updates manifest.json. Plugin itself has no dependency on any specific storage — any public HTTPS endpoint works.
Security model
Integrity is enforced. Authenticity is not — yet.
- The
checksumfield is a SHA-256 of the zip bytes, verified before extraction. This protects against accidental corruption (truncated download, CDN byte-flip) and against installing the wrong bundle. - SHA-256 does not protect against a malicious actor who can write to your manifest URL. Such an attacker can publish a bundle with a matching checksum and the plugin will install it.
- Authenticity today is delegated to your bucket/origin access controls — only principals you authorize can update
manifest.json, and TLS protects the manifest in transit. - The JS surface and manifest schema already carry an optional
signaturefield (ed25519, hex-encoded over the bundle bytes). The plugin accepts and currently ignores it. A future minor version will verify against a pinned public key before extraction. Ship the signature field in your manifest today — older clients will ignore it, newer clients will verify it, and the rollout is a server-side change. - Zip-slip protection is in place: the extractor rejects entries whose canonical path escapes the bundle directory.
Trial + rollback contract
A bundle goes through three states: DOWNLOADED → TRIAL → CURRENT (success) or TRIAL → FACTORY (failure).
- After
downloadUpdate, the bundle is staged. It is not live until the next cold start. - On the next cold start,
prepareBootpromotes it to trial and clears theconfirmedflag. - The trial bundle's JS must call
notifyReady()within that boot. - If the process dies or the app is killed before
notifyReady, the nextprepareBootdetects an unconfirmed boot and rolls back to factory, clearing the bundle directory.
Calls to applyNow() short-circuit step 2 — they load the bundle in the running WebView and begin the trial immediately, without a restart.
Explicit rollback() discards any state and reverts to factory immediately.
Upgrade path from a custom plugin
If you're migrating from an in-tree plugin that stored state under a different SharedPreferences namespace, the plugin performs a one-time migration from the legacy "ota_prefs" namespace (ota_current_version, ota_pending_version, ota_pending_path, ota_confirmed, ota_last_check_ts) into the new "silentupdate_prefs" namespace on the first prepareBoot after upgrade. Legacy keys are cleared after the copy.
This preserves an in-flight trial or staged bundle across the plugin swap. If your custom plugin used a different namespace, open an issue and a migration for your shape can be added.
API
getState()setLastCheckTs(...)checkManifest(...)downloadUpdate(...)applyNow()notifyReady()rollback()addListener('updateProgress', ...)- Interfaces
- Type Aliases
getState()
getState() => Promise<UpdateState>Snapshot the persisted OTA state. See {@link UpdateState}.
Returns: Promise<UpdateState>
setLastCheckTs(...)
setLastCheckTs(options: { ts: number; }) => Promise<void>Override the persisted lastCheckTs. Useful after a successful
user-initiated check to throttle the next background one.
| Param | Type |
| ------------- | ---------------------------- |
| options | { ts: number; } |
checkManifest(...)
checkManifest(options: { url: string; }) => Promise<UpdateManifest>Fetch and parse the manifest at url.
| Param | Type |
| ------------- | ----------------------------- |
| options | { url: string; } |
Returns: Promise<UpdateManifest>
downloadUpdate(...)
downloadUpdate(options: DownloadUpdateOptions) => Promise<DownloadUpdateResult>Download the bundle zip, verify SHA-256, extract to the app's private
storage, and stage it. The stage becomes the live bundle on the next
cold start (see prepareBoot in the Android plugin).
| Param | Type |
| ------------- | ----------------------------------------------------------------------- |
| options | DownloadUpdateOptions |
Returns: Promise<DownloadUpdateResult>
applyNow()
applyNow() => Promise<void>Promote a staged bundle immediately in the running WebView. Used for
force: true manifests where a restart would be too disruptive.
Begins the trial window — JS must call notifyReady before the
next cold start to avoid rollback.
notifyReady()
notifyReady() => Promise<void>Mark the current bundle as stable. Call on every successful boot. Failure to call before the next cold start is interpreted as a crash and triggers a rollback to factory.
rollback()
rollback() => Promise<void>Discard any staged bundle + revert to the factory serverBasePath.
Recreates the activity for immediate visual feedback on Android.
addListener('updateProgress', ...)
addListener(eventName: 'updateProgress', listenerFunc: (event: UpdateProgressEvent) => void) => Promise<PluginListenerHandle>| Param | Type |
| ------------------ | --------------------------------------------------------------------------------------- |
| eventName | 'updateProgress' |
| listenerFunc | (event: UpdateProgressEvent) => void |
Returns: Promise<PluginListenerHandle>
Interfaces
UpdateState
Snapshot of the plugin's persisted state. Read this on every check to decide whether to hit the manifest (throttling) or apply a pending bundle.
| Prop | Type | Description |
| -------------------- | -------------------- | --------------------------------------------------------------------- |
| currentVersion | string | Active bundle version, or the string "factory" when no OTA is live. |
| pendingVersion | string | Staged-but-not-yet-promoted bundle, or empty string when none. |
| lastCheckTs | number | Epoch ms of the most recent checkManifest call. 0 if never. |
| confirmed | boolean | False during a trial boot (between applyNow and notifyReady). |
UpdateManifest
Result of checkManifest. Mirrors the server's manifest.json plus
the required gate fields.
signature is reserved for a future ed25519 signing rollout. The
plugin currently accepts and ignores it — servers can ship it ahead
of verification landing without breaking older clients.
| Prop | Type | Description |
| ------------------------ | -------------------- | ---------------------------------------------------- |
| version | string | |
| url | string | |
| checksum | string | |
| force | boolean | |
| min_native_version | string | Semver-like string. Consumer decides how to compare. |
| signature | string | |
DownloadUpdateResult
| Prop | Type |
| ------------- | -------------------- |
| success | boolean |
| version | string |
DownloadUpdateOptions
| Prop | Type | Description |
| --------------- | ------------------- | ------------------------------------------------------------------------------------------------- |
| url | string | |
| version | string | |
| checksum | string | |
| signature | string | See {@link UpdateManifest.signature}. Forward as-is; plugin no-ops. |
PluginListenerHandle
| Prop | Type |
| ------------ | ----------------------------------------- |
| remove | () => Promise<void> |
UpdateProgressEvent
| Prop | Type |
| ------------------ | --------------------------------------------------- |
| stage | UpdateStage |
| percent | number |
| bytesWritten | number |
| totalBytes | number |
| version | string |
Type Aliases
UpdateStage
Stages emitted via the updateProgress event:
download— bytes are streaming;percent,bytesWritten,totalBytespresentverify— SHA-256 check in progressunzip— extracting the bundleready— staged +serverBasePathcommitted;versionpresent
'download' | 'verify' | 'unzip' | 'ready'
License
MIT. See LICENSE.
