Skip to content
Open
78 changes: 40 additions & 38 deletions README.md

Large diffs are not rendered by default.

12 changes: 9 additions & 3 deletions e2e/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,17 @@ async function launchExample(
const userDataDirectory =
options.userDataDirectory ?? (await createUserDataDirectory());
const app = await electron.launch({
args: [exampleDirectory as string],
// `--no-sandbox` lets Electron launch on Linux CI, where the copied
// distribution has no setuid sandbox helper and unprivileged user
// namespaces are restricted. No-op on macOS and Windows.
args: ['--no-sandbox', exampleDirectory as string],
env: {
...(process.env as Record<string, string>),
EXAMPLE_PUBLIC_KEY: (await getExamplePublicKey()) as string,
EXAMPLE_READY_TIMEOUT: '10000',
// Generous watchdog ceiling: a spurious rollback (and bundle
// block) during a slow CI boot would break these specs. No spec
// relies on the watchdog timer firing.
EXAMPLE_READY_TIMEOUT: '60000',
EXAMPLE_SERVER_DOMAIN: mockServer.serverDomain,
EXAMPLE_SERVING_MODE: options.servingMode ?? 'serve',
EXAMPLE_USER_DATA: userDataDirectory,
Expand Down Expand Up @@ -118,7 +124,7 @@ test('the packaged app boots the built-in bundle from the asar archive', async (
const userDataDirectory = await createUserDataDirectory();
const app = await electron.launch({
executablePath: process.env.E2E_PACKAGED_BINARY as string,
args: [],
args: ['--no-sandbox'],
env: {
...(process.env as Record<string, string>),
EXAMPLE_SERVER_DOMAIN: mockServer.serverDomain,
Expand Down
21 changes: 18 additions & 3 deletions e2e/drill.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -139,16 +139,31 @@ async function packageApp() {
}

function launchApp(binaryPath, userDataDirectory, serverDomain, publicKey) {
const child = spawn(binaryPath, [], {
// `--no-sandbox` is required on Linux CI: the Electron distribution is
// copied into place by the drill, so its `chrome-sandbox` helper is not
// owned by root with the setuid bit, and the runner (Ubuntu) also
// restricts unprivileged user namespaces. Without this flag Electron
// aborts on launch and never writes any engine state. Harmless on macOS
// and Windows, which do not use the SUID sandbox.
const child = spawn(binaryPath, ['--no-sandbox'], {
env: {
...process.env,
EXAMPLE_AUTO_UPDATE: 'background',
EXAMPLE_PUBLIC_KEY: publicKey,
EXAMPLE_READY_TIMEOUT: '10000',
// Generous watchdog ceiling: a cold Electron boot on a slow CI
// runner can take longer than 10 s to call ready(). If the
// watchdog fires during a legitimate boot it rolls back AND
// blocks the bundle (autoBlockRolledBackBundles), after which
// the drill's expected states are unreachable. The drill tests
// rollback via kills, never by waiting for this timer.
EXAMPLE_READY_TIMEOUT: '60000',
EXAMPLE_SERVER_DOMAIN: serverDomain,
EXAMPLE_USER_DATA: userDataDirectory,
},
stdio: 'ignore',
// Keep stderr attached so a launch failure (e.g. the Chromium
// sandbox aborting on CI) surfaces in the logs instead of leaving
// waitForState to time out with no explanation.
stdio: ['ignore', 'ignore', 'inherit'],
});
return child;
}
Expand Down
2 changes: 1 addition & 1 deletion e2e/helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export async function readState(userDataDirectory) {
try {
return JSON.parse(
await readFile(
join(userDataDirectory, 'live-update', 'state.json'),
join(userDataDirectory, 'capawesome-live-update', 'state.json'),
'utf8',
),
);
Expand Down
13 changes: 13 additions & 0 deletions e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Prerequisites: `npm run build` and `npm run build --workspace example`.
*/
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { artifactsDirectory, repositoryRoot } from './helpers.mjs';
Expand All @@ -22,6 +23,18 @@ function run(command, args, env = {}) {
}
}

// The electron npm package no longer downloads its binary via an install
// script, so a fresh `npm ci` leaves node_modules/electron/dist missing.
// Fetch it explicitly before the drill copies the distribution.
const electronPackageDirectory = join(
repositoryRoot,
'node_modules',
'electron',
);
if (!existsSync(join(electronPackageDirectory, 'dist'))) {
run(process.execPath, [join(electronPackageDirectory, 'install.js')]);
}

run(process.execPath, [join(repositoryRoot, 'e2e', 'drill.mjs')]);

const binaryPath = (
Expand Down
53 changes: 43 additions & 10 deletions example/scripts/mock-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
*
* 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
* - POST /__control -> {"latest": "<bundleId>" | null}
Expand All @@ -24,6 +26,18 @@ const bundles = JSON.parse(
);
let latestBundleId = process.env.LATEST ?? null;

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 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 +50,44 @@ 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)
) {
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('/download/')) {
Expand Down
17 changes: 12 additions & 5 deletions example/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,18 @@ app.whenReady().then(async () => {
webPreferences: { preload: join(__dirname, 'preload.js') },
});
liveUpdate.attach(window);
if (simpleMode) {
const bundlePath = await liveUpdate.getCurrentBundlePath();
await window.loadFile(join(bundlePath ?? '', 'index.html'));
} else {
await window.loadURL(liveUpdate.getServeUrl());
try {
if (simpleMode) {
const bundlePath = await liveUpdate.getCurrentBundlePath();
await window.loadFile(join(bundlePath ?? '', 'index.html'));
} else {
await window.loadURL(liveUpdate.getServeUrl());
}
} catch (error) {
// The initial load is aborted (ERR_ABORTED) when an SDK-initiated
// reload (e.g. a rollback) navigates the window while the load is
// still pending. The interrupting navigation supersedes this one.
console.warn('[example] Initial load was superseded:', error);
}
});

Expand Down
94 changes: 92 additions & 2 deletions src/engine/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,24 @@ export interface FetchLatestBundleRequest {
deviceId: string;
osVersion: string;
platform: string;
pluginVersion: string;
runtime: string | null;
sdkVersion: string;
}

export interface FetchChannelsRequest {
appId: string;
deviceId: string;
limit: number;
offset: number;
query: string | null;
}

/**
* A single channel returned by the Capawesome Cloud channels endpoint.
*/
export interface GetChannelsResponseItem {
id: string;
name: string;
}

export interface CloudApiClientOptions {
Expand Down Expand Up @@ -89,7 +105,7 @@ export class CloudApiClient {
this.appendQueryParameter(url, 'deviceId', request.deviceId);
this.appendQueryParameter(url, 'osVersion', request.osVersion);
this.appendQueryParameter(url, 'platform', request.platform);
this.appendQueryParameter(url, 'pluginVersion', request.sdkVersion);
this.appendQueryParameter(url, 'pluginVersion', request.pluginVersion);
this.appendQueryParameter(url, 'runtime', request.runtime);
let response: Response;
try {
Expand Down Expand Up @@ -121,6 +137,63 @@ export class CloudApiClient {
return this.parseLatestBundleResponse(json);
}

/**
* Fetch the available channels for the app.
*
* Throws `ChannelDiscoveryNotEnabled` on HTTP 401 (public channels
* not enabled), mirroring the `@capawesome/capacitor-live-update`
* plugin behavior.
*/
public async getChannels(
request: FetchChannelsRequest,
): Promise<GetChannelsResponseItem[]> {
const url = new URL(
`${this.getBaseUrl()}/v1/apps/${encodeURIComponent(request.appId)}/channels`,
);
this.appendQueryParameter(url, 'limit', String(request.limit));
this.appendQueryParameter(url, 'offset', String(request.offset));
this.appendQueryParameter(url, 'query', request.query);
let response: Response;
try {
response = await fetch(url, {
headers: {
'X-Capawesome-Device-Id': request.deviceId,
},
signal: AbortSignal.timeout(this.options.httpTimeout),
});
} catch (error) {
if (isTimeoutError(error)) {
throw new LiveUpdateError(ErrorCode.HttpTimeout, 'Request timed out.');
}
throw new LiveUpdateError(
ErrorCode.Unknown,
'An unknown error has occurred.',
);
}
if (response.status === 401) {
throw new LiveUpdateError(
ErrorCode.ChannelDiscoveryNotEnabled,
'Unauthorized. Channel Discovery may not be enabled for this app.',
);
}
if (!response.ok) {
throw new LiveUpdateError(
ErrorCode.Unknown,
'An unknown error has occurred.',
);
}
let json: unknown;
try {
json = await response.json();
} catch {
throw new LiveUpdateError(
ErrorCode.Unknown,
'An unknown error has occurred.',
);
}
return this.parseChannelsResponse(json);
}

private appendQueryParameter(
url: URL,
name: string,
Expand All @@ -131,6 +204,23 @@ export class CloudApiClient {
}
}

private parseChannelsResponse(json: unknown): GetChannelsResponseItem[] {
if (!Array.isArray(json)) {
return [];
}
const channels: GetChannelsResponseItem[] = [];
for (const entry of json) {
if (typeof entry !== 'object' || entry === null) {
continue;
}
const record = entry as Record<string, unknown>;
if (typeof record.id === 'string' && typeof record.name === 'string') {
channels.push({ id: record.id, name: record.name });
}
}
return channels;
}

private parseLatestBundleResponse(
json: unknown,
): GetLatestBundleResponse | null {
Expand Down
26 changes: 21 additions & 5 deletions src/engine/bundle-store.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import { randomUUID } from 'node:crypto';
import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises';
import { mkdir, readdir, rm, stat } from 'node:fs/promises';
import { join } from 'node:path';

import { ErrorCode, LiveUpdateError } from './errors';
import { renameWithRetry } from './fs-retry';

/**
* Options for the recursive `rm` calls: on Windows, deleting a
* directory fails with EPERM/EBUSY while another process (e.g. an
* antivirus scanner) holds a handle on a file inside it. `rm` retries
* these errors natively with a linear backoff.
*/
const RM_RETRY_OPTIONS = {
force: true,
maxRetries: 5,
recursive: true,
} as const;

/**
* The bundle identifier value that is reserved for the built-in bundle.
Expand Down Expand Up @@ -61,7 +74,7 @@ export class BundleStore {
public async initialize(): Promise<void> {
await mkdir(this.bundlesDirectory, { recursive: true });
// Leftover staging data from a previous crashed run is garbage.
await rm(this.stagingDirectory, { recursive: true, force: true });
await rm(this.stagingDirectory, RM_RETRY_OPTIONS);
await mkdir(this.stagingDirectory, { recursive: true });
}

Expand Down Expand Up @@ -112,17 +125,20 @@ export class BundleStore {
'bundle already exists.',
);
}
await rename(sourceDirectory, this.getPath(bundleId));
// Retried: on Windows the rename fails with EPERM/EACCES while an
// antivirus scanner holds a freshly written file in the staging
// directory.
await renameWithRetry(sourceDirectory, this.getPath(bundleId));
}

public async delete(bundleId: string): Promise<void> {
if (!(await this.has(bundleId))) {
throw new LiveUpdateError(ErrorCode.BundleNotFound, 'bundle not found.');
}
await rm(this.getPath(bundleId), { recursive: true, force: true });
await rm(this.getPath(bundleId), RM_RETRY_OPTIONS);
}

public async cleanUpStaging(directory: string): Promise<void> {
await rm(directory, { recursive: true, force: true });
await rm(directory, RM_RETRY_OPTIONS);
}
}
Loading
Loading