Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
---

feat: add support for the Electron platform via [`@capawesome/capacitor-electron`](https://github.com/capawesome-team/capacitor-electron)
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ jobs:
id: changesets
uses: changesets/action@v1
with:
version: npm run version
publish: npm run release
commit: "chore(release): publish"
title: "chore(release): publish"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"fmt": "turbo run fmt",
"docgen": "turbo run docgen",
"build": "turbo run build",
"version": "turbo run version",
"version": "changeset version && turbo run version",
"ios:pod:install": "turbo run ios:pod:install --concurrency=1 --no-cache",
"ios:spm:install": "turbo run ios:spm:install --concurrency=1 --no-cache",
"affected:verify": "npm run affected:verify:android && npm run affected:verify:ios && npm run affected:verify:web",
Expand Down
135 changes: 80 additions & 55 deletions packages/live-update/README.md

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions packages/live-update/electron/rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export default {
input: 'electron/dist/esm/electron/src/index.js',
output: {
file: 'electron/dist/plugin.mjs',
format: 'esm',
},
external: [
'electron',
'@capacitor/core',
'@capawesome/capacitor-electron/plugin',
'@capawesome/electron-live-update/engine',
/^node:/,
],
};
290 changes: 290 additions & 0 deletions packages/live-update/electron/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
import type { PluginsConfig } from '@capacitor/cli';
import { CapacitorException, ExceptionCode } from '@capacitor/core';
import { ElectronPlugin } from '@capawesome/capacitor-electron/plugin';
import type { ElectronPluginContext } from '@capawesome/capacitor-electron/plugin';
import { LiveUpdateEngine } from '@capawesome/electron-live-update/engine';
import { app, powerMonitor } from 'electron';
import { createRequire } from 'node:module';
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';

const AUTO_UPDATE_MIN_INTERVAL = 15 * 60 * 1000;
const CAPACITOR_RUNTIME = 'capacitor';
const ELECTRON_PLATFORM = '2';
// Resolve the plugin version from the package's own `package.json`. The built
// file lives at `electron/dist/plugin.mjs`, so the package root is two levels up.
const PLUGIN_VERSION: string = createRequire(import.meta.url)(
'../../package.json',
).version;
Comment thread
robingenz marked this conversation as resolved.

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

export class LiveUpdateElectron
extends ElectronPlugin
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 engine: LiveUpdateEngine;
private lastAutoUpdateCheck = 0;

constructor(context: ElectronPluginContext) {
super(context);
this.config =
(context.config.plugins as PluginsConfig | undefined)?.LiveUpdate ?? {};
this.engine = new LiveUpdateEngine({
appId: this.config.appId,
autoBlockRolledBackBundles: this.config.autoBlockRolledBackBundles,
autoDeleteBundles: this.config.autoDeleteBundles,
dataDirectory: join(app.getPath('userData'), 'capawesome-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 load(): 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,
);
}
}
38 changes: 38 additions & 0 deletions packages/live-update/electron/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"compilerOptions": {
"allowUnreachableCode": false,
"baseUrl": ".",
"esModuleInterop": true,
"lib": ["es2022"],
"module": "esnext",
"moduleResolution": "bundler",
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"outDir": "dist/esm",
"paths": {
// TypeScript tries each candidate in order and uses the first one
// that exists on disk. Once `@capawesome/electron-live-update` is
// published, the installed package (package-level or hoisted to the
// workspace root `node_modules`) is used; the sibling repository
// checkout is only a fallback for local development before then.
"@capawesome/electron-live-update/engine": [
"../node_modules/@capawesome/electron-live-update/dist/engine/index.d.ts",
"../../../node_modules/@capawesome/electron-live-update/dist/engine/index.d.ts",
"../../../../electron-live-update/dist/engine/index.d.ts"
],
"@capawesome/capacitor-electron/plugin": [
"../node_modules/@capawesome/capacitor-electron/dist/types/plugin/index.d.ts",
"../../../node_modules/@capawesome/capacitor-electron/dist/types/plugin/index.d.ts",
"../../../../capacitor-electron/dist/types/plugin/index.d.ts"
]
},
Comment thread
robingenz marked this conversation as resolved.
"pretty": true,
"rootDir": "..",
"skipLibCheck": true,
"strict": true,
"target": "es2022",
"types": []
},
"include": ["src/**/*.ts"]
}
6 changes: 6 additions & 0 deletions packages/live-update/example/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,9 @@ dist/

*.pem
*.crt

# Electron e2e (temporary local scaffolding)
.local-tarballs/
test/electron/.fixtures/
test/electron/.userdata/
test/electron/test-results/
6 changes: 6 additions & 0 deletions packages/live-update/example/electron/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules
build
dist
app
generated
vendor
9 changes: 9 additions & 0 deletions packages/live-update/example/electron/assets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Build resources

Place your app icons here for packaging with electron-builder:

- `icon.icns` — macOS (or a 1024x1024 `icon.png`, converted automatically)
- `icon.ico` — Windows
- `icon.png` — Linux (512x512 or larger)

See https://www.electron.build/configuration/icons for details.
Loading
Loading