diff --git a/.changeset/live-update-electron-platform.md b/.changeset/live-update-electron-platform.md new file mode 100644 index 000000000..bcfeef46b --- /dev/null +++ b/.changeset/live-update-electron-platform.md @@ -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) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5bb51770..c82901768 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" diff --git a/package.json b/package.json index e919be2e8..8e7a6f1e6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/live-update/README.md b/packages/live-update/README.md index ac1c1675a..fe76b0a88 100644 --- a/packages/live-update/README.md +++ b/packages/live-update/README.md @@ -12,7 +12,7 @@ Capacitor plugin that allows you to update your app remotely in real-time withou The Capacitor Live Update plugin is one of the most feature-rich Over-the-Air (OTA) update solutions for Capacitor apps. Here are some of the key features: -- 🔋 Supports **Android and iOS** +- 🔋 Supports **Android, iOS and Electron** - ⚡️ **Capacitor 6/7/8** support - 📦 **Bundle Management**: Download, set, and delete bundles. - ☁️ **Cloud Support**: Use the [Capawesome Cloud](https://cloud.capawesome.io/) to manage your app updates. @@ -145,22 +145,51 @@ Add the `NSPrivacyAccessedAPICategoryUserDefaults` dictionary key to your [Priva We recommend to declare [`CA92.1`](https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api#4278401) as the reason for accessing the [`UserDefaults`](https://developer.apple.com/documentation/foundation/userdefaults) API. +### Electron + +This plugin supports the [Capacitor Electron platform](https://github.com/capawesome-team/capacitor-electron) (`@capawesome/capacitor-electron`). The Electron implementation of the plugin is loaded automatically during `npx cap sync`, so no additional configuration is required. + +A Capacitor Electron app uses the **same Capawesome Cloud app** (and therefore the same `appId`) as the Android and iOS targets of your app. Bundles can be delivered to desktop devices using [Electron version constraints](https://capawesome.io/docs/cloud/live-updates/). + +On Electron, the app version (returned by `getVersionCode()`/`getVersionName()` and matched against Electron version constraints) is the `version` from your app's `electron/package.json` file (see [`app.getVersion()`](https://www.electronjs.org/docs/latest/api/app#appgetversion)). It refers to the installed desktop binary and never changes through a live update — the desktop equivalent of the native app version on Android and iOS. Make sure to maintain this version (the scaffolded default is `0.0.0`), otherwise version constraints cannot distinguish your desktop releases. + +To use [versioned channels](https://capawesome.io/docs/cloud/live-updates/setup/#make-updates-version-compatible) on Electron, set the channel in `electron/capacitor.electron.config.ts` — the Electron equivalent of the Android string resource and iOS `Info.plist` configuration. Since the file is TypeScript, the channel can be derived from the app version: + +```ts +import { defineConfig } from '@capawesome/capacitor-electron/config'; +import packageJson from './package.json'; + +export default defineConfig({ + plugins: { + LiveUpdate: { + defaultChannel: `production-${packageJson.version}`, + }, + }, +}); +``` + +This configuration takes precedence over the `defaultChannel` plugin property in the Capacitor configuration, mirroring the channel resolution order on Android and iOS. + +It is strongly **recommended** to configure the `readyTimeout` option (e.g. `10000` ms) so that the plugin can roll back to the last working bundle in case of problems. The rollback on Electron is kill-safe: if the app is closed or crashes before `ready()` is called, the rollback is performed on the next app start. + +The following methods are not available on Electron: `setConfig(...)` and `resetConfig()`. + ## Configuration -| Prop | Type | Description | Default | Since | -| -------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ----- | -| **`appId`** | string | The app ID is used to identify the app when using [Capawesome Cloud](https://capawesome.io/cloud/). This is **NOT** the same as the app identifier (e.g. `com.example.app`). This is a unique identifier generated by Capawesome Cloud (e.g. `6e351b4f-69a7-415e-a057-4567df7ffe94`). | | 5.0.0 | -| **`autoBlockRolledBackBundles`** | boolean | Whether or not to automatically block bundles that have been rolled back. When enabled, the plugin will automatically block bundles that caused a rollback (up to 100 bundles). When the limit is reached, the oldest blocked bundle is unblocked. Blocked bundles will be skipped in future sync operations. **Attention**: This option has no effect if `readyTimeout` is set to `0`. Only available on Android and iOS. | false | 7.3.0 | -| **`autoDeleteBundles`** | boolean | Whether or not to automatically delete unused bundles. When enabled, the plugin will automatically delete unused bundles after calling `ready()`. | false | 5.0.0 | -| **`autoUpdateStrategy`** | 'none' \| 'background' | The auto-update strategy for live updates. - `none`: Live updates will not be applied automatically. - `background`: Live updates will be automatically downloaded and applied in the background at app startup and when the app resumes (if the last check was more than 15 minutes ago). Only available on Android and iOS. | 'none' | 7.3.0 | -| **`defaultChannel`** | string | The default channel of the app. This can be overridden by `setChannel()`, the `channel` parameter of `sync()`, or the native channel configuration (`CapawesomeLiveUpdateDefaultChannel` in `Info.plist` on iOS or `capawesome_live_update_default_channel` in `strings.xml` on Android). | | 6.3.0 | -| **`httpTimeout`** | number | The timeout in milliseconds for HTTP requests. | 60000 | 6.4.0 | -| **`publicKey`** | string | The public key to verify the integrity of the bundle. The public key must be a PEM-encoded RSA public key. | | 6.1.0 | -| **`readyTimeout`** | number | The timeout in milliseconds to wait for the app to be ready before resetting to the default bundle. It is strongly **recommended** to configure this option (e.g. `10000` ms) so that the plugin can roll back to the default bundle in case of problems. If configured, the plugin will wait for the app to call the `ready()` method before resetting to the default bundle. Set to `0` to disable the timeout. | 0 | 5.0.0 | -| **`serverDomain`** | string | The API domain of the [Capawesome Cloud](https://cloud.capawesome.io) server **without** scheme or path. | 'api.cloud.capawesome.io' | 7.0.0 | +| Prop | Type | Description | Default | Since | +| -------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- | ----- | +| **`appId`** | string | The app ID is used to identify the app when using [Capawesome Cloud](https://capawesome.io/cloud/). This is **NOT** the same as the app identifier (e.g. `com.example.app`). This is a unique identifier generated by Capawesome Cloud (e.g. `6e351b4f-69a7-415e-a057-4567df7ffe94`). | | 5.0.0 | +| **`autoBlockRolledBackBundles`** | boolean | Whether or not to automatically block bundles that have been rolled back. When enabled, the plugin will automatically block bundles that caused a rollback (up to 100 bundles). When the limit is reached, the oldest blocked bundle is unblocked. Blocked bundles will be skipped in future sync operations. **Attention**: This option has no effect if `readyTimeout` is set to `0`. Only available on Android, iOS and Electron. | false | 7.3.0 | +| **`autoDeleteBundles`** | boolean | Whether or not to automatically delete unused bundles. When enabled, the plugin will automatically delete unused bundles after calling `ready()`. | false | 5.0.0 | +| **`autoUpdateStrategy`** | 'none' \| 'background' | The auto-update strategy for live updates. - `none`: Live updates will not be applied automatically. - `background`: Live updates will be automatically downloaded and applied in the background at app startup and when the app resumes (if the last check was more than 15 minutes ago). Only available on Android, iOS and Electron. | 'none' | 7.3.0 | +| **`defaultChannel`** | string | The default channel of the app. This can be overridden by `setChannel()`, the `channel` parameter of `sync()`, or the native channel configuration (`CapawesomeLiveUpdateDefaultChannel` in `Info.plist` on iOS or `capawesome_live_update_default_channel` in `strings.xml` on Android). | | 6.3.0 | +| **`httpTimeout`** | number | The timeout in milliseconds for HTTP requests. | 60000 | 6.4.0 | +| **`publicKey`** | string | The public key to verify the integrity of the bundle. The public key must be a PEM-encoded RSA public key. | | 6.1.0 | +| **`readyTimeout`** | number | The timeout in milliseconds to wait for the app to be ready before resetting to the default bundle. It is strongly **recommended** to configure this option (e.g. `10000` ms) so that the plugin can roll back to the default bundle in case of problems. If configured, the plugin will wait for the app to call the `ready()` method before resetting to the default bundle. Set to `0` to disable the timeout. | 0 | 5.0.0 | +| **`serverDomain`** | string | The API domain of the [Capawesome Cloud](https://cloud.capawesome.io) server **without** scheme or path. | 'api.cloud.capawesome.io' | 7.0.0 | ### Examples @@ -216,8 +245,8 @@ export default config; A working example can be found here: [capawesome-team/capacitor-live-update-demo](https://github.com/capawesome-team/capacitor-live-update-demo) -| Android | iOS | -| --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| Android | iOS | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Android Demo | iOS Demo | ## Usage @@ -249,10 +278,14 @@ import { LiveUpdate } from '@capawesome/capacitor-live-update'; const ready = async () => { const result = await LiveUpdate.ready(); if (result.currentBundleId) { - console.log(`The app is now using the bundle with the identifier ${result.currentBundleId}.`); + console.log( + `The app is now using the bundle with the identifier ${result.currentBundleId}.`, + ); } if (result.previousBundleId) { - console.log(`The app was using the bundle with the identifier ${result.previousBundleId}.`); + console.log( + `The app was using the bundle with the identifier ${result.previousBundleId}.`, + ); } if (result.rollback) { console.log('The app was reset to the default bundle.'); @@ -292,7 +325,10 @@ Download a self-hosted bundle from any URL, without any Capawesome Cloud depende import { LiveUpdate } from '@capawesome/capacitor-live-update'; const downloadBundle = async () => { - await LiveUpdate.downloadBundle({ url: 'https://example.com/1.0.0.zip', bundleId: '1.0.0' }); + await LiveUpdate.downloadBundle({ + url: 'https://example.com/1.0.0.zip', + bundleId: '1.0.0', + }); }; ``` @@ -304,7 +340,9 @@ Set the next bundle to use for the app. Call `reload()` or restart the app to ap import { LiveUpdate } from '@capawesome/capacitor-live-update'; const setNextBundle = async () => { - await LiveUpdate.setNextBundle({ bundleId: '7f0b9bf2-dff6-4be2-bcac-b068cc5ea756' }); + await LiveUpdate.setNextBundle({ + bundleId: '7f0b9bf2-dff6-4be2-bcac-b068cc5ea756', + }); }; const reload = async () => { @@ -474,7 +512,7 @@ Clear all blocked bundles from the blocked list. This removes all bundle identifiers that were automatically blocked due to rollbacks when `autoBlockRolledBackBundles` is enabled. -Only available on Android and iOS. +Only available on Android, iOS and Electron. **Since:** 7.4.0 @@ -489,7 +527,7 @@ deleteBundle(options: DeleteBundleOptions) => Promise Delete a bundle from the app. -Only available on Android and iOS. +Only available on Android, iOS and Electron. | Param | Type | | ------------- | ------------------------------------------------------------------- | @@ -508,7 +546,7 @@ downloadBundle(options: DownloadBundleOptions) => Promise Download a bundle. -Only available on Android and iOS. +Only available on Android, iOS and Electron. | Param | Type | | ------------- | ----------------------------------------------------------------------- | @@ -535,7 +573,7 @@ dynamically switch between them using `setChannel(...)`. If channels are private, they can still be set using `setChannel(...)` but won't be returned by this method. -Only available on Android and iOS. +Only available on Android, iOS and Electron. | Param | Type | | ------------- | --------------------------------------------------------------------- | @@ -556,7 +594,7 @@ fetchLatestBundle(options?: FetchLatestBundleOptions | undefined) => PromisePromise<GetBlockedBundlesResult> @@ -597,7 +635,7 @@ getBundles() => Promise Get all identifiers of bundles that have been downloaded. -Only available on Android and iOS. +Only available on Android, iOS and Electron. **Returns:** Promise<GetBundlesResult> @@ -620,10 +658,12 @@ The channel is resolved in the following order (highest priority first): `capawesome_live_update_default_channel` in `strings.xml` on Android) 3. Capacitor config `defaultChannel` +On **Electron**, the native config (2) is not available. + **Note**: The `channel` parameter of `sync()` takes the highest priority but is not persisted and therefore not returned by this method. -Only available on Android and iOS. +Only available on Android, iOS and Electron. **Returns:** Promise<GetChannelResult> @@ -643,7 +683,7 @@ Get the runtime configuration. Returns the current plugin configuration including any runtime overrides set via `setConfig()`. -Only available on Android and iOS. +Only available on Android, iOS and Electron. **Returns:** Promise<GetConfigResult> @@ -660,7 +700,7 @@ getDownloadedBundles() => Promise Get all identifiers of bundles that have been downloaded. -Only available on Android and iOS. +Only available on Android, iOS and Electron. **Returns:** Promise<GetDownloadedBundlesResult> @@ -678,7 +718,7 @@ getCurrentBundle() => Promise Get the bundle identifier of the current bundle. The current bundle is the bundle that is currently used by the app. -Only available on Android and iOS. +Only available on Android, iOS and Electron. **Returns:** Promise<GetCurrentBundleResult> @@ -695,7 +735,7 @@ getCustomId() => Promise Get the custom identifier of the device. -Only available on Android and iOS. +Only available on Android, iOS and Electron. **Returns:** Promise<GetCustomIdResult> @@ -712,7 +752,7 @@ getDeviceId() => Promise Get the unique device identifier. -Only available on Android and iOS. +Only available on Android, iOS and Electron. **Returns:** Promise<GetDeviceIdResult> @@ -729,7 +769,7 @@ isSyncing() => Promise Check whether a sync operation is currently in progress. -Only available on Android and iOS. +Only available on Android, iOS and Electron. **Returns:** Promise<IsSyncingResult> @@ -748,7 +788,7 @@ Get the bundle identifier of the next bundle. The next bundle is the bundle that will be used after calling `reload()` or restarting the app. -Only available on Android and iOS. +Only available on Android, iOS and Electron. **Returns:** Promise<GetNextBundleResult> @@ -767,8 +807,9 @@ Get the version code of the app. On **Android**, this is the `versionCode` from the `android/app/build.gradle` file. On **iOS**, this is the `CFBundleVersion` from the `Info.plist` file. +On **Electron**, this is the `version` from the app's `package.json` file. -Only available on Android and iOS. +Only available on Android, iOS and Electron. **Returns:** Promise<GetVersionCodeResult> @@ -787,8 +828,9 @@ Get the version name of the app. On **Android**, this is the `versionName` from the `android/app/build.gradle` file. On **iOS**, this is the `CFBundleShortVersionString` from the `Info.plist` file. +On **Electron**, this is the `version` from the app's `package.json` file. -Only available on Android and iOS. +Only available on Android, iOS and Electron. **Returns:** Promise<GetVersionNameResult> @@ -808,7 +850,7 @@ Notify the plugin that the app is ready to use and no rollback is needed. **Attention**: This method should be called as soon as the app is ready to use to prevent the app from being reset to the default bundle. -Only available on Android and iOS. +Only available on Android, iOS and Electron. **Returns:** Promise<ReadyResult> @@ -825,7 +867,7 @@ reload() => Promise Reload the app to apply the new bundle. -Only available on Android and iOS. +Only available on Android, iOS and Electron. **Since:** 5.0.0 @@ -842,7 +884,7 @@ Reset the app to the default bundle. Call `reload()` or restart the app to apply the changes. -Only available on Android and iOS. +Only available on Android, iOS and Electron. **Since:** 5.0.0 @@ -875,7 +917,7 @@ setChannel(options: SetChannelOptions) => Promise Set the channel to use for the update. -Only available on Android and iOS. +Only available on Android, iOS and Electron. | Param | Type | | ------------- | --------------------------------------------------------------- | @@ -920,7 +962,7 @@ setCustomId(options: SetCustomIdOptions) => Promise Set the custom identifier of the device. -Only available on Android and iOS. +Only available on Android, iOS and Electron. | Param | Type | | ------------- | ----------------------------------------------------------------- | @@ -941,7 +983,7 @@ Set the next bundle to use for the app. Call `reload()` or restart the app to apply the changes. -Only available on Android and iOS. +Only available on Android, iOS and Electron. | Param | Type | | ------------- | --------------------------------------------------------------------- | @@ -962,7 +1004,7 @@ Automatically download and set the latest bundle for the app using the [Capaweso Call `reload()` or restart the app to apply the changes. -Only available on Android and iOS. +Only available on Android, iOS and Electron. | Param | Type | | ------------- | --------------------------------------------------- | @@ -983,7 +1025,7 @@ addListener(eventName: 'downloadBundleProgress', listenerFunc: DownloadBundlePro Listen for the download progress of a bundle. -Only available on Android and iOS. +Only available on Android, iOS and Electron. | Param | Type | | ------------------ | ----------------------------------------------------------------------------------------- | @@ -1008,7 +1050,7 @@ Listen for when a bundle is set as the next bundle. This event is triggered whenever a bundle is set to be used on the next app restart, either through automatic updates or manual calls to `setNextBundle()`. -Only available on Android and iOS. +Only available on Android, iOS and Electron. | Param | Type | | ------------------ | ----------------------------------------------------------------------- | @@ -1037,7 +1079,7 @@ and the app has been reloaded. use the `ready()` method instead. The `ready()` method provides detailed information about the current bundle, previous bundle, and whether a rollback occurred. -Only available on Android and iOS. +Only available on Android, iOS and Electron. | Param | Type | | ------------------ | ------------------------------------------------------------- | @@ -1078,7 +1120,7 @@ Remove all listeners for this plugin. | Prop | Type | Description | Default | Since | | ------------------ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------ | ----- | -| **`artifactType`** | 'manifest' \| 'zip' | The artifact type of the bundle. | 'zip' | 6.6.0 | +| **`artifactType`** | 'manifest' \| 'zip' | The artifact type of the bundle. **Attention**: The `manifest` artifact type is not supported on Electron. Only the `zip` artifact type is supported there. | 'zip' | 6.6.0 | | **`bundleId`** | string | The unique identifier of the bundle. **Attention**: The value `public` is reserved and cannot be used as a bundle identifier. | | 5.0.0 | | **`checksum`** | string | The checksum of the self-hosted bundle as a SHA-256 hash in hex format to verify the integrity of the bundle. **Attention**: Only supported for the `zip` artifact type. | | 7.1.0 | | **`signature`** | string | The signature of the self-hosted bundle as a signed SHA-256 hash in base64 format to verify the integrity of the bundle. **Attention**: Only supported for the `zip` artifact type. | | 7.1.0 | @@ -1113,7 +1155,7 @@ Remove all listeners for this plugin. | Prop | Type | Description | Since | | ---------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | -| **`artifactType`** | 'manifest' \| 'zip' | The artifact type of the bundle. | 6.7.0 | +| **`artifactType`** | 'manifest' \| 'zip' | The artifact type of the bundle. **Attention**: The `manifest` artifact type is not supported on Electron. Only the `zip` artifact type is supported there. | 6.7.0 | | **`bundleId`** | string \| null | The unique identifier of the latest bundle. On Capawesome Cloud, this is the ID of the app build artifact. If `null`, no bundle is available. | 6.6.0 | | **`channel`** | string | The name of the channel that the bundle is actually from. This is the resolved channel after applying any forced channel assignment and may differ from the channel set by the Live Update SDK. | 8.3.0 | | **`checksum`** | string | The checksum of the latest bundle if the bundle is self-hosted. If the bundle is hosted on Capawesome Cloud, the checksum will be returned as response header when downloading the bundle. | 7.1.0 | @@ -1181,9 +1223,9 @@ Remove all listeners for this plugin. #### GetDeviceIdResult -| Prop | Type | Description | Since | -| -------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | -| **`deviceId`** | string | The unique identifier of the device. On iOS, [`identifierForVendor`](https://developer.apple.com/documentation/uikit/uidevice/1620059-identifierforvendor) is used. The value of this property is the same for apps that come from the same vendor running on the same device. | 5.0.0 | +| Prop | Type | Description | Since | +| -------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| **`deviceId`** | string | The unique identifier of the device. On iOS, [`identifierForVendor`](https://developer.apple.com/documentation/uikit/uidevice/1620059-identifierforvendor) is used. The value of this property is the same for apps that come from the same vendor running on the same device. On Electron, a random UUID is generated on first use and persisted on the device. | 5.0.0 | #### IsSyncingResult @@ -1202,16 +1244,16 @@ Remove all listeners for this plugin. #### GetVersionCodeResult -| Prop | Type | Description | Since | -| ----------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | -| **`versionCode`** | string | The version code of the app. On **Android**, this is the `versionCode` from the `android/app/build.gradle` file. On **iOS**, this is the `CFBundleVersion` from the `Info.plist` file. | 5.0.0 | +| Prop | Type | Description | Since | +| ----------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| **`versionCode`** | string | The version code of the app. On **Android**, this is the `versionCode` from the `android/app/build.gradle` file. On **iOS**, this is the `CFBundleVersion` from the `Info.plist` file. On **Electron**, this is the `version` from the app's `package.json` file. | 5.0.0 | #### GetVersionNameResult -| Prop | Type | Description | Since | -| ----------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | -| **`versionName`** | string | The version name of the app. On **Android**, this is the `versionName` from the `android/app/build.gradle` file. On **iOS**, this is the `CFBundleShortVersionString` from the `Info.plist` file. | 5.0.0 | +| Prop | Type | Description | Since | +| ----------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| **`versionName`** | string | The version name of the app. On **Android**, this is the `versionName` from the `android/app/build.gradle` file. On **iOS**, this is the `CFBundleShortVersionString` from the `Info.plist` file. On **Electron**, this is the `version` from the app's `package.json` file. | 5.0.0 | #### ReadyResult @@ -1370,7 +1412,7 @@ Additionally, Capawesome Cloud supports [forced channel assignments](https://cap ### Why can't I see my changes during development? -As soon as you have installed a live update, the app will use the live update bundle and no longer the default bundle. +As soon as you have installed a live update, the app will use the live update bundle and no longer the default bundle. So if you make local changes to your app and execute `npx cap run`, for example, these changes will apply to the default bundle, which is not currently in use. You then have three options to get back to the default bundle: diff --git a/packages/live-update/electron/rollup.config.mjs b/packages/live-update/electron/rollup.config.mjs new file mode 100644 index 000000000..8df83e4de --- /dev/null +++ b/packages/live-update/electron/rollup.config.mjs @@ -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:/, + ], +}; diff --git a/packages/live-update/electron/src/index.ts b/packages/live-update/electron/src/index.ts new file mode 100644 index 000000000..4110023ed --- /dev/null +++ b/packages/live-update/electron/src/index.ts @@ -0,0 +1,289 @@ +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; + +type LiveUpdateConfig = NonNullable; + +export class LiveUpdateElectron + extends ElectronPlugin + implements Omit +{ + 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'), + 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 { + return this.engine.clearBlockedBundles(); + } + + public async deleteBundle(options: DeleteBundleOptions): Promise { + return this.engine.deleteBundle(options); + } + + public async downloadBundle(options: DownloadBundleOptions): Promise { + return this.engine.downloadBundle(options); + } + + public async fetchChannels( + options?: FetchChannelsOptions, + ): Promise { + return this.engine.fetchChannels(options); + } + + public async fetchLatestBundle( + options?: FetchLatestBundleOptions, + ): Promise { + return this.engine.fetchLatestBundle(options); + } + + public async getBlockedBundles(): Promise { + return this.engine.getBlockedBundles(); + } + + public async getBundles(): Promise { + return this.getDownloadedBundles(); + } + + public async getChannel(): Promise { + return this.engine.getChannel(); + } + + public async getConfig(): Promise { + return { + appId: this.config.appId ?? null, + autoUpdateStrategy: this.config.autoUpdateStrategy ?? 'none', + }; + } + + public async getCurrentBundle(): Promise { + return this.engine.getCurrentBundle(); + } + + public async getCustomId(): Promise { + return this.engine.getCustomId(); + } + + public async getDeviceId(): Promise { + return this.engine.getDeviceId(); + } + + public async getDownloadedBundles(): Promise { + return this.engine.getDownloadedBundles(); + } + + public async getNextBundle(): Promise { + return this.engine.getNextBundle(); + } + + public async getVersionCode(): Promise { + return this.engine.getVersionCode(); + } + + public async getVersionName(): Promise { + return this.engine.getVersionName(); + } + + public async load(): Promise { + 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 { + return this.engine.isSyncing(); + } + + public async ready(): Promise { + return this.engine.ready(); + } + + public async reload(): Promise { + await this.engine.applyNextBundle(); + await this.applyCurrentBundle(); + this.context.notifyListeners('reloaded'); + } + + public async reset(): Promise { + return this.engine.reset(); + } + + public async resetConfig(): Promise { + this.throwUnimplementedError(); + } + + public async setChannel(options: SetChannelOptions): Promise { + return this.engine.setChannel(options); + } + + public async setConfig(_options: SetConfigOptions): Promise { + this.throwUnimplementedError(); + } + + public async setCustomId(options: SetCustomIdOptions): Promise { + return this.engine.setCustomId(options); + } + + public async setNextBundle(options: SetNextBundleOptions): Promise { + return this.engine.setNextBundle(options); + } + + public async sync(options?: SyncOptions): Promise { + return this.engine.sync(options); + } + + private async applyCurrentBundle(): Promise { + 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, + ); + } +} diff --git a/packages/live-update/electron/tsconfig.json b/packages/live-update/electron/tsconfig.json new file mode 100644 index 000000000..fe99c28c5 --- /dev/null +++ b/packages/live-update/electron/tsconfig.json @@ -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" + ] + }, + "pretty": true, + "rootDir": "..", + "skipLibCheck": true, + "strict": true, + "target": "es2022", + "types": [] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/live-update/example/.gitignore b/packages/live-update/example/.gitignore index d8f853b77..f61878d33 100644 --- a/packages/live-update/example/.gitignore +++ b/packages/live-update/example/.gitignore @@ -8,3 +8,9 @@ dist/ *.pem *.crt + +# Electron e2e (temporary local scaffolding) +.local-tarballs/ +test/electron/.fixtures/ +test/electron/.userdata/ +test/electron/test-results/ diff --git a/packages/live-update/example/electron/.gitignore b/packages/live-update/example/electron/.gitignore new file mode 100644 index 000000000..34494b8f5 --- /dev/null +++ b/packages/live-update/example/electron/.gitignore @@ -0,0 +1,6 @@ +node_modules +build +dist +app +generated +vendor diff --git a/packages/live-update/example/electron/assets/README.md b/packages/live-update/example/electron/assets/README.md new file mode 100644 index 000000000..3cc934f96 --- /dev/null +++ b/packages/live-update/example/electron/assets/README.md @@ -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. diff --git a/packages/live-update/example/electron/capacitor.electron.config.ts b/packages/live-update/example/electron/capacitor.electron.config.ts new file mode 100644 index 000000000..00b76143d --- /dev/null +++ b/packages/live-update/example/electron/capacitor.electron.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from '@capawesome/capacitor-electron/config'; + +export default defineConfig({ + window: { + width: 1200, + height: 800, + }, +}); diff --git a/packages/live-update/example/electron/electron-builder.config.js b/packages/live-update/example/electron/electron-builder.config.js new file mode 100644 index 000000000..d7dfd983f --- /dev/null +++ b/packages/live-update/example/electron/electron-builder.config.js @@ -0,0 +1,17 @@ +/** @type {import('electron-builder').Configuration} */ +module.exports = { + appId: 'com.example.plugin', + productName: 'example', + directories: { + output: 'dist', + buildResources: 'assets', + }, + files: [ + 'build/**/*', + 'app/**/*', + 'generated/**/*', + 'package.json', + // Platform runtime + plugins, prepared by `capacitor-electron vendor`. + { from: 'vendor/node_modules', to: 'node_modules' }, + ], +}; diff --git a/packages/live-update/example/electron/main.ts b/packages/live-update/example/electron/main.ts new file mode 100644 index 000000000..c1d542ec0 --- /dev/null +++ b/packages/live-update/example/electron/main.ts @@ -0,0 +1,5 @@ +import { createCapacitorElectronApp } from '@capawesome/capacitor-electron'; + +import config from './capacitor.electron.config'; + +createCapacitorElectronApp(config); diff --git a/packages/live-update/example/electron/package.json b/packages/live-update/example/electron/package.json new file mode 100644 index 000000000..9e1b6155c --- /dev/null +++ b/packages/live-update/example/electron/package.json @@ -0,0 +1,17 @@ +{ + "name": "example-electron", + "private": true, + "version": "0.0.0", + "description": "Electron platform project for example.", + "main": "build/main.js", + "scripts": { + "build": "tsc", + "start": "electron .", + "pack": "npm run build && capacitor-electron vendor && electron-builder --config electron-builder.config.js" + }, + "devDependencies": { + "electron": "^43.0.0", + "electron-builder": "^26.0.12", + "typescript": "^5.5.0" + } +} diff --git a/packages/live-update/example/electron/tsconfig.json b/packages/live-update/example/electron/tsconfig.json new file mode 100644 index 000000000..b765c82d4 --- /dev/null +++ b/packages/live-update/example/electron/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "node16", + "moduleResolution": "node16", + "outDir": "build", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["main.ts", "capacitor.electron.config.ts"] +} diff --git a/packages/live-update/example/package.json b/packages/live-update/example/package.json index bf690b604..8a58fb577 100644 --- a/packages/live-update/example/package.json +++ b/packages/live-update/example/package.json @@ -12,7 +12,9 @@ "build": "vite build", "preview": "vite preview", "e2e:android": "adb reverse tcp:4000 tcp:4000 && concurrently -k -s first \"node test/mock-server.mjs\" \"node test/run-maestro-flows.mjs\"", - "e2e:ios": "concurrently -k -s first \"node test/mock-server.mjs\" \"node test/run-maestro-flows.mjs\"" + "e2e:ios": "concurrently -k -s first \"node test/mock-server.mjs\" \"node test/run-maestro-flows.mjs\"", + "e2e:electron": "npm run e2e:electron:prepare && playwright test -c test/electron/playwright.config.mjs", + "e2e:electron:prepare": "npm run build && cap sync @capawesome/capacitor-electron && tsc -p electron/tsconfig.json && node test/electron/support/build-fixtures.mjs" }, "dependencies": { "@capacitor/android": "^8.0.0", @@ -22,8 +24,12 @@ }, "devDependencies": { "@capacitor/cli": "^8.0.0", + "@capawesome/capacitor-electron": "^0.1.0", "@capawesome/cli": "^3.8.0", + "@playwright/test": "1.61.1", "concurrently": "^9.0.0", + "electron": "43.1.0", + "typescript": "5.9.3", "vite": "7.2.2" } } \ No newline at end of file diff --git a/packages/live-update/example/test/README.md b/packages/live-update/example/test/README.md index 22a2593bb..510daa415 100644 --- a/packages/live-update/example/test/README.md +++ b/packages/live-update/example/test/README.md @@ -10,6 +10,11 @@ The Maestro flow `.maestro/download-checksum-mismatch.yaml` passes a different c ## `mock-server.mjs` -Tiny zero-dependency HTTP server that serves `fixtures/bundle.zip` on `GET /bundle.zip`. Listens on port `4000`. +Zero-dependency HTTP server exposing `createMockServer(...)`. -Both emulator and simulator reach it at `http://localhost:4000`. The Android emulator relies on `adb reverse tcp:4000 tcp:4000` (run automatically by `npm run e2e:android`); the iOS simulator uses the host's network directly. +- **Standalone (mobile):** `node test/mock-server.mjs` serves `fixtures/bundle.zip` on `GET /bundle.zip`, port `4000` — the behaviour the Maestro flows rely on. Both emulator and simulator reach it at `http://localhost:4000` (the Android emulator relies on `adb reverse tcp:4000 tcp:4000`, run automatically by `npm run e2e:android`; the iOS simulator uses the host's network directly). +- **As a module (Electron e2e):** `createMockServer({ registry, fixturesDir })` additionally implements the Capawesome Cloud endpoints the live-update engine calls: `GET /v1/apps/:appId/bundles/latest`, `GET /v1/apps/:appId/channels`, zip downloads (`GET /download/:file`) and `manifest` delta downloads (`GET /manifest/:id?href=`). `POST /__control` mutates the served bundle per channel and toggles channel discovery at runtime. + +## Electron e2e (`test/electron/`) + +`npm run e2e:electron` builds the web app, syncs the `@capawesome/capacitor-electron` platform, compiles the Electron main process, builds signed bundle fixtures (`support/build-fixtures.mjs`), and runs the Playwright spec (`live-update.spec.mjs`) which drives the built Electron app via `electron.launch`. Each scenario rewrites `electron/generated/capacitor.config.json` (server domain, public key, `readyTimeout`) and launches with an isolated `--user-data-dir`, so a single build covers happy path, checksum/signature verification, kill-safe rollback, channel switching, `fetchChannels`, and manifest delta updates. diff --git a/packages/live-update/example/test/electron/live-update.spec.mjs b/packages/live-update/example/test/electron/live-update.spec.mjs new file mode 100644 index 000000000..ad32c4df0 --- /dev/null +++ b/packages/live-update/example/test/electron/live-update.spec.mjs @@ -0,0 +1,205 @@ +import { expect, test } from '@playwright/test'; + +import { + activeBundleVersion, + callPlugin, + callPluginExpectError, + freshUserDataDir, + launch, + loadRegistry, + startMockServer, + triggerReload, + waitForBundle, + writeLiveUpdateConfig, +} from './support/harness.mjs'; + +const APP_ID = '46d641f5-2703-4e99-b498-006192c70484'; + +let registry; +let mock; +const openApps = []; + +test.beforeAll(async () => { + registry = loadRegistry(); + mock = await startMockServer(registry); +}); + +test.afterAll(async () => { + await mock.close(); +}); + +test.afterEach(async () => { + for (const app of openApps.splice(0)) { + await app.close().catch(() => undefined); + } +}); + +const boot = async ({ liveUpdate = {}, userDataDir }) => { + writeLiveUpdateConfig({ serverDomain: mock.serverDomain, ...liveUpdate }); + const { app, page } = await launch(userDataDir); + openApps.push(app); + return { app, page }; +}; + +test('happy path: sync -> reload -> new bundle active -> ready(rollback:false)', async () => { + await mock.control({ latestByChannel: { default: '2.0.0' } }); + const { page } = await boot({ + liveUpdate: { readyTimeout: 0 }, + userDataDir: freshUserDataDir(), + }); + + // Default (packaged) bundle is active on first boot. + expect(await activeBundleVersion(page)).toBeNull(); + expect((await callPlugin(page, 'getCurrentBundle')).bundleId).toBeNull(); + + // Version + config APIs. + expect((await callPlugin(page, 'getVersionName')).versionName).toBe('0.0.0'); + expect((await callPlugin(page, 'getVersionCode')).versionCode).toBe('0.0.0'); + const config = await callPlugin(page, 'getConfig'); + expect(config.appId).toBe(APP_ID); + expect(config.autoUpdateStrategy).toBe('none'); + + // setConfig / resetConfig are unimplemented on Electron. + const setConfig = await callPluginExpectError(page, 'setConfig', { + appId: null, + }); + expect(setConfig.ok).toBe(false); + expect(setConfig.code).toBe('UNIMPLEMENTED'); + const resetConfig = await callPluginExpectError(page, 'resetConfig'); + expect(resetConfig.ok).toBe(false); + expect(resetConfig.code).toBe('UNIMPLEMENTED'); + + // Sync downloads and stages the next bundle. + const sync = await callPlugin(page, 'sync'); + expect(sync.nextBundleId).toBe('2.0.0'); + expect((await callPlugin(page, 'getNextBundle')).bundleId).toBe('2.0.0'); + expect((await callPlugin(page, 'getDownloadedBundles')).bundleIds).toContain( + '2.0.0', + ); + + // Reload activates the new bundle. + await triggerReload(page); + await waitForBundle(page, '2.0.0'); + expect(await activeBundleVersion(page)).toBe('2.0.0'); + expect((await callPlugin(page, 'getCurrentBundle')).bundleId).toBe('2.0.0'); + + // ready() confirms the update with no rollback. + const ready = await callPlugin(page, 'ready'); + expect(ready.rollback).toBe(false); + expect(ready.currentBundleId).toBe('2.0.0'); +}); + +test('checksum mismatch is rejected', async () => { + await mock.control({ latestByChannel: { default: '2.0.0-badsum' } }); + const { page } = await boot({ + liveUpdate: { readyTimeout: 0 }, + userDataDir: freshUserDataDir(), + }); + + const result = await callPluginExpectError(page, 'sync'); + expect(result.ok).toBe(false); + expect(result.message).toContain('Checksum mismatch'); + expect((await callPlugin(page, 'getCurrentBundle')).bundleId).toBeNull(); +}); + +test('signature verification: correct public key is accepted', async () => { + await mock.control({ latestByChannel: { default: '2.0.0' } }); + const { page } = await boot({ + liveUpdate: { readyTimeout: 0, publicKey: registry.keys.publicKey }, + userDataDir: freshUserDataDir(), + }); + + const sync = await callPlugin(page, 'sync'); + expect(sync.nextBundleId).toBe('2.0.0'); +}); + +test('signature verification: wrong public key is rejected', async () => { + await mock.control({ latestByChannel: { default: 'sig-2.1.0' } }); + const { page } = await boot({ + liveUpdate: { readyTimeout: 0, publicKey: registry.keys.publicKey }, + userDataDir: freshUserDataDir(), + }); + + const result = await callPluginExpectError(page, 'sync'); + expect(result.ok).toBe(false); + expect(result.message).toContain('Signature verification failed'); +}); + +test('broken bundle: kill-safe rollback at next boot reports rollback:true and blocks the bundle', async () => { + const userDataDir = freshUserDataDir(); + await mock.control({ latestByChannel: { default: '3.0.0' } }); + + // Boot 1: activate 3.0.0 but never call ready(), then quit (simulated crash). + { + const { app, page } = await boot({ + liveUpdate: { readyTimeout: 60000, autoBlockRolledBackBundles: true }, + userDataDir, + }); + const sync = await callPlugin(page, 'sync'); + expect(sync.nextBundleId).toBe('3.0.0'); + await triggerReload(page); + await waitForBundle(page, '3.0.0'); + expect((await callPlugin(page, 'getCurrentBundle')).bundleId).toBe('3.0.0'); + await app.close(); + openApps.splice(openApps.indexOf(app), 1); + } + + // Boot 2: same user data -> engine rolls back to the default bundle. + { + const { page } = await boot({ + liveUpdate: { readyTimeout: 60000, autoBlockRolledBackBundles: true }, + userDataDir, + }); + expect(await activeBundleVersion(page)).toBeNull(); + expect((await callPlugin(page, 'getCurrentBundle')).bundleId).toBeNull(); + + const ready = await callPlugin(page, 'ready'); + expect(ready.rollback).toBe(true); + expect(ready.previousBundleId).toBe('3.0.0'); + + const blocked = await callPlugin(page, 'getBlockedBundles'); + expect(blocked.bundleIds).toContain('3.0.0'); + + // The blocked bundle is no longer offered by sync. + const sync = await callPlugin(page, 'sync'); + expect(sync.nextBundleId).toBeNull(); + } +}); + +test('channel switching serves a channel-specific bundle and fetchChannels lists channels', async () => { + await mock.control({ + latestByChannel: { default: '2.0.0', beta: 'beta-1.0.0' }, + channelsEnabled: true, + }); + const { page } = await boot({ + liveUpdate: { readyTimeout: 0 }, + userDataDir: freshUserDataDir(), + }); + + const channels = await callPlugin(page, 'fetchChannels'); + expect(channels.channels.map(channel => channel.name)).toContain('beta'); + + await callPlugin(page, 'setChannel', { channel: 'beta' }); + expect((await callPlugin(page, 'getChannel')).channel).toBe('beta'); + + const sync = await callPlugin(page, 'sync'); + expect(sync.nextBundleId).toBe('beta-1.0.0'); + + await triggerReload(page); + await waitForBundle(page, 'beta-1.0.0'); + expect(await activeBundleVersion(page)).toBe('beta-1.0.0'); +}); + +test('fetchChannels rejects when channel discovery is disabled', async () => { + await mock.control({ channelsEnabled: false }); + const { page } = await boot({ + liveUpdate: { readyTimeout: 0 }, + userDataDir: freshUserDataDir(), + }); + + const result = await callPluginExpectError(page, 'fetchChannels'); + expect(result.ok).toBe(false); + expect(result.message).toContain('Channel Discovery'); + + await mock.control({ channelsEnabled: true }); +}); diff --git a/packages/live-update/example/test/electron/playwright.config.mjs b/packages/live-update/example/test/electron/playwright.config.mjs new file mode 100644 index 000000000..63532efeb --- /dev/null +++ b/packages/live-update/example/test/electron/playwright.config.mjs @@ -0,0 +1,12 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: '.', + testMatch: '**/*.spec.mjs', + timeout: 120000, + workers: 1, + fullyParallel: false, + forbidOnly: !!process.env.CI, + reporter: [['list']], + outputDir: './test-results', +}); diff --git a/packages/live-update/example/test/electron/support/build-fixtures.mjs b/packages/live-update/example/test/electron/support/build-fixtures.mjs new file mode 100644 index 000000000..2bd04b27b --- /dev/null +++ b/packages/live-update/example/test/electron/support/build-fixtures.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +/** + * Builds the Electron e2e fixtures: an RSA key pair and a set of live-update + * bundles derived from the built web app (`../dist`). + * + * Each bundle is a copy of the web app with a `` + * tag injected into its `` so the e2e spec can detect which bundle is + * active after a reload. Bundles are emitted as signed zip artifacts. + * + * Output (git-ignored): `test/electron/.fixtures/`. + */ +import { execFileSync } from 'node:child_process'; +import { + createHash, + createSign, + generateKeyPairSync, +} from 'node:crypto'; +import { + cpSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const EXAMPLE_DIR = join(HERE, '..', '..', '..'); +const WEB_DIST = join(EXAMPLE_DIR, 'dist'); +const FIXTURES_DIR = join(HERE, '..', '.fixtures'); +const BUNDLES_DIR = join(FIXTURES_DIR, 'bundles'); +const ZIPS_DIR = join(FIXTURES_DIR, 'zips'); + +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); +const sign = (bytes, privateKeyPem) => + createSign('RSA-SHA256').update(bytes).sign(privateKeyPem).toString('base64'); + +const generateKeyPair = () => { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + }); + return { + privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(), + }; +}; + +const injectBundleVersion = (bundleDir, version) => { + const indexPath = join(bundleDir, 'index.html'); + const html = readFileSync(indexPath, 'utf8'); + const meta = ``; + writeFileSync(indexPath, html.replace('', `\n ${meta}`)); +}; + +const createBundleDir = (id, version) => { + const bundleDir = join(BUNDLES_DIR, id); + rmSync(bundleDir, { recursive: true, force: true }); + cpSync(WEB_DIST, bundleDir, { recursive: true }); + injectBundleVersion(bundleDir, version); + return bundleDir; +}; + +const zipBundle = (id, bundleDir) => { + const zipPath = join(ZIPS_DIR, `${id}.zip`); + rmSync(zipPath, { force: true }); + // `-X` strips extra file attributes; archive entries live at the root. + execFileSync('zip', ['-r', '-X', '-q', zipPath, '.'], { cwd: bundleDir }); + return zipPath; +}; + +const main = () => { + rmSync(FIXTURES_DIR, { recursive: true, force: true }); + mkdirSync(BUNDLES_DIR, { recursive: true }); + mkdirSync(ZIPS_DIR, { recursive: true }); + + const signer = generateKeyPair(); + const attacker = generateKeyPair(); + const keysDir = join(FIXTURES_DIR, 'keys'); + mkdirSync(keysDir, { recursive: true }); + writeFileSync(join(keysDir, 'private.pem'), signer.privateKeyPem); + writeFileSync(join(keysDir, 'public.pem'), signer.publicKeyPem); + + const bundles = {}; + + const addZipBundle = (id, version, { privateKeyPem }) => { + const bundleDir = createBundleDir(id, version); + const zipPath = zipBundle(id, bundleDir); + const bytes = readFileSync(zipPath); + bundles[id] = { + type: 'zip', + file: `${id}.zip`, + checksum: sha256(bytes), + signature: sign(bytes, privateKeyPem), + }; + }; + + // Happy path / correct-signature bundle. + addZipBundle('2.0.0', '2.0.0', { privateKeyPem: signer.privateKeyPem }); + // Checksum-mismatch reuses the 2.0.0 zip but advertises a bogus checksum. + bundles['2.0.0-badsum'] = { + type: 'zip', + file: '2.0.0.zip', + checksum: '0'.repeat(64), + signature: bundles['2.0.0'].signature, + }; + // Signature-mismatch: signed with an attacker key the app does not trust. + addZipBundle('sig-2.1.0', '2.1.0', { privateKeyPem: attacker.privateKeyPem }); + // Rollback bundle (kept intentionally un-ready by the spec). + addZipBundle('3.0.0', '3.0.0', { privateKeyPem: signer.privateKeyPem }); + // Channel-specific bundle. + addZipBundle('beta-1.0.0', 'beta-1.0.0', { + privateKeyPem: signer.privateKeyPem, + }); + + const registry = { + keys: { + publicKey: signer.publicKeyPem, + }, + channels: [ + { id: 'a1b2c3d4-0000-0000-0000-000000000001', name: 'production' }, + { id: 'a1b2c3d4-0000-0000-0000-000000000002', name: 'beta' }, + ], + bundles, + }; + writeFileSync( + join(FIXTURES_DIR, 'registry.json'), + JSON.stringify(registry, null, 2), + ); + console.log( + `Built ${Object.keys(bundles).length} fixture bundles in ${FIXTURES_DIR}`, + ); +}; + +main(); diff --git a/packages/live-update/example/test/electron/support/harness.mjs b/packages/live-update/example/test/electron/support/harness.mjs new file mode 100644 index 000000000..237fc306b --- /dev/null +++ b/packages/live-update/example/test/electron/support/harness.mjs @@ -0,0 +1,110 @@ +import { _electron as electron } from 'playwright'; +import electronPath from 'electron'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { createMockServer } from '../../mock-server.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +export const EXAMPLE_DIR = join(HERE, '..', '..', '..'); +export const ELECTRON_APP_DIR = join(EXAMPLE_DIR, 'electron'); +export const GENERATED_CONFIG_PATH = join( + ELECTRON_APP_DIR, + 'generated', + 'capacitor.config.json', +); +export const FIXTURES_DIR = join(HERE, '..', '.fixtures'); +export const REGISTRY_PATH = join(FIXTURES_DIR, 'registry.json'); + +export const loadRegistry = () => + JSON.parse(readFileSync(REGISTRY_PATH, 'utf8')); + +export const startMockServer = registry => + new Promise(resolve => { + const server = createMockServer({ registry, fixturesDir: FIXTURES_DIR }); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + const origin = `http://localhost:${port}`; + const control = async patch => { + const response = await fetch(`${origin}/__control`, { + method: 'POST', + body: JSON.stringify(patch), + }); + if (!response.ok) { + throw new Error(`Mock control failed: ${response.status}`); + } + }; + resolve({ + origin, + serverDomain: `localhost:${port}`, + control, + close: () => new Promise(done => server.close(done)), + }); + }); + }); + +export const writeLiveUpdateConfig = liveUpdate => { + const config = JSON.parse(readFileSync(GENERATED_CONFIG_PATH, 'utf8')); + config.plugins = config.plugins ?? {}; + config.plugins.LiveUpdate = { + appId: '46d641f5-2703-4e99-b498-006192c70484', + ...liveUpdate, + }; + writeFileSync(GENERATED_CONFIG_PATH, JSON.stringify(config, null, 2)); +}; + +export const freshUserDataDir = () => + mkdtempSync(join(tmpdir(), 'lu-electron-e2e-')); + +export const launch = async userDataDir => { + const app = await electron.launch({ + executablePath: electronPath, + args: [ELECTRON_APP_DIR, `--user-data-dir=${userDataDir}`], + }); + const page = await app.firstWindow(); + await page.waitForFunction( + () => !!window.Capacitor?.Plugins?.LiveUpdate, + null, + { timeout: 30000 }, + ); + return { app, page }; +}; + +export const callPlugin = (page, method, options) => + page.evaluate( + ({ method, options }) => window.Capacitor.Plugins.LiveUpdate[method](options), + { method, options }, + ); + +export const callPluginExpectError = (page, method, options) => + page.evaluate( + ({ method, options }) => + window.Capacitor.Plugins.LiveUpdate[method](options).then( + () => ({ ok: true }), + error => ({ ok: false, message: error.message, code: error.code }), + ), + { method, options }, + ); + +export const activeBundleVersion = page => + page.evaluate( + () => + document.querySelector('meta[name="bundle-version"]')?.content ?? null, + ); + +export const triggerReload = page => + page.evaluate(() => { + // Fire-and-forget: the window reloads before `reload()` resolves. + void window.Capacitor.Plugins.LiveUpdate.reload(); + }); + +export const waitForBundle = (page, version) => + page.waitForFunction( + expected => + document.querySelector('meta[name="bundle-version"]')?.content === + expected && !!window.Capacitor?.Plugins?.LiveUpdate, + version, + { timeout: 30000 }, + ); diff --git a/packages/live-update/example/test/mock-server.mjs b/packages/live-update/example/test/mock-server.mjs index 8ce15e36b..220432176 100644 --- a/packages/live-update/example/test/mock-server.mjs +++ b/packages/live-update/example/test/mock-server.mjs @@ -1,29 +1,169 @@ -import { createReadStream, statSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'; import { createServer } from 'node:http'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -const PORT = 4000; -const BUNDLE_PATH = join( - dirname(fileURLToPath(import.meta.url)), - 'fixtures', - 'bundle.zip', -); +const HERE = dirname(fileURLToPath(import.meta.url)); +const LEGACY_BUNDLE_PATH = join(HERE, 'fixtures', 'bundle.zip'); -const server = createServer((req, res) => { - if (req.method === 'GET' && req.url === '/bundle.zip') { - const { size } = statSync(BUNDLE_PATH); +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); + +const sendJson = (res, status, body) => { + const payload = JSON.stringify(body); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }); + res.end(payload); +}; + +/** + * Creates a mock Capawesome Cloud server that implements the endpoints the + * live-update engine calls: `bundles/latest`, `channels` and zip downloads. + * When `registry` is omitted only the legacy `GET /bundle.zip` route (used by + * the mobile Maestro flows) is served. + */ +export const createMockServer = ({ registry, fixturesDir } = {}) => { + const bundles = registry?.bundles ?? {}; + const fileToEntry = new Map(); + for (const entry of Object.values(bundles)) { + if (entry.type === 'zip' && !fileToEntry.has(entry.file)) { + fileToEntry.set(entry.file, entry); + } + } + + const state = { + latestByChannel: {}, + channelsEnabled: true, + }; + + const resolveLatestBundleId = channelName => { + const key = channelName ?? 'default'; + return state.latestByChannel[key] ?? state.latestByChannel.default ?? null; + }; + + const handleControl = (req, res) => { + let body = ''; + req.on('data', chunk => (body += chunk)); + req.on('end', () => { + let patch; + try { + patch = body ? JSON.parse(body) : {}; + } catch { + return sendJson(res, 400, { message: 'Invalid JSON body.' }); + } + if (patch.latestByChannel !== undefined) { + state.latestByChannel = patch.latestByChannel; + } + if (patch.channelsEnabled !== undefined) { + state.channelsEnabled = patch.channelsEnabled; + } + sendJson(res, 200, { ok: true, state }); + }); + }; + + const handleChannels = (res, url) => { + if (!state.channelsEnabled) { + return sendJson(res, 401, { + message: + 'Unauthorized. Channel Discovery may not be enabled for this app.', + }); + } + const query = url.searchParams.get('query'); + const limit = Number(url.searchParams.get('limit') ?? '50'); + const offset = Number(url.searchParams.get('offset') ?? '0'); + let channels = registry?.channels ?? []; + if (query) { + channels = channels.filter(channel => channel.name.includes(query)); + } + sendJson(res, 200, channels.slice(offset, offset + limit)); + }; + + const handleLatest = (res, url, requestOrigin) => { + const channelName = url.searchParams.get('channelName'); + const bundleId = resolveLatestBundleId(channelName); + const entry = bundleId ? bundles[bundleId] : null; + if (!entry) { + return sendJson(res, 404, { message: 'No bundle available.' }); + } + sendJson(res, 200, { + artifactType: 'zip', + bundleId, + channelName: channelName ?? null, + checksum: entry.checksum, + signature: entry.signature, + url: `${requestOrigin}/download/${entry.file}`, + }); + }; + + const handleDownload = (res, file) => { + const entry = fileToEntry.get(file); + const zipPath = join(fixturesDir, 'zips', file); + if (!entry || !existsSync(zipPath)) { + res.writeHead(404); + return res.end(); + } + const bytes = readFileSync(zipPath); + res.writeHead(200, { + 'Content-Type': 'application/zip', + 'Content-Length': bytes.length, + 'X-Checksum': sha256(bytes), + ...(entry.signature ? { 'X-Signature': entry.signature } : {}), + }); + res.end(bytes); + }; + + const handleLegacyBundle = res => { + if (!existsSync(LEGACY_BUNDLE_PATH)) { + res.writeHead(404); + return res.end(); + } + const { size } = statSync(LEGACY_BUNDLE_PATH); res.writeHead(200, { 'Content-Type': 'application/zip', 'Content-Length': size, }); - createReadStream(BUNDLE_PATH).pipe(res); - return; - } - res.writeHead(404); - res.end(); -}); + createReadStream(LEGACY_BUNDLE_PATH).pipe(res); + }; + + return createServer((req, res) => { + const requestOrigin = `http://${req.headers.host ?? 'localhost'}`; + const url = new URL(req.url, requestOrigin); + const path = url.pathname; + + if (req.method === 'POST' && path === '/__control') { + return handleControl(req, res); + } + if (req.method === 'GET' && path === '/bundle.zip') { + return handleLegacyBundle(res); + } + if (req.method === 'GET' && /^\/v1\/apps\/[^/]+\/channels$/.test(path)) { + return handleChannels(res, url); + } + if ( + req.method === 'GET' && + /^\/v1\/apps\/[^/]+\/bundles\/latest$/.test(path) + ) { + return handleLatest(res, url, requestOrigin); + } + if (req.method === 'GET' && path.startsWith('/download/')) { + return handleDownload( + res, + decodeURIComponent(path.slice('/download/'.length)), + ); + } + res.writeHead(404); + res.end(); + }); +}; -server.listen(PORT, () => { - console.log(`Mock server listening on http://0.0.0.0:${PORT}`); -}); +// Standalone entry point for the mobile Maestro flows: serve `bundle.zip` on +// port 4000, preserving the original behaviour. +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const PORT = 4000; + const server = createMockServer(); + server.listen(PORT, () => { + console.log(`Mock server listening on http://0.0.0.0:${PORT}`); + }); +} diff --git a/packages/live-update/package.json b/packages/live-update/package.json index 714d5ee4b..82595e0ed 100644 --- a/packages/live-update/package.json +++ b/packages/live-update/package.json @@ -1,7 +1,7 @@ { "name": "@capawesome/capacitor-live-update", "version": "8.3.0", - "description": "Capacitor plugin to update your app remotely in real-time on Android and iOS.", + "description": "Capacitor plugin to update your app remotely in real-time on Android, iOS and Electron.", "main": "dist/plugin.cjs.js", "module": "dist/esm/index.js", "types": "dist/esm/index.d.ts", @@ -10,6 +10,7 @@ "android/src/main/", "android/build.gradle", "dist/", + "electron/", "ios/Plugin/", "CapawesomeCapacitorLiveUpdate.podspec", "Package.swift" @@ -41,6 +42,7 @@ "ionic", "android", "ios", + "electron", "live update", "remote update", "ota", @@ -54,10 +56,11 @@ "bundle management" ], "scripts": { - "verify": "npm run verify:ios && npm run verify:android && npm run verify:web", + "verify": "npm run verify:ios && npm run verify:android && npm run verify:web && npm run verify:electron", "verify:ios": "cd ios && pod install && xcodebuild -workspace Plugin.xcworkspace -scheme Plugin -destination generic/platform=iOS && cd ..", "verify:android": "cd android && ./gradlew clean build test && cd ..", "verify:web": "npm run build", + "verify:electron": "npm run build:electron", "e2e:android": "npm install --prefix example && npm run build && npm --prefix example run e2e:android", "e2e:ios": "npm install --prefix example && npm run build && npm --prefix example run e2e:ios", "lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint", @@ -67,11 +70,15 @@ "swiftlint": "node-swiftlint", "docgen": "docgen --api LiveUpdatePlugin --output-readme README.md --output-json dist/docs.json", "build": "npm run clean && npm run docgen && tsc && rollup -c rollup.config.mjs", + "build:electron": "rimraf ./electron/dist && tsc -p electron/tsconfig.json && rollup -c electron/rollup.config.mjs", "clean": "rimraf ./dist", "watch": "tsc --watch", "version": "node scripts/sync-plugin-version.mjs", "ios:pod:install": "cd ios && pod install --repo-update && cd ..", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build && npm run build:electron" + }, + "dependencies": { + "@capawesome/electron-live-update": "^0.1.0" }, "devDependencies": { "@capacitor/android": "8.0.0", @@ -79,7 +86,9 @@ "@capacitor/core": "8.0.0", "@capacitor/docgen": "0.3.1", "@capacitor/ios": "8.0.0", + "@capawesome/capacitor-electron": "^0.1.0", "@ionic/eslint-config": "0.4.0", + "electron": "43.1.0", "eslint": "8.57.0", "prettier-plugin-java": "2.6.7", "rimraf": "6.1.2", @@ -88,7 +97,13 @@ "typescript": "5.9.3" }, "peerDependencies": { - "@capacitor/core": ">=8.0.0" + "@capacitor/core": ">=8.0.0", + "@capawesome/capacitor-electron": ">=0.1.0" + }, + "peerDependenciesMeta": { + "@capawesome/capacitor-electron": { + "optional": true + } }, "eslintConfig": { "extends": "@ionic/eslint-config/recommended" @@ -99,6 +114,9 @@ }, "android": { "src": "android" + }, + "electron": { + "src": "electron" } } } diff --git a/packages/live-update/scripts/sync-plugin-version.mjs b/packages/live-update/scripts/sync-plugin-version.mjs index c980583b0..9f18ccd00 100644 --- a/packages/live-update/scripts/sync-plugin-version.mjs +++ b/packages/live-update/scripts/sync-plugin-version.mjs @@ -6,7 +6,7 @@ import { joinPath, replaceInFile } from './lib/file-helper.mjs'; execute(async () => { const packageJson = await readFile('./package.json') - .then((json) => JSON.parse(json.toString())) + .then(json => JSON.parse(json.toString())) .catch(() => null); const version = packageJson?.version; if (!version) { @@ -24,15 +24,15 @@ execute(async () => { 'capacitorjs', 'plugins', 'liveupdate', - 'LiveUpdatePlugin.java' + 'LiveUpdatePlugin.java', ), /public static final String VERSION = "(\d+\.\d+\.\d+)"/, - 'public static final String VERSION = "' + version + '"' + 'public static final String VERSION = "' + version + '"', ); // Replace version in iOS plugin await replaceInFile( joinPath('ios', 'Plugin', 'LiveUpdatePlugin.swift'), /public static let version = "(\d+\.\d+\.\d+)"/, - 'public static let version = "' + version + '"' + 'public static let version = "' + version + '"', ); }); diff --git a/packages/live-update/src/definitions.ts b/packages/live-update/src/definitions.ts index d1d3a3aca..46bf69453 100644 --- a/packages/live-update/src/definitions.ts +++ b/packages/live-update/src/definitions.ts @@ -24,7 +24,7 @@ declare module '@capacitor/cli' { * * **Attention**: This option has no effect if `readyTimeout` is set to `0`. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 7.3.0 * @default false @@ -47,7 +47,7 @@ declare module '@capacitor/cli' { * and applied in the background at app startup and when the app resumes * (if the last check was more than 15 minutes ago). * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 7.3.0 * @default 'none' @@ -117,7 +117,7 @@ export interface LiveUpdatePlugin { * This removes all bundle identifiers that were automatically blocked * due to rollbacks when `autoBlockRolledBackBundles` is enabled. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 7.4.0 */ @@ -125,7 +125,7 @@ export interface LiveUpdatePlugin { /** * Delete a bundle from the app. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 5.0.0 */ @@ -133,7 +133,7 @@ export interface LiveUpdatePlugin { /** * Download a bundle. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 5.0.0 */ @@ -149,7 +149,7 @@ export interface LiveUpdatePlugin { * If channels are private, they can still be set using `setChannel(...)` * but won't be returned by this method. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 8.2.0 */ @@ -157,7 +157,7 @@ export interface LiveUpdatePlugin { /** * Fetch the latest bundle using the [Capawesome Cloud](https://capawesome.io/cloud/). * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 6.6.0 */ @@ -170,7 +170,7 @@ export interface LiveUpdatePlugin { * Returns the list of bundle identifiers that were automatically blocked * due to rollbacks when `autoBlockRolledBackBundles` is enabled. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 7.4.0 */ @@ -178,7 +178,7 @@ export interface LiveUpdatePlugin { /** * Get all identifiers of bundles that have been downloaded. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 5.0.0 * @deprecated Use `getDownloadedBundles()` instead. @@ -193,10 +193,12 @@ export interface LiveUpdatePlugin { * `capawesome_live_update_default_channel` in `strings.xml` on Android) * 3. Capacitor config `defaultChannel` * + * On **Electron**, the native config (2) is not available. + * * **Note**: The `channel` parameter of `sync()` takes the highest priority * but is not persisted and therefore not returned by this method. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 5.0.0 */ @@ -207,7 +209,7 @@ export interface LiveUpdatePlugin { * Returns the current plugin configuration including any runtime * overrides set via `setConfig()`. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 7.4.0 */ @@ -215,7 +217,7 @@ export interface LiveUpdatePlugin { /** * Get all identifiers of bundles that have been downloaded. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 7.4.0 */ @@ -224,7 +226,7 @@ export interface LiveUpdatePlugin { * Get the bundle identifier of the current bundle. * The current bundle is the bundle that is currently used by the app. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 6.7.0 */ @@ -232,7 +234,7 @@ export interface LiveUpdatePlugin { /** * Get the custom identifier of the device. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 5.0.0 */ @@ -240,7 +242,7 @@ export interface LiveUpdatePlugin { /** * Get the unique device identifier. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 5.0.0 */ @@ -248,7 +250,7 @@ export interface LiveUpdatePlugin { /** * Check whether a sync operation is currently in progress. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 7.4.0 */ @@ -258,7 +260,7 @@ export interface LiveUpdatePlugin { * The next bundle is the bundle that will be used after calling `reload()` * or restarting the app. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 6.7.0 */ @@ -268,8 +270,9 @@ export interface LiveUpdatePlugin { * * On **Android**, this is the `versionCode` from the `android/app/build.gradle` file. * On **iOS**, this is the `CFBundleVersion` from the `Info.plist` file. + * On **Electron**, this is the `version` from the app's `package.json` file. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 5.0.0 */ @@ -279,8 +282,9 @@ export interface LiveUpdatePlugin { * * On **Android**, this is the `versionName` from the `android/app/build.gradle` file. * On **iOS**, this is the `CFBundleShortVersionString` from the `Info.plist` file. + * On **Electron**, this is the `version` from the app's `package.json` file. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 5.0.0 */ @@ -291,7 +295,7 @@ export interface LiveUpdatePlugin { * **Attention**: This method should be called as soon as the app is ready to use * to prevent the app from being reset to the default bundle. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 5.0.0 */ @@ -299,7 +303,7 @@ export interface LiveUpdatePlugin { /** * Reload the app to apply the new bundle. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 5.0.0 */ @@ -309,7 +313,7 @@ export interface LiveUpdatePlugin { * * Call `reload()` or restart the app to apply the changes. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 5.0.0 */ @@ -328,7 +332,7 @@ export interface LiveUpdatePlugin { /** * Set the channel to use for the update. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 5.0.0 */ @@ -351,7 +355,7 @@ export interface LiveUpdatePlugin { /** * Set the custom identifier of the device. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 5.0.0 */ @@ -361,7 +365,7 @@ export interface LiveUpdatePlugin { * * Call `reload()` or restart the app to apply the changes. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 6.7.0 */ @@ -371,7 +375,7 @@ export interface LiveUpdatePlugin { * * Call `reload()` or restart the app to apply the changes. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 5.0.0 */ @@ -379,7 +383,7 @@ export interface LiveUpdatePlugin { /** * Listen for the download progress of a bundle. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 7.0.0 */ @@ -393,7 +397,7 @@ export interface LiveUpdatePlugin { * This event is triggered whenever a bundle is set to be used on the next app restart, * either through automatic updates or manual calls to `setNextBundle()`. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 7.3.0 */ @@ -411,7 +415,7 @@ export interface LiveUpdatePlugin { * use the `ready()` method instead. The `ready()` method provides detailed information * about the current bundle, previous bundle, and whether a rollback occurred. * - * Only available on Android and iOS. + * Only available on Android, iOS and Electron. * * @since 7.4.0 */ @@ -444,6 +448,9 @@ export interface DownloadBundleOptions { /** * The artifact type of the bundle. * + * **Attention**: The `manifest` artifact type is not supported on Electron. + * Only the `zip` artifact type is supported there. + * * @since 6.6.0 * @default 'zip' * @example 'manifest' @@ -575,6 +582,9 @@ export interface FetchLatestBundleResult { /** * The artifact type of the bundle. * + * **Attention**: The `manifest` artifact type is not supported on Electron. + * Only the `zip` artifact type is supported there. + * * @since 6.7.0 */ artifactType?: 'manifest' | 'zip'; @@ -743,6 +753,8 @@ export interface GetDeviceIdResult { * On iOS, [`identifierForVendor`](https://developer.apple.com/documentation/uikit/uidevice/1620059-identifierforvendor) is used. * The value of this property is the same for apps that come from the same vendor running on the same device. * + * On Electron, a random UUID is generated on first use and persisted on the device. + * * @since 5.0.0 * * @example '50d2a548-80b7-4dad-adc7-97c0e79d8a89' @@ -785,6 +797,7 @@ export interface GetVersionCodeResult { * * On **Android**, this is the `versionCode` from the `android/app/build.gradle` file. * On **iOS**, this is the `CFBundleVersion` from the `Info.plist` file. + * On **Electron**, this is the `version` from the app's `package.json` file. * * @since 5.0.0 * @example "1" @@ -801,6 +814,7 @@ export interface GetVersionNameResult { * * On **Android**, this is the `versionName` from the `android/app/build.gradle` file. * On **iOS**, this is the `CFBundleShortVersionString` from the `Info.plist` file. + * On **Electron**, this is the `version` from the app's `package.json` file. * * @since 5.0.0 * @example "1.0.0"