Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/live-update-electron-platform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@capawesome/capacitor-live-update": minor
---

Add support for the Electron platform via [`@capawesome/capacitor-electron`](https://github.com/capawesome-team/capacitor-electron)
133 changes: 78 additions & 55 deletions packages/live-update/README.md

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions packages/live-update/electron/rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export default {
input: 'electron/dist/esm/electron/src/index.js',
output: {
file: 'electron/dist/plugin.mjs',
format: 'esm',
},
external: [
'electron',
'@capacitor/core',
'@capawesome/electron-live-update/engine',
/^node:/,
],
};
38 changes: 38 additions & 0 deletions packages/live-update/electron/src/definitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { PluginsConfig } from '@capacitor/cli';

/**
* Structural subset of the `@capawesome/capacitor-electron` plugin contract.
*
* The platform's contract is the static plugin marker property, so no
* build-time dependency on the platform package is required.
*/
export interface BundlesService {
/**
* Absolute path of the currently active web bundle directory, or `null`
* when the packaged app bundle is active.
*/
getActiveBundlePath(): string | null;
/**
* Repoint the serving protocol to the given bundle directory and reload
* all app windows. Pass `null` to revert to the packaged app bundle.
*
* Pass `{ bootWatchdog: false }` to opt out of the platform's failed-boot
* rollback watchdog for this activation. The live update engine owns
* rollback, so the adapter always opts out.
*/
setActiveBundle(
bundleDirectory: string | null,
options?: { bootWatchdog?: boolean },
): Promise<void>;
}

export interface ElectronPluginContext {
config: {
plugins?: PluginsConfig;
[key: string]: unknown;
};
notifyListeners: (eventName: string, data?: unknown) => void;
services: {
bundles: BundlesService;
};
}
284 changes: 284 additions & 0 deletions packages/live-update/electron/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
import type { PluginsConfig } from '@capacitor/cli';
import { CapacitorException, ExceptionCode } from '@capacitor/core';
import { LiveUpdateEngine } from '@capawesome/electron-live-update/engine';
import { app, powerMonitor } from 'electron';
import { join } from 'node:path';

import type {
DeleteBundleOptions,
DownloadBundleOptions,
FetchChannelsOptions,
FetchChannelsResult,
FetchLatestBundleOptions,
FetchLatestBundleResult,
GetBlockedBundlesResult,
GetBundlesResult,
GetChannelResult,
GetConfigResult,
GetCurrentBundleResult,
GetCustomIdResult,
GetDeviceIdResult,
GetDownloadedBundlesResult,
GetNextBundleResult,
GetVersionCodeResult,
GetVersionNameResult,
IsSyncingResult,
LiveUpdatePlugin,
ReadyResult,
SetChannelOptions,
SetConfigOptions,
SetCustomIdOptions,
SetNextBundleOptions,
SyncOptions,
SyncResult,
} from '../../src/definitions';

import type { ElectronPluginContext } from './definitions';
import { PLUGIN_VERSION } from './version';

const AUTO_UPDATE_MIN_INTERVAL = 15 * 60 * 1000;
const CAPACITOR_RUNTIME = 'capacitor';
const ELECTRON_PLATFORM = '2';

type LiveUpdateConfig = NonNullable<PluginsConfig['LiveUpdate']>;

export class LiveUpdateElectron
implements Omit<LiveUpdatePlugin, 'addListener' | 'removeAllListeners'>
{
public static readonly __capacitorElectronPlugin = {
name: 'LiveUpdate',
methods: [
'clearBlockedBundles',
'deleteBundle',
'downloadBundle',
'fetchChannels',
'fetchLatestBundle',
'getBlockedBundles',
'getBundles',
'getChannel',
'getConfig',
'getCurrentBundle',
'getCustomId',
'getDeviceId',
'getDownloadedBundles',
'getNextBundle',
'getVersionCode',
'getVersionName',
'isSyncing',
'ready',
'reload',
'reset',
'resetConfig',
'setChannel',
'setConfig',
'setCustomId',
'setNextBundle',
'sync',
],
};

private static readonly errorNotImplemented = 'Not implemented on Electron.';

private readonly config: LiveUpdateConfig;
private readonly context: ElectronPluginContext;
private readonly engine: LiveUpdateEngine;
private lastAutoUpdateCheck = 0;

constructor(context: ElectronPluginContext) {
this.context = context;
this.config = context.config.plugins?.LiveUpdate ?? {};
this.engine = new LiveUpdateEngine({
appId: this.config.appId,
autoBlockRolledBackBundles: this.config.autoBlockRolledBackBundles,
autoDeleteBundles: this.config.autoDeleteBundles,
dataDirectory: join(app.getPath('userData'), 'live-update'),
defaultBundlePath: join(app.getAppPath(), 'app'),
defaultChannel: this.config.defaultChannel,
httpTimeout: this.config.httpTimeout,
osVersion: process.getSystemVersion(),
platform: ELECTRON_PLATFORM,
pluginVersion: PLUGIN_VERSION,
publicKey: this.config.publicKey,
readyTimeout: this.config.readyTimeout,
runtime: CAPACITOR_RUNTIME,
sdkVersion: PLUGIN_VERSION,
serverDomain: this.config.serverDomain,
versionCode: app.getVersion(),
versionName: app.getVersion(),
});
this.engine.on('downloadBundleProgress', event =>
this.context.notifyListeners('downloadBundleProgress', event),
);
this.engine.on('nextBundleSet', event =>
this.context.notifyListeners('nextBundleSet', event),
);
// The engine owns rollback. When its ready watchdog rolls back,
// repoint the platform so that all windows reload the target bundle.
this.engine.on('rolledBack', () => {
void this.applyCurrentBundle().catch(error =>
console.error(`[LiveUpdate] Failed to apply rollback: ${error}`),
);
});
}

public async clearBlockedBundles(): Promise<void> {
return this.engine.clearBlockedBundles();
}

public async deleteBundle(options: DeleteBundleOptions): Promise<void> {
return this.engine.deleteBundle(options);
}

public async downloadBundle(options: DownloadBundleOptions): Promise<void> {
return this.engine.downloadBundle(options);
}

public async fetchChannels(
options?: FetchChannelsOptions,
): Promise<FetchChannelsResult> {
return this.engine.fetchChannels(options);
}

public async fetchLatestBundle(
options?: FetchLatestBundleOptions,
): Promise<FetchLatestBundleResult> {
return this.engine.fetchLatestBundle(options);
}

public async getBlockedBundles(): Promise<GetBlockedBundlesResult> {
return this.engine.getBlockedBundles();
}

public async getBundles(): Promise<GetBundlesResult> {
return this.getDownloadedBundles();
}

public async getChannel(): Promise<GetChannelResult> {
return this.engine.getChannel();
}

public async getConfig(): Promise<GetConfigResult> {
return {
appId: this.config.appId ?? null,
autoUpdateStrategy: this.config.autoUpdateStrategy ?? 'none',
};
}

public async getCurrentBundle(): Promise<GetCurrentBundleResult> {
return this.engine.getCurrentBundle();
}

public async getCustomId(): Promise<GetCustomIdResult> {
return this.engine.getCustomId();
}

public async getDeviceId(): Promise<GetDeviceIdResult> {
return this.engine.getDeviceId();
}

public async getDownloadedBundles(): Promise<GetDownloadedBundlesResult> {
return this.engine.getDownloadedBundles();
}

public async getNextBundle(): Promise<GetNextBundleResult> {
return this.engine.getNextBundle();
}

public async getVersionCode(): Promise<GetVersionCodeResult> {
return this.engine.getVersionCode();
}

public async getVersionName(): Promise<GetVersionNameResult> {
return this.engine.getVersionName();
}

public async initialize(): Promise<void> {
await this.engine.initialize();
const bundlePath = this.engine.getCurrentBundlePath();
if (
bundlePath !== null ||
this.context.services.bundles.getActiveBundlePath() !== null
) {
await this.context.services.bundles.setActiveBundle(bundlePath, {
bootWatchdog: false,
});
}
if (this.config.autoUpdateStrategy === 'background') {
this.setUpBackgroundAutoUpdate();
}
}

public async isSyncing(): Promise<IsSyncingResult> {
return this.engine.isSyncing();
}

public async ready(): Promise<ReadyResult> {
return this.engine.ready();
}

public async reload(): Promise<void> {
await this.engine.applyNextBundle();
await this.applyCurrentBundle();
this.context.notifyListeners('reloaded');
}

public async reset(): Promise<void> {
return this.engine.reset();
}

public async resetConfig(): Promise<void> {
this.throwUnimplementedError();
}

public async setChannel(options: SetChannelOptions): Promise<void> {
return this.engine.setChannel(options);
}

public async setConfig(_options: SetConfigOptions): Promise<void> {
this.throwUnimplementedError();
}

public async setCustomId(options: SetCustomIdOptions): Promise<void> {
return this.engine.setCustomId(options);
}

public async setNextBundle(options: SetNextBundleOptions): Promise<void> {
return this.engine.setNextBundle(options);
}

public async sync(options?: SyncOptions): Promise<SyncResult> {
return this.engine.sync(options);
}

private async applyCurrentBundle(): Promise<void> {
await this.context.services.bundles.setActiveBundle(
this.engine.getCurrentBundlePath(),
{ bootWatchdog: false },
);
}

private setUpBackgroundAutoUpdate(): void {
const check = (): void => {
const now = Date.now();
if (now - this.lastAutoUpdateCheck < AUTO_UPDATE_MIN_INTERVAL) {
return;
}
this.lastAutoUpdateCheck = now;
void this.sync().catch(error =>
console.warn(`[LiveUpdate] Background sync failed: ${error}`),
);
};
void app.whenReady().then(() => {
check();
app.on('browser-window-focus', check);
powerMonitor.on('resume', check);
});
}

private throwUnimplementedError(): never {
throw new CapacitorException(
LiveUpdateElectron.errorNotImplemented,
ExceptionCode.Unimplemented,
);
}
}
1 change: 1 addition & 0 deletions packages/live-update/electron/src/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const PLUGIN_VERSION = '8.3.0';
34 changes: 34 additions & 0 deletions packages/live-update/electron/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"allowUnreachableCode": false,
"baseUrl": ".",
"esModuleInterop": true,
"lib": ["es2022"],
"module": "esnext",
"moduleResolution": "bundler",
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"outDir": "dist/esm",
"paths": {
// Type-check against the local engine repository until
// `@capawesome/electron-live-update` is published. TypeScript falls
// back to `node_modules` resolution when the mapped file is missing.
"@capawesome/electron-live-update/engine": [
"../../../../electron-live-update/dist/engine/index.d.ts"
]
},
Comment thread
robingenz marked this conversation as resolved.
"pretty": true,
"rootDir": "..",
"skipLibCheck": true,
"strict": true,
"target": "es2022",
"types": []
},
"include": [
"src/**/*.ts",
// Ambient Electron types from the local engine repository until
// `electron` is installed as a dev dependency.
"../../../../electron-live-update/node_modules/electron/electron.d.ts"
]
Comment thread
robingenz marked this conversation as resolved.
Outdated
}
Loading
Loading