{"id":50665628,"url":"https://github.com/svssdeva/cap-silent-update","last_synced_at":"2026-06-08T06:04:36.993Z","repository":{"id":353083526,"uuid":"1217868857","full_name":"svssdeva/cap-silent-update","owner":"svssdeva","description":"Self-hosted silent live updates for Capacitor apps. Zero cloud, SHA-256 integrity, trial + rollback. Android implemented, iOS planned.","archived":false,"fork":false,"pushed_at":"2026-04-22T11:07:47.000Z","size":425,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-23T15:35:30.869Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Java","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/svssdeva.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-22T09:49:31.000Z","updated_at":"2026-04-22T11:06:25.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/svssdeva/cap-silent-update","commit_stats":null,"previous_names":["svssdeva/cap-silent-update"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/svssdeva/cap-silent-update","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/svssdeva%2Fcap-silent-update","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/svssdeva%2Fcap-silent-update/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/svssdeva%2Fcap-silent-update/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/svssdeva%2Fcap-silent-update/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/svssdeva","download_url":"https://codeload.github.com/svssdeva/cap-silent-update/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/svssdeva%2Fcap-silent-update/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34050239,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-08T02:00:07.615Z","response_time":111,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-08T06:04:18.563Z","updated_at":"2026-06-08T06:04:36.987Z","avatar_url":"https://github.com/svssdeva.png","language":"Java","funding_links":[],"categories":[],"sub_categories":[],"readme":"# @svssdeva/cap-silent-update\n\nSelf-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.\n\n## Status\n\n| Platform | Status                                       |\n| -------- | -------------------------------------------- |\n| Android  | Working. Used in production.                 |\n| iOS      | Plugin surface present, methods unimplemented. Planned. |\n| Web      | Unimplemented — gate calls behind `Capacitor.isNativePlatform()`. |\n\n## Why another update plugin?\n\n| | This plugin | Capgo Live Updates | Capawesome Live Updates |\n| --- | --- | --- | --- |\n| Self-hosted | Yes | Optional (default: Capgo cloud) | Cloud only |\n| Account required | None | Capgo | Capawesome |\n| Runtime deps | Zero | `@capgo/capacitor-updater` | `@capawesome/capacitor-live-update` |\n| Code size | ~15 kB minified | Larger | Larger |\n| Signing | Scaffolded (ed25519 planned) | Bring-your-own | Cloud-managed |\n| License | MIT | MIT | Commercial |\n\nThis plugin is deliberately small. You bring the hosting, the manifest format is one JSON object, and the only native dependency is Capacitor itself.\n\n## Install\n\n```bash\nnpm install @svssdeva/cap-silent-update\nnpx cap sync\n```\n\n### Android setup\n\nCall `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.\n\n```java\npackage com.example.myapp;\n\nimport android.os.Bundle;\nimport com.getcapacitor.BridgeActivity;\nimport com.svssdeva.silentupdate.SilentUpdatePlugin;\n\npublic class MainActivity extends BridgeActivity {\n    @Override\n    protected void onCreate(Bundle savedInstanceState) {\n        SilentUpdatePlugin.prepareBoot(this);\n        super.onCreate(savedInstanceState);\n    }\n}\n```\n\nCapacitor auto-discovers the plugin via Gradle — no manual `registerPlugin(...)` call needed.\n\n### iOS setup\n\nInstalling the pod keeps the JS surface stable. Every method currently rejects with `unimplemented`. Gate iOS calls defensively:\n\n```ts\nimport { Capacitor } from '@capacitor/core';\nimport { SilentUpdate } from '@svssdeva/cap-silent-update';\n\nif (Capacitor.getPlatform() === 'android') {\n  await SilentUpdate.checkManifest({ url: '...' });\n}\n```\n\n## Usage\n\n```ts\nimport { SilentUpdate } from '@svssdeva/cap-silent-update';\n\n// On every successful boot — MUST be called to confirm the trial bundle.\nawait SilentUpdate.notifyReady();\n\n// Check for an update (cheap: single HTTP GET of your manifest).\nconst manifest = await SilentUpdate.checkManifest({\n  url: 'https://cdn.example.com/ota/manifest.json',\n});\n\nconst state = await SilentUpdate.getState();\nif (manifest.version === state.currentVersion) return;\n\n// Download + verify + stage. Takes effect on next cold start.\nawait SilentUpdate.downloadUpdate({\n  url: manifest.url,\n  version: manifest.version,\n  checksum: manifest.checksum,\n  signature: manifest.signature,\n});\n\n// If your manifest says force:true, apply without waiting for a restart.\nif (manifest.force) {\n  await SilentUpdate.applyNow();\n}\n```\n\n### Progress events\n\nThe plugin emits a single `updateProgress` event with a `stage` discriminator. Use this to drive a progress bar on force-apply or user-initiated checks.\n\n```ts\nimport type { UpdateProgressEvent } from '@svssdeva/cap-silent-update';\n\nconst handle = await SilentUpdate.addListener('updateProgress', (ev: UpdateProgressEvent) =\u003e {\n  switch (ev.stage) {\n    case 'download':\n      console.log(`${ev.percent}% (${ev.bytesWritten}/${ev.totalBytes})`);\n      break;\n    case 'verify':\n      console.log('Verifying checksum…');\n      break;\n    case 'unzip':\n      console.log('Extracting…');\n      break;\n    case 'ready':\n      console.log(`Bundle ${ev.version} staged.`);\n      break;\n  }\n});\n\n// Remember to detach.\nawait handle.remove();\n```\n\nProgress 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.\n\n## Manifest schema\n\nThe server-side manifest is one JSON object. The plugin only reads these fields:\n\n```json\n{\n  \"version\": \"1.2.0\",\n  \"url\": \"https://cdn.example.com/ota/bundles/1.2.0.zip\",\n  \"checksum\": \"a1b2c3…\",\n  \"force\": false,\n  \"min_native_version\": \"1.1\",\n  \"signature\": \"optional-ed25519-hex-reserved-for-future-use\"\n}\n```\n\n| Field | Type | Required | Notes |\n| --- | --- | --- | --- |\n| `version` | string | yes | Opaque to the plugin. Used to name the on-disk bundle dir. |\n| `url` | string | yes | Absolute URL to the bundle zip. |\n| `checksum` | string | yes | Hex-encoded SHA-256 of the zip bytes. |\n| `force` | boolean | no | When `true`, consumer should call `applyNow()` after download. |\n| `min_native_version` | string | no | Semver-ish. Consumer decides gating — plugin only passes it through. |\n| `signature` | string | no | Reserved; plugin currently ignores. |\n\n### Bundle layout\n\nThe 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.).\n\n### Example upload script\n\nA 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.\n\n## Security model\n\n**Integrity is enforced. Authenticity is not — yet.**\n\n- 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.\n- 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.\n- 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.\n- 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.\n- Zip-slip protection is in place: the extractor rejects entries whose canonical path escapes the bundle directory.\n\n## Trial + rollback contract\n\nA bundle goes through three states: `DOWNLOADED → TRIAL → CURRENT` (success) or `TRIAL → FACTORY` (failure).\n\n1. After `downloadUpdate`, the bundle is **staged**. It is not live until the next cold start.\n2. On the next cold start, `prepareBoot` promotes it to **trial** and clears the `confirmed` flag.\n3. The trial bundle's JS must call `notifyReady()` within that boot.\n4. 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.\n\nCalls to `applyNow()` short-circuit step 2 — they load the bundle in the running WebView and begin the trial immediately, without a restart.\n\nExplicit `rollback()` discards any state and reverts to factory immediately.\n\n## Upgrade path from a custom plugin\n\nIf 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.\n\nThis 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.\n\n## API\n\n\u003cdocgen-index\u003e\n\n* [`getState()`](#getstate)\n* [`setLastCheckTs(...)`](#setlastcheckts)\n* [`checkManifest(...)`](#checkmanifest)\n* [`downloadUpdate(...)`](#downloadupdate)\n* [`applyNow()`](#applynow)\n* [`notifyReady()`](#notifyready)\n* [`rollback()`](#rollback)\n* [`addListener('updateProgress', ...)`](#addlistenerupdateprogress-)\n* [Interfaces](#interfaces)\n* [Type Aliases](#type-aliases)\n\n\u003c/docgen-index\u003e\n\n\u003cdocgen-api\u003e\n\u003c!--Update the source file JSDoc comments and rerun docgen to update the docs below--\u003e\n\n### getState()\n\n```typescript\ngetState() =\u003e Promise\u003cUpdateState\u003e\n```\n\nSnapshot the persisted OTA state. See {@link \u003ca href=\"#updatestate\"\u003eUpdateState\u003c/a\u003e}.\n\n**Returns:** \u003ccode\u003ePromise\u0026lt;\u003ca href=\"#updatestate\"\u003eUpdateState\u003c/a\u003e\u0026gt;\u003c/code\u003e\n\n--------------------\n\n\n### setLastCheckTs(...)\n\n```typescript\nsetLastCheckTs(options: { ts: number; }) =\u003e Promise\u003cvoid\u003e\n```\n\nOverride the persisted `lastCheckTs`. Useful after a successful\nuser-initiated check to throttle the next background one.\n\n| Param         | Type                         |\n| ------------- | ---------------------------- |\n| **`options`** | \u003ccode\u003e{ ts: number; }\u003c/code\u003e |\n\n--------------------\n\n\n### checkManifest(...)\n\n```typescript\ncheckManifest(options: { url: string; }) =\u003e Promise\u003cUpdateManifest\u003e\n```\n\nFetch and parse the manifest at `url`.\n\n| Param         | Type                          |\n| ------------- | ----------------------------- |\n| **`options`** | \u003ccode\u003e{ url: string; }\u003c/code\u003e |\n\n**Returns:** \u003ccode\u003ePromise\u0026lt;\u003ca href=\"#updatemanifest\"\u003eUpdateManifest\u003c/a\u003e\u0026gt;\u003c/code\u003e\n\n--------------------\n\n\n### downloadUpdate(...)\n\n```typescript\ndownloadUpdate(options: DownloadUpdateOptions) =\u003e Promise\u003cDownloadUpdateResult\u003e\n```\n\nDownload the bundle zip, verify SHA-256, extract to the app's private\nstorage, and stage it. The stage becomes the live bundle on the next\ncold start (see `prepareBoot` in the Android plugin).\n\n| Param         | Type                                                                    |\n| ------------- | ----------------------------------------------------------------------- |\n| **`options`** | \u003ccode\u003e\u003ca href=\"#downloadupdateoptions\"\u003eDownloadUpdateOptions\u003c/a\u003e\u003c/code\u003e |\n\n**Returns:** \u003ccode\u003ePromise\u0026lt;\u003ca href=\"#downloadupdateresult\"\u003eDownloadUpdateResult\u003c/a\u003e\u0026gt;\u003c/code\u003e\n\n--------------------\n\n\n### applyNow()\n\n```typescript\napplyNow() =\u003e Promise\u003cvoid\u003e\n```\n\nPromote a staged bundle immediately in the running WebView. Used for\n`force: true` manifests where a restart would be too disruptive.\nBegins the trial window — JS must call `notifyReady` before the\nnext cold start to avoid rollback.\n\n--------------------\n\n\n### notifyReady()\n\n```typescript\nnotifyReady() =\u003e Promise\u003cvoid\u003e\n```\n\nMark the current bundle as stable. Call on every successful boot.\nFailure to call before the next cold start is interpreted as a\ncrash and triggers a rollback to factory.\n\n--------------------\n\n\n### rollback()\n\n```typescript\nrollback() =\u003e Promise\u003cvoid\u003e\n```\n\nDiscard any staged bundle + revert to the factory `serverBasePath`.\nRecreates the activity for immediate visual feedback on Android.\n\n--------------------\n\n\n### addListener('updateProgress', ...)\n\n```typescript\naddListener(eventName: 'updateProgress', listenerFunc: (event: UpdateProgressEvent) =\u003e void) =\u003e Promise\u003cPluginListenerHandle\u003e\n```\n\n| Param              | Type                                                                                    |\n| ------------------ | --------------------------------------------------------------------------------------- |\n| **`eventName`**    | \u003ccode\u003e'updateProgress'\u003c/code\u003e                                                           |\n| **`listenerFunc`** | \u003ccode\u003e(event: \u003ca href=\"#updateprogressevent\"\u003eUpdateProgressEvent\u003c/a\u003e) =\u0026gt; void\u003c/code\u003e |\n\n**Returns:** \u003ccode\u003ePromise\u0026lt;\u003ca href=\"#pluginlistenerhandle\"\u003ePluginListenerHandle\u003c/a\u003e\u0026gt;\u003c/code\u003e\n\n--------------------\n\n\n### Interfaces\n\n\n#### UpdateState\n\nSnapshot of the plugin's persisted state. Read this on every check to\ndecide whether to hit the manifest (throttling) or apply a pending\nbundle.\n\n| Prop                 | Type                 | Description                                                           |\n| -------------------- | -------------------- | --------------------------------------------------------------------- |\n| **`currentVersion`** | \u003ccode\u003estring\u003c/code\u003e  | Active bundle version, or the string `\"factory\"` when no OTA is live. |\n| **`pendingVersion`** | \u003ccode\u003estring\u003c/code\u003e  | Staged-but-not-yet-promoted bundle, or empty string when none.        |\n| **`lastCheckTs`**    | \u003ccode\u003enumber\u003c/code\u003e  | Epoch ms of the most recent `checkManifest` call. `0` if never.       |\n| **`confirmed`**      | \u003ccode\u003eboolean\u003c/code\u003e | False during a trial boot (between `applyNow` and `notifyReady`).     |\n\n\n#### UpdateManifest\n\nResult of `checkManifest`. Mirrors the server's manifest.json plus\nthe required gate fields.\n\n`signature` is reserved for a future ed25519 signing rollout. The\nplugin currently accepts and ignores it — servers can ship it ahead\nof verification landing without breaking older clients.\n\n| Prop                     | Type                 | Description                                          |\n| ------------------------ | -------------------- | ---------------------------------------------------- |\n| **`version`**            | \u003ccode\u003estring\u003c/code\u003e  |                                                      |\n| **`url`**                | \u003ccode\u003estring\u003c/code\u003e  |                                                      |\n| **`checksum`**           | \u003ccode\u003estring\u003c/code\u003e  |                                                      |\n| **`force`**              | \u003ccode\u003eboolean\u003c/code\u003e |                                                      |\n| **`min_native_version`** | \u003ccode\u003estring\u003c/code\u003e  | Semver-like string. Consumer decides how to compare. |\n| **`signature`**          | \u003ccode\u003estring\u003c/code\u003e  |                                                      |\n\n\n#### DownloadUpdateResult\n\n| Prop          | Type                 |\n| ------------- | -------------------- |\n| **`success`** | \u003ccode\u003eboolean\u003c/code\u003e |\n| **`version`** | \u003ccode\u003estring\u003c/code\u003e  |\n\n\n#### DownloadUpdateOptions\n\n| Prop            | Type                | Description                                                                                       |\n| --------------- | ------------------- | ------------------------------------------------------------------------------------------------- |\n| **`url`**       | \u003ccode\u003estring\u003c/code\u003e |                                                                                                   |\n| **`version`**   | \u003ccode\u003estring\u003c/code\u003e |                                                                                                   |\n| **`checksum`**  | \u003ccode\u003estring\u003c/code\u003e |                                                                                                   |\n| **`signature`** | \u003ccode\u003estring\u003c/code\u003e | See {@link \u003ca href=\"#updatemanifest\"\u003eUpdateManifest.signature\u003c/a\u003e}. Forward as-is; plugin no-ops. |\n\n\n#### PluginListenerHandle\n\n| Prop         | Type                                      |\n| ------------ | ----------------------------------------- |\n| **`remove`** | \u003ccode\u003e() =\u0026gt; Promise\u0026lt;void\u0026gt;\u003c/code\u003e |\n\n\n#### UpdateProgressEvent\n\n| Prop               | Type                                                |\n| ------------------ | --------------------------------------------------- |\n| **`stage`**        | \u003ccode\u003e\u003ca href=\"#updatestage\"\u003eUpdateStage\u003c/a\u003e\u003c/code\u003e |\n| **`percent`**      | \u003ccode\u003enumber\u003c/code\u003e                                 |\n| **`bytesWritten`** | \u003ccode\u003enumber\u003c/code\u003e                                 |\n| **`totalBytes`**   | \u003ccode\u003enumber\u003c/code\u003e                                 |\n| **`version`**      | \u003ccode\u003estring\u003c/code\u003e                                 |\n\n\n### Type Aliases\n\n\n#### UpdateStage\n\nStages emitted via the `updateProgress` event:\n- `download` — bytes are streaming; `percent`, `bytesWritten`, `totalBytes` present\n- `verify`   — SHA-256 check in progress\n- `unzip`    — extracting the bundle\n- `ready`    — staged + `serverBasePath` committed; `version` present\n\n\u003ccode\u003e'download' | 'verify' | 'unzip' | 'ready'\u003c/code\u003e\n\n\u003c/docgen-api\u003e\n\n## License\n\nMIT. See [LICENSE](./LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsvssdeva%2Fcap-silent-update","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsvssdeva%2Fcap-silent-update","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsvssdeva%2Fcap-silent-update/lists"}