Skip to content
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,19 @@ The constructor context provides:
- `notifyListeners(eventName, data)` — emits a plugin event, mirroring Capacitor's native `notifyListeners`. Web listeners use the standard `addListener(eventName, callback)` / `PluginListenerHandle` API.
- `services` — platform primitives (currently `services.bundles`: web-bundle serving, reload, and the failed-boot rollback watchdog).

A plugin class may also implement an optional `initialize()` lifecycle hook:

```ts
class SqliteImpl {
constructor({ config, services, notifyListeners }) { ... }

// Optional. Awaited by the platform before the first window loads.
async initialize() { ... }
}
```

`initialize()` runs once after the plugin is constructed and is **awaited before the first application window loads**, so async setup — including repointing the active bundle via `services.bundles.setActiveBundle()` — takes effect on first paint (no default-bundle flash). It is a lifecycle hook, **not** a bridged method: it runs whether or not it is listed in `methods`, and listing it there is harmless. A rejected or thrown `initialize()` fails the app boot loudly, the same way a declared-but-missing method does.
Comment thread
robingenz marked this conversation as resolved.
Outdated

At sync time the platform statically scans the app's dependencies and generates a plugin manifest — no plugin code runs outside Electron. Results, thrown `Error`s, and their `code` properties cross the bridge with Capacitor semantics.

## Packaging
Expand Down
39 changes: 34 additions & 5 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,27 @@ export interface BundlesService {
/**
* Repoint the serving protocol to the given bundle directory and reload
* all app windows. Pass `null` to revert to the packaged app bundle.
* The renderer must call `notifyBootReady()` within the watchdog timeout,
* otherwise the previous bundle is restored.
*
* By default the failed-boot rollback watchdog is armed: the renderer must
* call `notifyBootReady()` within the watchdog timeout, otherwise the
* previous bundle is restored.
*
* Pass `{ bootWatchdog: false }` to opt out of the watchdog for this
* activation. No pending marker is persisted (so the startup pending-check
* never reverts the bundle), the watchdog timer is not armed, and
* `notifyBootReady()` becomes a no-op for this activation. Use this when
* the caller owns rollback itself (e.g. a live-update engine with its own
* kill-safe state machine); running both watchdogs would drift the two
* persisted states.
*/
setActiveBundle(bundleDirectory: string | null): Promise<void>;
setActiveBundle(
bundleDirectory: string | null,
options?: { bootWatchdog?: boolean },
): Promise<void>;
/**
* Signal that the newly served bundle booted successfully, cancelling the
* failed-boot rollback watchdog.
* failed-boot rollback watchdog. A no-op for activations made with
* `{ bootWatchdog: false }`.
*/
notifyBootReady(): void;
}
Expand Down Expand Up @@ -62,9 +76,24 @@ export interface ElectronPluginMetadata {
methods: string[];
}

/**
* Optional lifecycle contract a plugin instance may implement.
*
* `initialize` runs once after the plugin is constructed and is awaited by
* the platform before the first application window loads, so a plugin can
* perform async setup (e.g. repointing the active bundle via
* `services.bundles`) and have it take effect on first paint. It is a
* lifecycle hook, NOT a bridged method: it is invoked whether or not it is
* listed in the static metadata's `methods`, and listing it there is
* harmless. A rejected/thrown `initialize` fails the app boot loudly.
Comment thread
robingenz marked this conversation as resolved.
Outdated
*/
export interface ElectronPluginLifecycle {
initialize?(): Promise<void> | void;
}

export type ElectronPluginClass = (new (
context: ElectronPluginContext,
) => unknown) & {
) => ElectronPluginLifecycle | unknown) & {
[ELECTRON_PLUGIN_MARKER]?: ElectronPluginMetadata;
};

Expand Down
191 changes: 191 additions & 0 deletions src/runtime/bundles.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { Bundles } from './bundles';

const { fsState } = vi.hoisted(() => ({
fsState: {
files: new Map<string, string>(),
dirs: new Set<string>(),
},
}));

vi.mock('electron', () => ({
app: { getPath: () => '/userData' },
}));

vi.mock('fs', () => ({
existsSync: (path: string): boolean =>
fsState.files.has(path) || fsState.dirs.has(path),
readFileSync: (path: string): string => {
const content = fsState.files.get(path);
if (content === undefined) {
throw new Error(`ENOENT: ${path}`);
}
return content;
},
writeFileSync: (path: string, data: string): void => {
fsState.files.set(path, data);
},
}));

const STATE_FILE = join('/userData', 'capacitor-electron-bundles.json');
const BUNDLE_A = join('/bundles', 'a');
const BUNDLE_B = join('/bundles', 'b');

interface PersistedState {
activeBundlePath: string | null;
previousBundlePath: string | null;
pending: boolean;
}

const registerBundle = (dir: string): void => {
fsState.dirs.add(dir);
fsState.dirs.add(join(dir, 'index.html'));
};

const seedState = (state: PersistedState): void => {
fsState.files.set(STATE_FILE, JSON.stringify(state));
};

const readState = (): PersistedState =>
JSON.parse(fsState.files.get(STATE_FILE) as string) as PersistedState;

beforeEach(() => {
fsState.files.clear();
fsState.dirs.clear();
registerBundle(BUNDLE_A);
registerBundle(BUNDLE_B);
vi.useFakeTimers();
});

afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});

describe('Bundles.setActiveBundle', () => {
it('arms the watchdog and persists a pending marker by default', async () => {
const reloadWindows = vi.fn();
const bundles = new Bundles({ reloadWindows, bootReadyTimeoutMs: 100 });

await bundles.setActiveBundle(BUNDLE_A);

expect(reloadWindows).toHaveBeenCalledTimes(1);
expect(readState()).toEqual({
activeBundlePath: BUNDLE_A,
previousBundlePath: null,
pending: true,
});

// Watchdog fires (no notifyBootReady): rolls back to the previous bundle.
vi.advanceTimersByTime(100);
expect(reloadWindows).toHaveBeenCalledTimes(2);
expect(bundles.getActiveBundlePath()).toBeNull();
});

it('does not persist a pending marker or arm the watchdog when opted out', async () => {
const reloadWindows = vi.fn();
const bundles = new Bundles({ reloadWindows, bootReadyTimeoutMs: 100 });

await bundles.setActiveBundle(BUNDLE_A, { bootWatchdog: false });

expect(reloadWindows).toHaveBeenCalledTimes(1);
expect(readState().pending).toBe(false);
expect(bundles.getActiveBundlePath()).toBe(BUNDLE_A);

// No watchdog: advancing past the timeout must not reload or roll back.
vi.advanceTimersByTime(1000);
expect(reloadWindows).toHaveBeenCalledTimes(1);
expect(bundles.getActiveBundlePath()).toBe(BUNDLE_A);
});

it('treats notifyBootReady as a no-op for an opted-out activation', async () => {
const reloadWindows = vi.fn();
const bundles = new Bundles({ reloadWindows, bootReadyTimeoutMs: 100 });

await bundles.setActiveBundle(BUNDLE_A, { bootWatchdog: false });
const before = readState();
bundles.notifyBootReady();

expect(readState()).toEqual(before);
expect(bundles.getActiveBundlePath()).toBe(BUNDLE_A);
});

it('validates index.html regardless of the watchdog option', async () => {
const bundles = new Bundles({ reloadWindows: vi.fn() });

await expect(
bundles.setActiveBundle(join('/bundles', 'missing'), {
bootWatchdog: false,
}),
).rejects.toThrow(/does not contain an index\.html/);
});
});

describe('Bundles startup pending-check', () => {
it('does not revert an opted-out bundle across a restart', async () => {
const first = new Bundles({ reloadWindows: vi.fn() });
await first.setActiveBundle(BUNDLE_A, { bootWatchdog: false });

// Simulate a restart: a fresh instance reads the persisted state.
const second = new Bundles({ reloadWindows: vi.fn() });
expect(second.getActiveBundlePath()).toBe(BUNDLE_A);
});

it('still rolls back a stale pending state from a previous watchdog activation', () => {
seedState({
activeBundlePath: BUNDLE_B,
previousBundlePath: BUNDLE_A,
pending: true,
});

const bundles = new Bundles({ reloadWindows: vi.fn() });

expect(bundles.getActiveBundlePath()).toBe(BUNDLE_A);
expect(readState()).toEqual({
activeBundlePath: BUNDLE_A,
previousBundlePath: null,
pending: false,
});
});
});

describe('Bundles mixed activation sequences', () => {
it('watchdog activation followed by an opt-out cancels the watchdog', async () => {
const reloadWindows = vi.fn();
const bundles = new Bundles({ reloadWindows, bootReadyTimeoutMs: 100 });

await bundles.setActiveBundle(BUNDLE_A);
await bundles.setActiveBundle(BUNDLE_B, { bootWatchdog: false });

expect(readState()).toEqual({
activeBundlePath: BUNDLE_B,
previousBundlePath: BUNDLE_A,
pending: false,
});

// The earlier watchdog must not fire and revert the opted-out bundle.
vi.advanceTimersByTime(1000);
expect(bundles.getActiveBundlePath()).toBe(BUNDLE_B);
});

it('opt-out activation followed by a watchdog activation rolls back to the opted-out bundle', async () => {
const reloadWindows = vi.fn();
const bundles = new Bundles({ reloadWindows, bootReadyTimeoutMs: 100 });

await bundles.setActiveBundle(BUNDLE_A, { bootWatchdog: false });
await bundles.setActiveBundle(BUNDLE_B);

expect(readState()).toEqual({
activeBundlePath: BUNDLE_B,
previousBundlePath: BUNDLE_A,
pending: true,
});

// Watchdog fires: rolls back to the opted-out bundle.
vi.advanceTimersByTime(100);
expect(bundles.getActiveBundlePath()).toBe(BUNDLE_A);
expect(readState().pending).toBe(false);
});
});
20 changes: 17 additions & 3 deletions src/runtime/bundles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export interface BundlesServiceOptions {
* reload, and the failed-boot rollback watchdog. The OTA update product
* (download, verification, channels) is deliberately NOT part of the
* platform; it consumes this primitive.
*
* The watchdog is opt-out per activation via
* `setActiveBundle(dir, { bootWatchdog: false })`: a consumer that owns its
* own rollback state machine (e.g. a live-update engine) must disable it, as
* two concurrent watchdogs writing two persisted states would drift.
*/
export class Bundles implements BundlesService {
private readonly options: BundlesServiceOptions;
Expand Down Expand Up @@ -62,7 +67,10 @@ export class Bundles implements BundlesService {
return this.state.activeBundlePath;
}

async setActiveBundle(bundleDirectory: string | null): Promise<void> {
async setActiveBundle(
bundleDirectory: string | null,
options?: { bootWatchdog?: boolean },
): Promise<void> {
if (
bundleDirectory !== null &&
!existsSync(join(bundleDirectory, 'index.html'))
Expand All @@ -71,15 +79,21 @@ export class Bundles implements BundlesService {
`Bundle directory ${bundleDirectory} does not contain an index.html.`,
);
}
// A pending marker exists only while the watchdog is responsible for
// this activation. With `bootWatchdog: false` the caller owns rollback,
// so no marker is written (the startup pending-check never reverts it)
// and `notifyBootReady()` is a no-op for this activation.
const bootWatchdog = options?.bootWatchdog ?? true;
const pending = bundleDirectory !== null && bootWatchdog;
this.cancelWatchdog();
this.state = {
activeBundlePath: bundleDirectory,
previousBundlePath: this.state.activeBundlePath,
pending: bundleDirectory !== null,
pending,
};
this.writeState();
this.options.reloadWindows();
if (bundleDirectory !== null) {
if (pending) {
this.armWatchdog();
}
}
Expand Down
1 change: 1 addition & 0 deletions src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export { defineConfig } from '../config/index';
export type {
BundlesService,
ElectronPluginContext,
ElectronPluginLifecycle,
PlatformServices,
} from '../plugin/index';
export { defineElectronPlugin } from '../plugin/index';
Expand Down
Loading
Loading