https://github.com/svssdeva/cap-silent-update
Self-hosted silent live updates for Capacitor apps. Zero cloud, SHA-256 integrity, trial + rollback. Android implemented, iOS planned.
https://github.com/svssdeva/cap-silent-update
Last synced: 21 days ago
JSON representation
Self-hosted silent live updates for Capacitor apps. Zero cloud, SHA-256 integrity, trial + rollback. Android implemented, iOS planned.
- Host: GitHub
- URL: https://github.com/svssdeva/cap-silent-update
- Owner: svssdeva
- License: mit
- Created: 2026-04-22T09:49:31.000Z (2 months ago)
- Default Branch: main
- Last Pushed: 2026-04-22T11:07:47.000Z (2 months ago)
- Last Synced: 2026-05-23T15:35:30.869Z (about 1 month ago)
- Language: Java
- Size: 415 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Contributing: CONTRIBUTING.md
- License: LICENSE
Awesome Lists containing this project
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
```bash
npm install @svssdeva/cap-silent-update
npx cap sync
```
### Android 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.
```java
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:
```ts
import { Capacitor } from '@capacitor/core';
import { SilentUpdate } from '@svssdeva/cap-silent-update';
if (Capacitor.getPlatform() === 'android') {
await SilentUpdate.checkManifest({ url: '...' });
}
```
## Usage
```ts
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.
```ts
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:
```json
{
"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 `checksum` field 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 `signature` field (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).
1. After `downloadUpdate`, the bundle is **staged**. It is not live until the next cold start.
2. On the next cold start, `prepareBoot` promotes it to **trial** and clears the `confirmed` flag.
3. The trial bundle's JS must call `notifyReady()` within that boot.
4. If the process dies or the app is killed before `notifyReady`, the **next** `prepareBoot` detects 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()`](#getstate)
* [`setLastCheckTs(...)`](#setlastcheckts)
* [`checkManifest(...)`](#checkmanifest)
* [`downloadUpdate(...)`](#downloadupdate)
* [`applyNow()`](#applynow)
* [`notifyReady()`](#notifyready)
* [`rollback()`](#rollback)
* [`addListener('updateProgress', ...)`](#addlistenerupdateprogress-)
* [Interfaces](#interfaces)
* [Type Aliases](#type-aliases)
### getState()
```typescript
getState() => Promise
```
Snapshot the persisted OTA state. See {@link UpdateState}.
**Returns:** Promise<UpdateState>
--------------------
### setLastCheckTs(...)
```typescript
setLastCheckTs(options: { ts: number; }) => Promise
```
Override the persisted `lastCheckTs`. Useful after a successful
user-initiated check to throttle the next background one.
| Param | Type |
| ------------- | ---------------------------- |
| **`options`** | { ts: number; } |
--------------------
### checkManifest(...)
```typescript
checkManifest(options: { url: string; }) => Promise
```
Fetch and parse the manifest at `url`.
| Param | Type |
| ------------- | ----------------------------- |
| **`options`** | { url: string; } |
**Returns:** Promise<UpdateManifest>
--------------------
### downloadUpdate(...)
```typescript
downloadUpdate(options: DownloadUpdateOptions) => Promise
```
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()
```typescript
applyNow() => Promise
```
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()
```typescript
notifyReady() => Promise
```
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()
```typescript
rollback() => Promise
```
Discard any staged bundle + revert to the factory `serverBasePath`.
Recreates the activity for immediate visual feedback on Android.
--------------------
### addListener('updateProgress', ...)
```typescript
addListener(eventName: 'updateProgress', listenerFunc: (event: UpdateProgressEvent) => void) => Promise
```
| 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`, `totalBytes` present
- `verify` — SHA-256 check in progress
- `unzip` — extracting the bundle
- `ready` — staged + `serverBasePath` committed; `version` present
'download' | 'verify' | 'unzip' | 'ready'
## License
MIT. See [LICENSE](./LICENSE).