Skip to content
Open
43 changes: 23 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ This SDK speaks the same protocol and the same vocabulary as the [`@capawesome/c
- 🛟 **Kill-safe rollback**: A pending-boot marker and boot counter are persisted to disk _before_ a new bundle loads. If the app crashes, hangs or is killed during boot — even by a power loss — the next start automatically reverts to the last bundle that worked and optionally blocks the broken one.
- 🔒 **Signature verification**: RSA signature verification of every downloaded bundle (`publicKey`), plus checksum re-verification of the installed bundle at activation time — tampering after download is detected too.
- 🌐 **Stable origin serving**: A privileged custom scheme serves the active bundle under a constant origin, so `localStorage`, IndexedDB and service workers survive bundle switches. A simple path-based mode is available as an alternative.
- 🚦 **Channels**: Deliver different bundles to different user groups (production, beta, staged rollouts).
- 🚦 **Channels**: Deliver different bundles to different user groups (production, beta, staged rollouts), and discover them at runtime with `fetchChannels()`.
- 🧩 **Delta updates**: The `manifest` artifact type downloads only the files that changed and reuses the rest from the current bundle — smaller, faster updates.
- 📂 **Multiple bundles**: Download, manage and switch between bundles programmatically.
- 🔁 **Background updates**: Optional automatic sync at app start, on focus and on resume.
- 🔐 **Secure by default**: HTTPS-only downloads (localhost exempt for development), zip-slip protection, atomic bundle installation.
Expand Down Expand Up @@ -204,19 +205,20 @@ const { currentBundleId } = await engine.initialize(); // BEFORE loading web con

The API mirrors [`@capawesome/capacitor-live-update`](https://capawesome.io/plugins/live-update/). Differences that exist are deliberate and listed here:

| Aspect | Capacitor plugin | This SDK |
| -------------------------------- | --------------------------------------- | ------------------------------------------------------------------------- |
| `readyTimeout` default | `0` (disabled) | `0` (disabled) — same default, same recommendation to set `10000` |
| Rollback target | Default bundle | **Last successful bundle**, then default — desktop has no store reinstall |
| Kill-safe boot rollback | — | Pending-boot marker on disk, checked at every process start |
| Activation-time verification | — | Installed bundles re-verified against install-time checksums |
| Rollback blocking | On `ready()` | At rollback time (survives a kill before `ready()`) |
| Configuration | Capacitor config file | `createLiveUpdate()` options |
| `versionCode` / `versionName` | Native app version | `app.getVersion()` unless configured |
| Device ID | Random UUID (Android) / vendor ID (iOS) | Random UUID, persisted per app ID |
| Serving | Capacitor WebView | `serve()` custom scheme or `getCurrentBundlePath()` |
| `fetchChannels()`, `setConfig()` | Available | Not yet available |
| `manifest` artifact type | Available (delta updates) | Not yet available (`zip` only) |
| Aspect | Capacitor plugin | This SDK |
| ----------------------------- | --------------------------------------- | ------------------------------------------------------------------------- |
| `readyTimeout` default | `0` (disabled) | `0` (disabled) — same default, same recommendation to set `10000` |
| Rollback target | Default bundle | **Last successful bundle**, then default — desktop has no store reinstall |
| Kill-safe boot rollback | — | Pending-boot marker on disk, checked at every process start |
| Activation-time verification | — | Installed bundles re-verified against install-time checksums |
| Rollback blocking | On `ready()` | At rollback time (survives a kill before `ready()`) |
| Configuration | Capacitor config file | `createLiveUpdate()` options |
| `versionCode` / `versionName` | Native app version | `app.getVersion()` unless configured |
| Device ID | Random UUID (Android) / vendor ID (iOS) | Random UUID, persisted per app ID |
| Serving | Capacitor WebView | `serve()` custom scheme or `getCurrentBundlePath()` |
| `setConfig()` | Available | Not available |
| `fetchChannels()` | Available | **Available** |
| `manifest` artifact type | Available (delta updates) | **Available** (delta updates) |

## API

Expand Down Expand Up @@ -247,7 +249,7 @@ Creates the SDK. Call once, early in your main process (before `app.whenReady()`

The returned `LiveUpdate` object implements the shared vocabulary — the same methods you know from the Capacitor plugin:

`clearBlockedBundles()`, `deleteBundle(options)`, `downloadBundle(options)`, `fetchLatestBundle(options?)`, `getBlockedBundles()`, `getChannel()`, `getCurrentBundle()`, `getCustomId()`, `getDeviceId()`, `getDownloadedBundles()`, `getNextBundle()`, `getVersionCode()`, `getVersionName()`, `isSyncing()`, `ready()`, `reload()`, `reset()`, `setChannel(options)`, `setCustomId(options)`, `setNextBundle(options)`, `sync(options?)`, `addListener(eventName, listener)`, `removeAllListeners()`
`clearBlockedBundles()`, `deleteBundle(options)`, `downloadBundle(options)`, `fetchChannels(options?)`, `fetchLatestBundle(options?)`, `getBlockedBundles()`, `getChannel()`, `getCurrentBundle()`, `getCustomId()`, `getDeviceId()`, `getDownloadedBundles()`, `getNextBundle()`, `getVersionCode()`, `getVersionName()`, `isSyncing()`, `ready()`, `reload()`, `reset()`, `setChannel(options)`, `setCustomId(options)`, `setNextBundle(options)`, `sync(options?)`, `addListener(eventName, listener)`, `removeAllListeners()`

plus the Electron-specific serving integration:

Expand All @@ -260,11 +262,12 @@ All options and results use the exact same shapes as the Capacitor plugin (`Sync

#### Events

| Event | Payload | Emitted when |
| ------------------------ | ----------------------------------------------------- | ----------------------------------- |
| `downloadBundleProgress` | `{ bundleId, downloadedBytes, progress, totalBytes }` | A bundle download makes progress |
| `nextBundleSet` | `{ bundleId }` | A bundle is set as the next bundle |
| `reloaded` | – | The app was reloaded via `reload()` |
| Event | Payload | Emitted when |
| ------------------------ | ----------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `downloadBundleProgress` | `{ bundleId, downloadedBytes, progress, totalBytes }` | A bundle download makes progress |
| `nextBundleSet` | `{ bundleId }` | A bundle is set as the next bundle |
| `reloaded` | – | The app was reloaded via `reload()` |
| `rolledBack` | `{ currentBundleId, previousBundleId }` | The app was rolled back to a previous bundle after a boot did not signal readiness in time |

Events are available in the main process (`liveUpdate.addListener(...)`) and forwarded to attached renderers (`LiveUpdate.addListener(...)`).

Expand Down
14 changes: 14 additions & 0 deletions e2e/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,20 @@ test('rejects a tampered bundle (signature verification)', async () => {
await app.close();
});

test('syncs a manifest (delta) bundle over the built-in bundle', async () => {
await mockServer.setLatest('4.0.0-manifest');
const { app, page } = await launchExample();
await page.getByTestId('sync').click();
await expect(page.getByTestId('next-bundle')).toHaveText('4.0.0-manifest');
await page.getByTestId('reload').click();
await expect(page.getByTestId('current-bundle')).toHaveText('4.0.0-manifest');
await expect(page.getByTestId('marker')).toHaveText('2.0.0');
await expect(page.getByTestId('ready-state')).toContainText(
'rollback: false',
);
await app.close();
});

test('simple mode: syncs and reloads via getCurrentBundlePath()', async () => {
await mockServer.setLatest('2.0.0');
const { app, page } = await launchExample({ servingMode: 'simple' });
Expand Down
159 changes: 148 additions & 11 deletions example/scripts/mock-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,106 @@
*
* Speaks the Live Update protocol:
* - GET /v1/apps/{appId}/bundles/latest -> latest bundle JSON or 404
* - GET /v1/apps/{appId}/channels -> list of channels (or 401 when
* CHANNELS_DISABLED is set)
* - GET /download/{file} -> zip bytes with X-Checksum
* and X-Signature headers
* - GET /manifest/{bundleId}?href=<href> -> the manifest JSON (delta)
* or a single file with its
* X-Checksum / X-Signature headers
* - POST /__control -> {"latest": "<bundleId>" | null}
* switches the offered bundle
*
* The offered bundle can also be set via the LATEST env variable.
*/
import { readFile } from 'node:fs/promises';
import { createHash, createSign } from 'node:crypto';
import { existsSync } from 'node:fs';
import { readFile, readdir } from 'node:fs/promises';
import { createServer } from 'node:http';
import { dirname, join } from 'node:path';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';

const exampleDirectory = dirname(dirname(fileURLToPath(import.meta.url)));
const bundlesDirectory = join(exampleDirectory, 'dist', 'bundles');
const keysDirectory = join(exampleDirectory, 'dist', 'keys');
const port = Number(process.env.MOCK_SERVER_PORT ?? 4100);

const MANIFEST_FILE_NAME = 'capawesome-live-update-manifest.json';

const bundles = JSON.parse(
await readFile(join(bundlesDirectory, 'index.json'), 'utf8'),
);
let latestBundleId = process.env.LATEST ?? null;

const privateKeyPath = join(keysDirectory, 'private.pem');
const privateKeyPem = existsSync(privateKeyPath)
? await readFile(privateKeyPath, 'utf8')
: null;

// Manifest (delta) bundles are served directly from a source directory
// of web assets; the manifest itself is generated on the fly.
const manifestBundles = {
'4.0.0-manifest': join(exampleDirectory, 'dist', 'bundle-2.0.0'),
};

const channels = [
{ id: 'a1b2c3d4-0000-0000-0000-000000000001', name: 'production' },
{ id: 'a1b2c3d4-0000-0000-0000-000000000002', name: 'beta' },
{ id: 'a1b2c3d4-0000-0000-0000-000000000003', name: 'canary' },
];

function checksum(bytes) {
return createHash('sha256').update(bytes).digest('hex');
}

function sign(bytes) {
if (!privateKeyPem) {
return null;
}
const signer = createSign('RSA-SHA256');
signer.update(bytes);
return signer.sign(privateKeyPem).toString('base64');
}

async function listFiles(directory) {
const files = [];
const walk = async current => {
for (const entry of await readdir(current, { withFileTypes: true })) {
const entryPath = join(current, entry.name);
if (entry.isDirectory()) {
await walk(entryPath);
} else if (entry.isFile()) {
files.push({
absolutePath: entryPath,
href: relative(directory, entryPath).split('\\').join('/'),
});
}
}
};
await walk(directory);
return files;
}

async function buildManifest(directory) {
const files = await listFiles(directory);
return Promise.all(
files.map(async file => {
const bytes = await readFile(file.absolutePath);
return {
checksum: checksum(bytes),
href: file.href,
sizeInBytes: bytes.length,
};
}),
);
}

function sendJson(response, status, payload) {
response.statusCode = status;
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(payload));
}

const server = createServer(async (request, response) => {
const url = new URL(request.url ?? '/', `http://localhost:${port}`);
console.log(`[mock-server] ${request.method} ${url.pathname}${url.search}`);
Expand All @@ -36,25 +115,83 @@ const server = createServer(async (request, response) => {
response.end(JSON.stringify({ latest: latestBundleId }));
return;
}
if (
request.method === 'GET' &&
/^\/v1\/apps\/[^/]+\/channels$/.test(url.pathname)
) {
if (process.env.CHANNELS_DISABLED) {
response.statusCode = 401;
response.end(
JSON.stringify({
message:
'Unauthorized. Channel Discovery may not be enabled for this app.',
}),
);
return;
}
const limit = Number(url.searchParams.get('limit') ?? 50);
const offset = Number(url.searchParams.get('offset') ?? 0);
const query = url.searchParams.get('query');
const filtered = channels.filter(channel =>
query ? channel.name.includes(query) : true,
);
sendJson(response, 200, filtered.slice(offset, offset + limit));
return;
}
if (
request.method === 'GET' &&
/^\/v1\/apps\/[^/]+\/bundles\/latest$/.test(url.pathname)
) {
if (latestBundleId && manifestBundles[latestBundleId]) {
sendJson(response, 200, {
artifactType: 'manifest',
bundleId: latestBundleId,
url: `http://localhost:${port}/manifest/${latestBundleId}`,
});
return;
}
const bundle =
latestBundleId === null ? undefined : bundles[latestBundleId];
if (!bundle) {
response.statusCode = 404;
response.end(JSON.stringify({ message: 'No bundle available.' }));
sendJson(response, 404, { message: 'No bundle available.' });
return;
}
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
artifactType: 'zip',
bundleId: latestBundleId,
url: `http://localhost:${port}/download/${bundle.file}`,
}),
sendJson(response, 200, {
artifactType: 'zip',
bundleId: latestBundleId,
url: `http://localhost:${port}/download/${bundle.file}`,
});
return;
}
if (request.method === 'GET' && url.pathname.startsWith('/manifest/')) {
const bundleId = decodeURIComponent(
url.pathname.slice('/manifest/'.length),
);
const sourceDirectory = manifestBundles[bundleId];
if (!sourceDirectory || !existsSync(sourceDirectory)) {
response.statusCode = 404;
response.end('Not found');
return;
}
const href = url.searchParams.get('href');
if (href === MANIFEST_FILE_NAME) {
sendJson(response, 200, await buildManifest(sourceDirectory));
return;
}
const files = await listFiles(sourceDirectory);
const file = files.find(entry => entry.href === href);
if (!file) {
response.statusCode = 404;
response.end('Not found');
return;
}
const bytes = await readFile(file.absolutePath);
response.setHeader('X-Checksum', checksum(bytes));
const signature = sign(bytes);
if (signature) {
response.setHeader('X-Signature', signature);
}
response.end(bytes);
return;
}
if (request.method === 'GET' && url.pathname.startsWith('/download/')) {
Expand Down
Loading
Loading