@capawesome/capacitor-live-update 6.0.8 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,11 +7,13 @@ Capacitor plugin to update your app remotely in real-time.
7
7
  - 🔋 Supports **Android and iOS**
8
8
  - ⚡️ **Capacitor 6** support
9
9
  - 📦 **Bundle Management**: Download, set, and delete bundles.
10
- - ☁️ **Cloud Support**: Use the [Capawesome Cloud](https://capawesome.io/cloud) to manage your app updates.
10
+ - ☁️ **Cloud Support**: Use the [Capawesome Cloud](https://capawesome.io/cloud/live-updates/) to manage your app updates.
11
11
  - 📺 **Channel Support**: Set a channel for the app to manage different versions.
12
12
  - 🔄 **Auto Update**: Automatically download and set the latest bundle for the app.
13
13
  - 🛟 **Rollback**: Reset the app to the default bundle if an incompatible bundle has been set.
14
14
  - 🚀 **Rollout**: Gradually roll out new bundles to gather valuable feedback.
15
+ - 🔒 **Security**: Verify the authenticity and integrity of the bundle using a public key.
16
+ - 🌐 **Open Source**: Licensed under the MIT License.
15
17
 
16
18
  ## Installation
17
19
 
@@ -63,13 +65,15 @@ We recommend to declare [`CA92.1`](https://developer.apple.com/documentation/bun
63
65
  <docgen-config>
64
66
  <!--Update the source file JSDoc comments and rerun docgen to update the docs below-->
65
67
 
66
- | Prop | Type | Description | Default | Since |
67
- | ----------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ----- |
68
- | **`appId`** | <code>string</code> | The app ID is used to identify the app when using [Capawesome Cloud](https://capawesome.io/cloud). | | 5.0.0 |
69
- | **`autoDeleteBundles`** | <code>boolean</code> | Whether or not to automatically delete unused bundles. When enabled, the plugin will automatically delete unused bundles after calling `ready()`. | <code>false</code> | 5.0.0 |
70
- | **`enabled`** | <code>boolean</code> | Whether or not the plugin is enabled. | <code>true</code> | 5.0.0 |
71
- | **`readyTimeout`** | <code>number</code> | The timeout in milliseconds to wait for the app to be ready before resetting to the default bundle. | <code>10000</code> | 5.0.0 |
72
- | **`resetOnUpdate`** | <code>boolean</code> | Whether or not the app should be reset to the default bundle during an update. | <code>true</code> | 5.0.0 |
68
+ | Prop | Type | Description | Default | Since |
69
+ | ----------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ----- |
70
+ | **`appId`** | <code>string</code> | The app ID is used to identify the app when using [Capawesome Cloud](https://capawesome.io/cloud). | | 5.0.0 |
71
+ | **`autoDeleteBundles`** | <code>boolean</code> | Whether or not to automatically delete unused bundles. When enabled, the plugin will automatically delete unused bundles after calling `ready()`. | <code>false</code> | 5.0.0 |
72
+ | **`enabled`** | <code>boolean</code> | Whether or not the plugin is enabled. | <code>true</code> | 5.0.0 |
73
+ | **`location`** | <code>'us' \| 'eu'</code> | The location of the server to use when using [Capawesome Cloud](https://capawesome.io/cloud). | <code>'us'</code> | 6.2.0 |
74
+ | **`publicKey`** | <code>string</code> | The public key to verify the integrity of the bundle. The public key must be a PEM-encoded RSA public key. | | 6.1.0 |
75
+ | **`readyTimeout`** | <code>number</code> | The timeout in milliseconds to wait for the app to be ready before resetting to the default bundle. | <code>10000</code> | 5.0.0 |
76
+ | **`resetOnUpdate`** | <code>boolean</code> | Whether or not the app should be reset to the default bundle during an update. | <code>true</code> | 5.0.0 |
73
77
 
74
78
  ### Examples
75
79
 
@@ -82,6 +86,8 @@ In `capacitor.config.json`:
82
86
  "appId": '4100e356-e851-47fe-9d3c-b411eb325a99',
83
87
  "autoDeleteBundles": undefined,
84
88
  "enabled": undefined,
89
+ "location": 'eu',
90
+ "publicKey": '-----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDodf1SD0OOn6hIlDuKBza0Ed0OqtwyVJwiyjmE9BJaZ7y8ZUfcF+SKmd0l2cDPM45XIg2tAFux5n29uoKyHwSt+6tCi5CJA5Z1/1eZruRRqABLonV77KS3HUtvOgqRLDnKSV89dYZkM++NwmzOPgIF422mvc+VukcVOBfc8/AHQIDAQAB-----END PUBLIC KEY-----',
85
91
  "readyTimeout": undefined,
86
92
  "resetOnUpdate": undefined
87
93
  }
@@ -102,6 +108,8 @@ const config: CapacitorConfig = {
102
108
  appId: '4100e356-e851-47fe-9d3c-b411eb325a99',
103
109
  autoDeleteBundles: undefined,
104
110
  enabled: undefined,
111
+ location: 'eu',
112
+ publicKey: '-----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDodf1SD0OOn6hIlDuKBza0Ed0OqtwyVJwiyjmE9BJaZ7y8ZUfcF+SKmd0l2cDPM45XIg2tAFux5n29uoKyHwSt+6tCi5CJA5Z1/1eZruRRqABLonV77KS3HUtvOgqRLDnKSV89dYZkM++NwmzOPgIF422mvc+VukcVOBfc8/AHQIDAQAB-----END PUBLIC KEY-----',
105
113
  readyTimeout: undefined,
106
114
  resetOnUpdate: undefined,
107
115
  },
@@ -530,10 +538,11 @@ Only available on Android and iOS.
530
538
 
531
539
  #### DownloadBundleOptions
532
540
 
533
- | Prop | Type | Description | Since |
534
- | -------------- | ------------------- | --------------------------------------------------------------------------------------------------------- | ----- |
535
- | **`url`** | <code>string</code> | The URL of the bundle to download. The bundle must be a ZIP file containing at least a `index.html` file. | 5.0.0 |
536
- | **`bundleId`** | <code>string</code> | The unique identifier of the bundle. | 5.0.0 |
541
+ | Prop | Type | Description | Since |
542
+ | -------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------- | ----- |
543
+ | **`bundleId`** | <code>string</code> | The unique identifier of the bundle. | 5.0.0 |
544
+ | **`checksum`** | <code>string</code> | The checksum of the bundle to verify the integrity of the ZIP file. Must be a SHA-256 hash in hexadecimal format. | 6.1.0 |
545
+ | **`url`** | <code>string</code> | The URL of the bundle to download. The bundle must be a ZIP file containing at least a `index.html` file. | 5.0.0 |
537
546
 
538
547
 
539
548
  #### GetBundleResult
@@ -630,7 +639,7 @@ If you want to disable the plugin to test other parts of your app, you can set t
630
639
 
631
640
  Live updates are only supported for [binary-compatible changes](https://capawesome.io/cloud/live-updates/faq/#what-are-binary-compatible-changes) (e.g. HTML, CSS, JavaScript).
632
641
  If you change native code, such as adding a new plugin, you need to resubmit your app to the app stores.
633
- For this reason, you must be careful to limit live updates to compatible native versions of your app.
642
+ For this reason, you must be careful to [restrict live updates to compatible native versions](https://capawesome.io/blog/how-to-restrict-capacitor-live-updates-to-native-versions/) of your app.
634
643
 
635
644
  ## Changelog
636
645
 
@@ -6,6 +6,7 @@ import android.content.pm.PackageInfo;
6
6
  import android.content.pm.PackageManager;
7
7
  import android.os.Build;
8
8
  import android.os.Handler;
9
+ import android.util.Base64;
9
10
  import androidx.annotation.NonNull;
10
11
  import androidx.annotation.Nullable;
11
12
  import com.getcapacitor.Logger;
@@ -29,6 +30,11 @@ import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.NonEmptyCallback;
29
30
  import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.Result;
30
31
  import java.io.File;
31
32
  import java.io.IOException;
33
+ import java.security.KeyFactory;
34
+ import java.security.MessageDigest;
35
+ import java.security.PublicKey;
36
+ import java.security.Signature;
37
+ import java.security.spec.X509EncodedKeySpec;
32
38
  import java.util.Arrays;
33
39
  import java.util.UUID;
34
40
  import net.lingala.zip4j.ZipFile;
@@ -97,6 +103,7 @@ public class LiveUpdate {
97
103
 
98
104
  public void downloadBundle(@NonNull DownloadBundleOptions options, @NonNull EmptyCallback callback) throws Exception {
99
105
  String bundleId = options.getBundleId();
106
+ String checksum = options.getChecksum();
100
107
  String url = options.getUrl();
101
108
 
102
109
  // Check if the bundle already exists
@@ -107,7 +114,7 @@ public class LiveUpdate {
107
114
  }
108
115
 
109
116
  // Download the bundle
110
- downloadBundle(bundleId, url, callback);
117
+ downloadBundle(bundleId, checksum, null, url, callback);
111
118
  }
112
119
 
113
120
  public void getBundle(@NonNull NonEmptyCallback callback) {
@@ -215,6 +222,8 @@ public class LiveUpdate {
215
222
  @Override
216
223
  public void success(@NonNull GetLatestBundleResponse result) {
217
224
  String latestBundleId = result.getBundleId();
225
+ String checksum = result.getChecksum();
226
+ String signature = result.getSignature();
218
227
  String url = result.getUrl();
219
228
 
220
229
  // Check if the bundle already exists
@@ -233,6 +242,8 @@ public class LiveUpdate {
233
242
  // Download and unzip the bundle
234
243
  downloadBundle(
235
244
  latestBundleId,
245
+ checksum,
246
+ signature,
236
247
  url,
237
248
  new EmptyCallback() {
238
249
  @Override
@@ -290,6 +301,31 @@ public class LiveUpdate {
290
301
  return new File(plugin.getContext().getFilesDir(), bundlesDirectory + "/" + bundle);
291
302
  }
292
303
 
304
+ private byte[] getChecksumForFileAsBytes(@NonNull File file) throws Exception {
305
+ try {
306
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
307
+ BufferedSource source = Okio.buffer(Okio.source(file));
308
+ Buffer buffer = new Buffer();
309
+ for (long bytesRead; (bytesRead = source.read(buffer, 2048)) != -1;) {
310
+ digest.update(buffer.readByteArray());
311
+ }
312
+ source.close();
313
+ return digest.digest();
314
+ } catch (IOException exception) {
315
+ Logger.error(LiveUpdatePlugin.TAG, exception.getMessage(), exception);
316
+ throw new Exception(LiveUpdatePlugin.ERROR_CHECKSUM_CALCULATION_FAILED);
317
+ }
318
+ }
319
+
320
+ private String getChecksumForFileAsString(@NonNull File file) throws Exception {
321
+ byte[] checksumBytes = getChecksumForFileAsBytes(file);
322
+ StringBuilder checksum = new StringBuilder();
323
+ for (byte checksumByte : checksumBytes) {
324
+ checksum.append(Integer.toString((checksumByte & 0xff) + 0x100, 16).substring(1));
325
+ }
326
+ return checksum.toString();
327
+ }
328
+
293
329
  private void createBundlesDirectory() {
294
330
  File bundlesDirectory = buildBundlesDirectory();
295
331
  if (!bundlesDirectory.exists()) {
@@ -297,6 +333,19 @@ public class LiveUpdate {
297
333
  }
298
334
  }
299
335
 
336
+ private PublicKey createPublicKeyFromString(@NonNull String value) throws Exception {
337
+ try {
338
+ value = value.replace("-----BEGIN PUBLIC KEY-----", "").replace("-----END PUBLIC KEY-----", "").replace("\n", "");
339
+ byte[] byteKey = Base64.decode(value, Base64.DEFAULT);
340
+ X509EncodedKeySpec X509publicKey = new X509EncodedKeySpec(byteKey);
341
+ KeyFactory keyFactory = KeyFactory.getInstance("RSA");
342
+ return keyFactory.generatePublic(X509publicKey);
343
+ } catch (Exception exception) {
344
+ Logger.error(LiveUpdatePlugin.TAG, exception.getMessage(), exception);
345
+ throw new Exception(LiveUpdatePlugin.ERROR_PUBLIC_KEY_INVALID);
346
+ }
347
+ }
348
+
300
349
  private void deleteBundle(@NonNull String bundleId) {
301
350
  File bundleDirectory = buildBundleDirectoryFor(bundleId);
302
351
  deleteFileRecursively(bundleDirectory);
@@ -320,7 +369,13 @@ public class LiveUpdate {
320
369
  }
321
370
  }
322
371
 
323
- private void downloadBundle(@NonNull String bundleId, @NonNull String url, @NonNull EmptyCallback callback) {
372
+ private void downloadBundle(
373
+ @NonNull String bundleId,
374
+ @Nullable String expectedChecksum,
375
+ @Nullable String signature,
376
+ @NonNull String url,
377
+ @NonNull EmptyCallback callback
378
+ ) {
324
379
  downloadFile(
325
380
  url,
326
381
  new NonEmptyCallback<File>() {
@@ -332,6 +387,28 @@ public class LiveUpdate {
332
387
  @Override
333
388
  public void success(@NonNull File result) {
334
389
  try {
390
+ // Verify the signature
391
+ String publicKey = config.getPublicKey();
392
+ if (publicKey != null) {
393
+ if (signature == null) {
394
+ throw new Exception(LiveUpdatePlugin.ERROR_SIGNATURE_MISSING);
395
+ }
396
+ PublicKey key = createPublicKeyFromString(publicKey);
397
+ // Verify the signature
398
+ boolean verified = verifySignatureForFile(result, signature, key);
399
+ if (!verified) {
400
+ throw new Exception(LiveUpdatePlugin.ERROR_SIGNATURE_VERIFICATION_FAILED);
401
+ }
402
+ }
403
+ // Verify the checksum
404
+ else if (expectedChecksum != null) {
405
+ // Calculate the checksum
406
+ String receivedChecksum = getChecksumForFileAsString(result);
407
+ if (!expectedChecksum.equals(receivedChecksum)) {
408
+ throw new Exception(LiveUpdatePlugin.ERROR_CHECKSUM_MISMATCH);
409
+ }
410
+ }
411
+
335
412
  // Add the bundle
336
413
  addBundle(bundleId, result);
337
414
 
@@ -395,9 +472,13 @@ public class LiveUpdate {
395
472
  @Nullable
396
473
  private void fetchLatestBundle(@NonNull NonEmptyCallback<GetLatestBundleResponse> callback)
397
474
  throws PackageManager.NameNotFoundException {
475
+ String host = "api.cloud.capawesome.io";
476
+ if (config.getLocation() != null && config.getLocation().equals("eu")) {
477
+ host = "api.cloud.capawesome.eu";
478
+ }
398
479
  HttpUrl url = new HttpUrl.Builder()
399
480
  .scheme("https")
400
- .host("api.cloud.capawesome.io")
481
+ .host(host)
401
482
  .addPathSegment("v1")
402
483
  .addPathSegment("apps")
403
484
  .addPathSegment(config.getAppId())
@@ -617,6 +698,24 @@ public class LiveUpdate {
617
698
  return destination;
618
699
  }
619
700
 
701
+ private boolean verifySignatureForFile(@NonNull File file, @NonNull String signature, @NonNull PublicKey key) throws Exception {
702
+ try {
703
+ byte[] signatureBytes = Base64.decode(signature, Base64.DEFAULT);
704
+ Signature sig = Signature.getInstance("SHA256withRSA");
705
+ sig.initVerify(key);
706
+ BufferedSource source = Okio.buffer(Okio.source(file));
707
+ Buffer buffer = new Buffer();
708
+ for (long bytesRead; (bytesRead = source.read(buffer, 2048)) != -1;) {
709
+ sig.update(buffer.readByteArray());
710
+ }
711
+ source.close();
712
+ return sig.verify(signatureBytes);
713
+ } catch (Exception exception) {
714
+ Logger.error(LiveUpdatePlugin.TAG, exception.getMessage(), exception);
715
+ throw new Exception(LiveUpdatePlugin.ERROR_SIGNATURE_VERIFICATION_FAILED);
716
+ }
717
+ }
718
+
620
719
  private boolean wasUpdated() throws PackageManager.NameNotFoundException {
621
720
  int lastVersionCode = preferences.getLastVersionCode();
622
721
  int currentVersionCode = getPackageInfo().versionCode;
@@ -1,6 +1,7 @@
1
1
  package io.capawesome.capacitorjs.plugins.liveupdate;
2
2
 
3
3
  import androidx.annotation.Nullable;
4
+ import java.security.Key;
4
5
 
5
6
  public class LiveUpdateConfig {
6
7
 
@@ -9,6 +10,13 @@ public class LiveUpdateConfig {
9
10
 
10
11
  private boolean autoDeleteBundles = false;
11
12
  private boolean enabled = true;
13
+
14
+ @Nullable
15
+ private String location = null;
16
+
17
+ @Nullable
18
+ private String publicKey = null;
19
+
12
20
  private int readyTimeout = 10000;
13
21
  private boolean resetOnUpdate = true;
14
22
 
@@ -25,6 +33,16 @@ public class LiveUpdateConfig {
25
33
  return enabled;
26
34
  }
27
35
 
36
+ @Nullable
37
+ public String getLocation() {
38
+ return location;
39
+ }
40
+
41
+ @Nullable
42
+ public String getPublicKey() {
43
+ return publicKey;
44
+ }
45
+
28
46
  public int getReadyTimeout() {
29
47
  return readyTimeout;
30
48
  }
@@ -45,6 +63,14 @@ public class LiveUpdateConfig {
45
63
  this.enabled = enabled;
46
64
  }
47
65
 
66
+ public void setLocation(@Nullable String location) {
67
+ this.location = location;
68
+ }
69
+
70
+ public void setPublicKey(@Nullable String publicKey) {
71
+ this.publicKey = publicKey;
72
+ }
73
+
48
74
  public void setReadyTimeout(int readyTimeout) {
49
75
  this.readyTimeout = readyTimeout;
50
76
  }
@@ -19,16 +19,21 @@ import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.Result;
19
19
  public class LiveUpdatePlugin extends Plugin {
20
20
 
21
21
  public static final String TAG = "LiveUpdate";
22
- public static final String VERSION = "6.0.7";
22
+ public static final String VERSION = "6.2.0";
23
23
  public static final String SHARED_PREFERENCES_NAME = "CapawesomeLiveUpdate"; // DO NOT CHANGE
24
24
  public static final String ERROR_APP_ID_MISSING = "appId must be configured.";
25
25
  public static final String ERROR_BUNDLE_EXISTS = "bundle already exists.";
26
26
  public static final String ERROR_BUNDLE_ID_MISSING = "bundleId must be provided.";
27
27
  public static final String ERROR_BUNDLE_INDEX_HTML_MISSING = "The bundle does not contain an index.html file.";
28
28
  public static final String ERROR_BUNDLE_NOT_FOUND = "bundle not found.";
29
+ public static final String ERROR_CHECKSUM_CALCULATION_FAILED = "Failed to calculate checksum.";
30
+ public static final String ERROR_CHECKSUM_MISMATCH = "Checksum mismatch.";
29
31
  public static final String ERROR_CUSTOM_ID_MISSING = "customId must be provided.";
30
32
  public static final String ERROR_DOWNLOAD_FAILED = "Bundle could not be downloaded.";
31
33
  public static final String ERROR_URL_MISSING = "url must be provided.";
34
+ public static final String ERROR_SIGNATURE_VERIFICATION_FAILED = "Signature verification failed.";
35
+ public static final String ERROR_PUBLIC_KEY_INVALID = "Invalid public key.";
36
+ public static final String ERROR_SIGNATURE_MISSING = "Bundle does not contain a signature.";
32
37
 
33
38
  @Nullable
34
39
  private LiveUpdateConfig config;
@@ -78,18 +83,19 @@ public class LiveUpdatePlugin extends Plugin {
78
83
  @PluginMethod
79
84
  public void downloadBundle(PluginCall call) {
80
85
  try {
81
- String url = call.getString("url");
82
- if (url == null) {
83
- call.reject(ERROR_URL_MISSING);
84
- return;
85
- }
86
86
  String bundleId = call.getString("bundleId");
87
87
  if (bundleId == null) {
88
88
  call.reject(ERROR_BUNDLE_ID_MISSING);
89
89
  return;
90
90
  }
91
+ String checksum = call.getString("checksum");
92
+ String url = call.getString("url");
93
+ if (url == null) {
94
+ call.reject(ERROR_URL_MISSING);
95
+ return;
96
+ }
91
97
 
92
- DownloadBundleOptions options = new DownloadBundleOptions(bundleId, url);
98
+ DownloadBundleOptions options = new DownloadBundleOptions(bundleId, checksum, url);
93
99
  EmptyCallback callback = new EmptyCallback() {
94
100
  @Override
95
101
  public void success() {
@@ -427,6 +433,10 @@ public class LiveUpdatePlugin extends Plugin {
427
433
  config.setAutoDeleteBundles(autoDeleteBundles);
428
434
  boolean enabled = getConfig().getBoolean("enabled", config.getEnabled());
429
435
  config.setEnabled(enabled);
436
+ String location = getConfig().getString("location", config.getLocation());
437
+ config.setLocation(location);
438
+ String publicKey = getConfig().getString("publicKey", config.getPublicKey());
439
+ config.setPublicKey(publicKey);
430
440
  int readyTimeout = getConfig().getInt("readyTimeout", config.getReadyTimeout());
431
441
  config.setReadyTimeout(readyTimeout);
432
442
  boolean resetOnUpdate = getConfig().getBoolean("resetOnUpdate", config.getResetOnUpdate());
@@ -6,10 +6,29 @@ import org.json.JSONObject;
6
6
  public class GetLatestBundleResponse {
7
7
 
8
8
  private String bundleId;
9
+
10
+ @Nullable
11
+ private String checksum;
12
+
13
+ @Nullable
14
+ private String signature;
15
+
9
16
  private String url;
10
17
 
11
18
  public GetLatestBundleResponse(JSONObject responseJson) {
12
19
  this.bundleId = responseJson.optString("bundleId");
20
+ String checksum = responseJson.optString("checksum", "null");
21
+ if (checksum.equals("null")) {
22
+ this.checksum = null;
23
+ } else {
24
+ this.checksum = checksum;
25
+ }
26
+ String signature = responseJson.optString("signature", "null");
27
+ if (signature.equals("null")) {
28
+ this.signature = null;
29
+ } else {
30
+ this.signature = signature;
31
+ }
13
32
  this.url = responseJson.optString("url");
14
33
  }
15
34
 
@@ -17,6 +36,16 @@ public class GetLatestBundleResponse {
17
36
  return bundleId;
18
37
  }
19
38
 
39
+ @Nullable
40
+ public String getChecksum() {
41
+ return checksum;
42
+ }
43
+
44
+ @Nullable
45
+ public String getSignature() {
46
+ return signature;
47
+ }
48
+
20
49
  public String getUrl() {
21
50
  return url;
22
51
  }
@@ -1,17 +1,22 @@
1
1
  package io.capawesome.capacitorjs.plugins.liveupdate.classes.options;
2
2
 
3
3
  import androidx.annotation.NonNull;
4
+ import androidx.annotation.Nullable;
4
5
 
5
6
  public class DownloadBundleOptions {
6
7
 
7
8
  @NonNull
8
9
  private String bundleId;
9
10
 
11
+ @Nullable
12
+ private String checksum;
13
+
10
14
  @NonNull
11
15
  private String url;
12
16
 
13
- public DownloadBundleOptions(@NonNull String bundleId, @NonNull String url) {
17
+ public DownloadBundleOptions(@NonNull String bundleId, @Nullable String checksum, @NonNull String url) {
14
18
  this.bundleId = bundleId;
19
+ this.checksum = checksum;
15
20
  this.url = url;
16
21
  }
17
22
 
@@ -20,6 +25,11 @@ public class DownloadBundleOptions {
20
25
  return bundleId;
21
26
  }
22
27
 
28
+ @Nullable
29
+ public String getChecksum() {
30
+ return checksum;
31
+ }
32
+
23
33
  @NonNull
24
34
  public String getUrl() {
25
35
  return url;
package/dist/docs.json CHANGED
@@ -343,34 +343,50 @@
343
343
  "methods": [],
344
344
  "properties": [
345
345
  {
346
- "name": "url",
346
+ "name": "bundleId",
347
347
  "tags": [
348
348
  {
349
349
  "text": "5.0.0",
350
350
  "name": "since"
351
351
  },
352
352
  {
353
- "text": "'https://example.com/bundle.zip'",
353
+ "text": "'1.0.0'",
354
354
  "name": "example"
355
355
  }
356
356
  ],
357
- "docs": "The URL of the bundle to download.\n\nThe bundle must be a ZIP file containing at least a `index.html` file.",
357
+ "docs": "The unique identifier of the bundle.",
358
358
  "complexTypes": [],
359
359
  "type": "string"
360
360
  },
361
361
  {
362
- "name": "bundleId",
362
+ "name": "checksum",
363
+ "tags": [
364
+ {
365
+ "text": "6.1.0",
366
+ "name": "since"
367
+ },
368
+ {
369
+ "text": "'aab8e0a312e807c8eaeb923e2bdccd7731c5508869c62ad8e6e43ee881414c84'",
370
+ "name": "example"
371
+ }
372
+ ],
373
+ "docs": "The checksum of the bundle to verify the integrity of the ZIP file.\n\nMust be a SHA-256 hash in hexadecimal format.",
374
+ "complexTypes": [],
375
+ "type": "string | undefined"
376
+ },
377
+ {
378
+ "name": "url",
363
379
  "tags": [
364
380
  {
365
381
  "text": "5.0.0",
366
382
  "name": "since"
367
383
  },
368
384
  {
369
- "text": "'1.0.0'",
385
+ "text": "'https://example.com/bundle.zip'",
370
386
  "name": "example"
371
387
  }
372
388
  ],
373
- "docs": "The unique identifier of the bundle.",
389
+ "docs": "The URL of the bundle to download.\n\nThe bundle must be a ZIP file containing at least a `index.html` file.",
374
390
  "complexTypes": [],
375
391
  "type": "string"
376
392
  }
@@ -746,6 +762,46 @@
746
762
  "complexTypes": [],
747
763
  "type": "boolean | undefined"
748
764
  },
765
+ {
766
+ "name": "location",
767
+ "tags": [
768
+ {
769
+ "text": "6.2.0",
770
+ "name": "since"
771
+ },
772
+ {
773
+ "text": "'us'",
774
+ "name": "default"
775
+ },
776
+ {
777
+ "text": "'eu'",
778
+ "name": "example"
779
+ },
780
+ {
781
+ "text": "https ://capawesome.io/cloud/live-updates/advanced/privacy/",
782
+ "name": "see"
783
+ }
784
+ ],
785
+ "docs": "The location of the server to use when using [Capawesome Cloud](https://capawesome.io/cloud).",
786
+ "complexTypes": [],
787
+ "type": "'us' | 'eu' | undefined"
788
+ },
789
+ {
790
+ "name": "publicKey",
791
+ "tags": [
792
+ {
793
+ "text": "6.1.0",
794
+ "name": "since"
795
+ },
796
+ {
797
+ "text": "'-----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDodf1SD0OOn6hIlDuKBza0Ed0OqtwyVJwiyjmE9BJaZ7y8ZUfcF+SKmd0l2cDPM45XIg2tAFux5n29uoKyHwSt+6tCi5CJA5Z1/1eZruRRqABLonV77KS3HUtvOgqRLDnKSV89dYZkM++NwmzOPgIF422mvc+VukcVOBfc8/AHQIDAQAB-----END PUBLIC KEY-----'",
798
+ "name": "example"
799
+ }
800
+ ],
801
+ "docs": "The public key to verify the integrity of the bundle.\n\nThe public key must be a PEM-encoded RSA public key.",
802
+ "complexTypes": [],
803
+ "type": "string | undefined"
804
+ },
749
805
  {
750
806
  "name": "readyTimeout",
751
807
  "tags": [
@@ -24,6 +24,24 @@ declare module '@capacitor/cli' {
24
24
  * @default true
25
25
  */
26
26
  enabled?: boolean;
27
+ /**
28
+ * The location of the server to use when using [Capawesome Cloud](https://capawesome.io/cloud).
29
+ *
30
+ * @since 6.2.0
31
+ * @default 'us'
32
+ * @example 'eu'
33
+ * @see https://capawesome.io/cloud/live-updates/advanced/privacy/
34
+ */
35
+ location?: 'us' | 'eu';
36
+ /**
37
+ * The public key to verify the integrity of the bundle.
38
+ *
39
+ * The public key must be a PEM-encoded RSA public key.
40
+ *
41
+ * @since 6.1.0
42
+ * @example '-----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDodf1SD0OOn6hIlDuKBza0Ed0OqtwyVJwiyjmE9BJaZ7y8ZUfcF+SKmd0l2cDPM45XIg2tAFux5n29uoKyHwSt+6tCi5CJA5Z1/1eZruRRqABLonV77KS3HUtvOgqRLDnKSV89dYZkM++NwmzOPgIF422mvc+VukcVOBfc8/AHQIDAQAB-----END PUBLIC KEY-----'
43
+ */
44
+ publicKey?: string;
27
45
  /**
28
46
  * The timeout in milliseconds to wait for the app to be ready before resetting to the default bundle.
29
47
  *
@@ -193,6 +211,22 @@ export interface DeleteBundleOptions {
193
211
  bundleId: string;
194
212
  }
195
213
  export interface DownloadBundleOptions {
214
+ /**
215
+ * The unique identifier of the bundle.
216
+ *
217
+ * @since 5.0.0
218
+ * @example '1.0.0'
219
+ */
220
+ bundleId: string;
221
+ /**
222
+ * The checksum of the bundle to verify the integrity of the ZIP file.
223
+ *
224
+ * Must be a SHA-256 hash in hexadecimal format.
225
+ *
226
+ * @since 6.1.0
227
+ * @example 'aab8e0a312e807c8eaeb923e2bdccd7731c5508869c62ad8e6e43ee881414c84'
228
+ */
229
+ checksum?: string;
196
230
  /**
197
231
  * The URL of the bundle to download.
198
232
  *
@@ -202,13 +236,6 @@ export interface DownloadBundleOptions {
202
236
  * @example 'https://example.com/bundle.zip'
203
237
  */
204
238
  url: string;
205
- /**
206
- * The unique identifier of the bundle.
207
- *
208
- * @since 5.0.0
209
- * @example '1.0.0'
210
- */
211
- bundleId: string;
212
239
  }
213
240
  /**
214
241
  * @since 5.0.0
@@ -1 +1 @@
1
- {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"AAAA,wCAAwC","sourcesContent":["/// <reference types=\"@capacitor/cli\" />\n\ndeclare module '@capacitor/cli' {\n export interface PluginsConfig {\n LiveUpdate?: {\n /**\n * The app ID is used to identify the app when using [Capawesome Cloud](https://capawesome.io/cloud).\n *\n * @since 5.0.0\n * @example '4100e356-e851-47fe-9d3c-b411eb325a99'\n */\n appId?: string;\n /**\n * Whether or not to automatically delete unused bundles.\n *\n * When enabled, the plugin will automatically delete unused bundles after calling `ready()`.\n *\n * @since 5.0.0\n * @default false\n */\n autoDeleteBundles?: boolean;\n /**\n * Whether or not the plugin is enabled.\n *\n * @since 5.0.0\n * @default true\n */\n enabled?: boolean;\n /**\n * The timeout in milliseconds to wait for the app to be ready before resetting to the default bundle.\n *\n * @since 5.0.0\n * @default 10000\n */\n readyTimeout?: number;\n /**\n * Whether or not the app should be reset to the default bundle during an update.\n *\n * @since 5.0.0\n * @default true\n */\n resetOnUpdate?: boolean;\n };\n }\n}\n\nexport interface LiveUpdatePlugin {\n /**\n * Delete a bundle from the app.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n deleteBundle(options: DeleteBundleOptions): Promise<void>;\n /**\n * Download a bundle.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n downloadBundle(options: DownloadBundleOptions): Promise<void>;\n /**\n * Get the active bundle identifier.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n getBundle(): Promise<GetBundleResult>;\n /**\n * Get all available bundle identifiers.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n getBundles(): Promise<GetBundlesResult>;\n /**\n * Get the channel of the app.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n getChannel(): Promise<GetChannelResult>;\n /**\n * Get the custom identifier of the app.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n getCustomId(): Promise<GetCustomIdResult>;\n /**\n * Get the device identifier of the app.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n getDeviceId(): Promise<GetDeviceIdResult>;\n /**\n * Get the version code of the app.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n getVersionCode(): Promise<GetVersionCodeResult>;\n /**\n * Get the version name of the app.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n getVersionName(): Promise<GetVersionNameResult>;\n /**\n * Notify the plugin that the app is ready to use and no rollback is needed.\n *\n * **Attention**: This method should be called as soon as the app is ready to use\n * to prevent the app from being reset to the default bundle.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n ready(): Promise<void>;\n /**\n * Reload the app to apply the new bundle.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n reload(): Promise<void>;\n /**\n * Reset the app to the default bundle.\n *\n * Call `reload()` or restart the app to apply the changes.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n reset(): Promise<void>;\n /**\n * Set the next bundle to use for the app.\n *\n * Call `reload()` or restart the app to apply the changes.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n setBundle(options: SetBundleOptions): Promise<void>;\n /**\n * Set the channel of the app.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n setChannel(options: SetChannelOptions): Promise<void>;\n /**\n * Set the custom identifier of the app.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n setCustomId(options: SetCustomIdOptions): Promise<void>;\n /**\n * Automatically download and set the latest bundle for the app using the [Capawesome Cloud](https://capawesome.io/cloud/).\n *\n * Call `reload()` or restart the app to apply the changes.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n sync(): Promise<SyncResult>;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface DeleteBundleOptions {\n /**\n * The unique identifier of the bundle to delete.\n *\n * @since 5.0.0\n * @example '1.0.0'\n */\n bundleId: string;\n}\n\nexport interface DownloadBundleOptions {\n /**\n * The URL of the bundle to download.\n *\n * The bundle must be a ZIP file containing at least a `index.html` file.\n *\n * @since 5.0.0\n * @example 'https://example.com/bundle.zip'\n */\n url: string;\n /**\n * The unique identifier of the bundle.\n *\n * @since 5.0.0\n * @example '1.0.0'\n */\n bundleId: string;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface GetBundleResult {\n /**\n * The unique identifier of the active bundle.\n *\n * If `null`, the default bundle is being used.\n *\n * @since 5.0.0\n * @example '1.0.0'\n */\n bundleId: string | null;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface GetBundlesResult {\n /**\n * An array of unique identifiers of all available bundles.\n *\n * @since 5.0.0\n */\n bundleIds: string[];\n}\n\n/**\n * @since 5.0.0\n */\nexport interface GetChannelResult {\n /**\n * The channel of the app.\n *\n * If `null`, the app is using the default channel.\n *\n * @since 5.0.0\n * @example 'production'\n */\n channel: string | null;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface GetDeviceIdResult {\n /**\n * The unique identifier of the device.\n *\n * On iOS, [`identifierForVendor`](https://developer.apple.com/documentation/uikit/uidevice/1620059-identifierforvendor) is used.\n * The value of this property is the same for apps that come from the same vendor running on the same device.\n *\n * @since 5.0.0\n *\n * @example '50d2a548-80b7-4dad-adc7-97c0e79d8a89'\n */\n deviceId: string;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface GetVersionCodeResult {\n /**\n * The version code of the app.\n *\n * On **Android**, this is the `versionCode` from the `android/app/build.gradle` file.\n * On **iOS**, this is the `CFBundleVersion` from the `Info.plist` file.\n *\n * @since 5.0.0\n * @example \"1\"\n */\n versionCode: string;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface GetVersionNameResult {\n /**\n * The version name of the app.\n *\n * On **Android**, this is the `versionName` from the `android/app/build.gradle` file.\n * On **iOS**, this is the `CFBundleShortVersionString` from the `Info.plist` file.\n *\n * @since 5.0.0\n * @example \"1.0.0\"\n */\n versionName: string;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface GetCustomIdResult {\n /**\n * The custom identifier of the app.\n *\n * If `null`, no custom identifier is set.\n *\n * @since 5.0.0\n * @example '50d2a548-80b7-4dad-adc7-97c0e79d8a89'\n */\n customId: string | null;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface SetBundleOptions {\n /**\n * The unique identifier of the bundle to use.\n *\n * @since 5.0.0\n * @example '1.0.0'\n */\n bundleId: string;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface SetChannelOptions {\n /**\n * The channel of the app.\n *\n * Set `null` to remove the channel.\n *\n * @since 5.0.0\n */\n channel: string | null;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface SetCustomIdOptions {\n /**\n * The custom identifier of the app.\n *\n * Set `null` to remove the custom identifier.\n *\n * @since 5.0.0\n */\n customId: string | null;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface SyncResult {\n /**\n * The identifier of the next bundle to use.\n *\n * If `null`, the app is up-to-date and no new bundle is available.\n *\n * @since 5.0.0\n */\n nextBundleId: string | null;\n}\n"]}
1
+ {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"AAAA,wCAAwC","sourcesContent":["/// <reference types=\"@capacitor/cli\" />\n\ndeclare module '@capacitor/cli' {\n export interface PluginsConfig {\n LiveUpdate?: {\n /**\n * The app ID is used to identify the app when using [Capawesome Cloud](https://capawesome.io/cloud).\n *\n * @since 5.0.0\n * @example '4100e356-e851-47fe-9d3c-b411eb325a99'\n */\n appId?: string;\n /**\n * Whether or not to automatically delete unused bundles.\n *\n * When enabled, the plugin will automatically delete unused bundles after calling `ready()`.\n *\n * @since 5.0.0\n * @default false\n */\n autoDeleteBundles?: boolean;\n /**\n * Whether or not the plugin is enabled.\n *\n * @since 5.0.0\n * @default true\n */\n enabled?: boolean;\n /**\n * The location of the server to use when using [Capawesome Cloud](https://capawesome.io/cloud).\n *\n * @since 6.2.0\n * @default 'us'\n * @example 'eu'\n * @see https://capawesome.io/cloud/live-updates/advanced/privacy/\n */\n location?: 'us' | 'eu';\n /**\n * The public key to verify the integrity of the bundle.\n *\n * The public key must be a PEM-encoded RSA public key.\n *\n * @since 6.1.0\n * @example '-----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDodf1SD0OOn6hIlDuKBza0Ed0OqtwyVJwiyjmE9BJaZ7y8ZUfcF+SKmd0l2cDPM45XIg2tAFux5n29uoKyHwSt+6tCi5CJA5Z1/1eZruRRqABLonV77KS3HUtvOgqRLDnKSV89dYZkM++NwmzOPgIF422mvc+VukcVOBfc8/AHQIDAQAB-----END PUBLIC KEY-----'\n */\n publicKey?: string;\n /**\n * The timeout in milliseconds to wait for the app to be ready before resetting to the default bundle.\n *\n * @since 5.0.0\n * @default 10000\n */\n readyTimeout?: number;\n /**\n * Whether or not the app should be reset to the default bundle during an update.\n *\n * @since 5.0.0\n * @default true\n */\n resetOnUpdate?: boolean;\n };\n }\n}\n\nexport interface LiveUpdatePlugin {\n /**\n * Delete a bundle from the app.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n deleteBundle(options: DeleteBundleOptions): Promise<void>;\n /**\n * Download a bundle.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n downloadBundle(options: DownloadBundleOptions): Promise<void>;\n /**\n * Get the active bundle identifier.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n getBundle(): Promise<GetBundleResult>;\n /**\n * Get all available bundle identifiers.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n getBundles(): Promise<GetBundlesResult>;\n /**\n * Get the channel of the app.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n getChannel(): Promise<GetChannelResult>;\n /**\n * Get the custom identifier of the app.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n getCustomId(): Promise<GetCustomIdResult>;\n /**\n * Get the device identifier of the app.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n getDeviceId(): Promise<GetDeviceIdResult>;\n /**\n * Get the version code of the app.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n getVersionCode(): Promise<GetVersionCodeResult>;\n /**\n * Get the version name of the app.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n getVersionName(): Promise<GetVersionNameResult>;\n /**\n * Notify the plugin that the app is ready to use and no rollback is needed.\n *\n * **Attention**: This method should be called as soon as the app is ready to use\n * to prevent the app from being reset to the default bundle.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n ready(): Promise<void>;\n /**\n * Reload the app to apply the new bundle.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n reload(): Promise<void>;\n /**\n * Reset the app to the default bundle.\n *\n * Call `reload()` or restart the app to apply the changes.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n reset(): Promise<void>;\n /**\n * Set the next bundle to use for the app.\n *\n * Call `reload()` or restart the app to apply the changes.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n setBundle(options: SetBundleOptions): Promise<void>;\n /**\n * Set the channel of the app.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n setChannel(options: SetChannelOptions): Promise<void>;\n /**\n * Set the custom identifier of the app.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n setCustomId(options: SetCustomIdOptions): Promise<void>;\n /**\n * Automatically download and set the latest bundle for the app using the [Capawesome Cloud](https://capawesome.io/cloud/).\n *\n * Call `reload()` or restart the app to apply the changes.\n *\n * Only available on Android and iOS.\n *\n * @since 5.0.0\n */\n sync(): Promise<SyncResult>;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface DeleteBundleOptions {\n /**\n * The unique identifier of the bundle to delete.\n *\n * @since 5.0.0\n * @example '1.0.0'\n */\n bundleId: string;\n}\n\nexport interface DownloadBundleOptions {\n /**\n * The unique identifier of the bundle.\n *\n * @since 5.0.0\n * @example '1.0.0'\n */\n bundleId: string;\n /**\n * The checksum of the bundle to verify the integrity of the ZIP file.\n *\n * Must be a SHA-256 hash in hexadecimal format.\n *\n * @since 6.1.0\n * @example 'aab8e0a312e807c8eaeb923e2bdccd7731c5508869c62ad8e6e43ee881414c84'\n */\n checksum?: string;\n /**\n * The URL of the bundle to download.\n *\n * The bundle must be a ZIP file containing at least a `index.html` file.\n *\n * @since 5.0.0\n * @example 'https://example.com/bundle.zip'\n */\n url: string;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface GetBundleResult {\n /**\n * The unique identifier of the active bundle.\n *\n * If `null`, the default bundle is being used.\n *\n * @since 5.0.0\n * @example '1.0.0'\n */\n bundleId: string | null;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface GetBundlesResult {\n /**\n * An array of unique identifiers of all available bundles.\n *\n * @since 5.0.0\n */\n bundleIds: string[];\n}\n\n/**\n * @since 5.0.0\n */\nexport interface GetChannelResult {\n /**\n * The channel of the app.\n *\n * If `null`, the app is using the default channel.\n *\n * @since 5.0.0\n * @example 'production'\n */\n channel: string | null;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface GetDeviceIdResult {\n /**\n * The unique identifier of the device.\n *\n * On iOS, [`identifierForVendor`](https://developer.apple.com/documentation/uikit/uidevice/1620059-identifierforvendor) is used.\n * The value of this property is the same for apps that come from the same vendor running on the same device.\n *\n * @since 5.0.0\n *\n * @example '50d2a548-80b7-4dad-adc7-97c0e79d8a89'\n */\n deviceId: string;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface GetVersionCodeResult {\n /**\n * The version code of the app.\n *\n * On **Android**, this is the `versionCode` from the `android/app/build.gradle` file.\n * On **iOS**, this is the `CFBundleVersion` from the `Info.plist` file.\n *\n * @since 5.0.0\n * @example \"1\"\n */\n versionCode: string;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface GetVersionNameResult {\n /**\n * The version name of the app.\n *\n * On **Android**, this is the `versionName` from the `android/app/build.gradle` file.\n * On **iOS**, this is the `CFBundleShortVersionString` from the `Info.plist` file.\n *\n * @since 5.0.0\n * @example \"1.0.0\"\n */\n versionName: string;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface GetCustomIdResult {\n /**\n * The custom identifier of the app.\n *\n * If `null`, no custom identifier is set.\n *\n * @since 5.0.0\n * @example '50d2a548-80b7-4dad-adc7-97c0e79d8a89'\n */\n customId: string | null;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface SetBundleOptions {\n /**\n * The unique identifier of the bundle to use.\n *\n * @since 5.0.0\n * @example '1.0.0'\n */\n bundleId: string;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface SetChannelOptions {\n /**\n * The channel of the app.\n *\n * Set `null` to remove the channel.\n *\n * @since 5.0.0\n */\n channel: string | null;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface SetCustomIdOptions {\n /**\n * The custom identifier of the app.\n *\n * Set `null` to remove the custom identifier.\n *\n * @since 5.0.0\n */\n customId: string | null;\n}\n\n/**\n * @since 5.0.0\n */\nexport interface SyncResult {\n /**\n * The identifier of the next bundle to use.\n *\n * If `null`, the app is up-to-date and no new bundle is available.\n *\n * @since 5.0.0\n */\n nextBundleId: string | null;\n}\n"]}
@@ -7,8 +7,12 @@ public enum CustomError: Error {
7
7
  case bundleIndexHtmlMissing
8
8
  case bundleNotFound
9
9
  case channelMissing
10
+ case checksumCalculationFailed
11
+ case checksumMismatch
10
12
  case customIdMissing
11
13
  case downloadFailed
14
+ case signatureMissing
15
+ case signatureVerificationFailed
12
16
  case urlMissing
13
17
  }
14
18
 
@@ -27,10 +31,18 @@ extension CustomError: LocalizedError {
27
31
  return NSLocalizedString("bundle not found.", comment: "bundleNotFound")
28
32
  case .channelMissing:
29
33
  return NSLocalizedString("channel must be provided.", comment: "channelMissing")
34
+ case .checksumCalculationFailed:
35
+ return NSLocalizedString("Failed to calculate checksum.", comment: "checksumCalculationFailed")
36
+ case .checksumMismatch:
37
+ return NSLocalizedString("Checksum mismatch.", comment: "checksumMismatch")
30
38
  case .customIdMissing:
31
39
  return NSLocalizedString("customId must be provided.", comment: "customIdMissing")
32
40
  case .downloadFailed:
33
41
  return NSLocalizedString("Bundle could not be downloaded.", comment: "downloadFailed")
42
+ case .signatureMissing:
43
+ return NSLocalizedString("Bundle does not contain a signature.", comment: "signatureMissing")
44
+ case .signatureVerificationFailed:
45
+ return NSLocalizedString("Signature verification failed.", comment: "signatureVerificationFailed")
34
46
  case .urlMissing:
35
47
  return NSLocalizedString("url must be provided.", comment: "urlMissing")
36
48
  }
@@ -3,10 +3,12 @@ import Capacitor
3
3
 
4
4
  @objc public class DownloadBundleOptions: NSObject {
5
5
  private var bundleId: String
6
+ private var checksum: String?
6
7
  private var url: String
7
8
 
8
- init(bundleId: String, url: String) {
9
+ init(bundleId: String, checksum: String?, url: String) {
9
10
  self.bundleId = bundleId
11
+ self.checksum = checksum
10
12
  self.url = url
11
13
  }
12
14
 
@@ -14,6 +16,10 @@ import Capacitor
14
16
  return bundleId
15
17
  }
16
18
 
19
+ func getChecksum() -> String? {
20
+ return checksum
21
+ }
22
+
17
23
  func getUrl() -> String {
18
24
  return url
19
25
  }
@@ -1,7 +1,9 @@
1
1
  import Foundation
2
+ import CryptoKit
2
3
  import SSZipArchive
3
4
  import Capacitor
4
5
  import Alamofire
6
+ import CommonCrypto
5
7
 
6
8
  // swiftlint:disable type_body_length
7
9
  @objc public class LiveUpdate: NSObject {
@@ -50,8 +52,9 @@ import Alamofire
50
52
  }
51
53
 
52
54
  @objc public func downloadBundle(_ options: DownloadBundleOptions, completion: @escaping (Error?) -> Void) {
53
- let url = options.getUrl()
54
55
  let bundleId = options.getBundleId()
56
+ let checksum = options.getChecksum()
57
+ let url = options.getUrl()
55
58
 
56
59
  // Check if the bundle already exists
57
60
  if hasBundle(bundleId: bundleId) {
@@ -61,7 +64,7 @@ import Alamofire
61
64
  }
62
65
 
63
66
  // Download the bundle
64
- downloadBundle(bundleId: bundleId, url: url, completion: { error in
67
+ downloadBundle(bundleId: bundleId, checksum: checksum, signature: nil, url: url, completion: { error in
65
68
  if let error = error {
66
69
  completion(error)
67
70
  return
@@ -187,7 +190,7 @@ import Alamofire
187
190
  completion(result, nil)
188
191
  } else {
189
192
  // Download and unzip the bundle
190
- self.downloadBundle(bundleId: latestBundleId, url: response.url, completion: { error in
193
+ self.downloadBundle(bundleId: latestBundleId, checksum: response.checksum, signature: response.signature, url: response.url, completion: { error in
191
194
  if let error = error {
192
195
  completion(nil, error)
193
196
  return
@@ -241,6 +244,20 @@ import Alamofire
241
244
  return url.path
242
245
  }
243
246
 
247
+ /// - Returns: The sha256 checksum of the file at the given URL.
248
+ private func getChecksumForFile(url: URL) throws -> String {
249
+ let handle = try FileHandle(forReadingFrom: url)
250
+ var hasher = SHA256()
251
+ while autoreleasepool(invoking: {
252
+ let nextChunk = handle.readData(ofLength: 2048)
253
+ guard !nextChunk.isEmpty else { return false }
254
+ hasher.update(data: nextChunk)
255
+ return true
256
+ }) { }
257
+ let digest = hasher.finalize()
258
+ return digest.map { String(format: "%02hhx", $0) }.joined()
259
+ }
260
+
244
261
  private func createBundlesDirectory() {
245
262
  let bundlesDirectoryUrl = libraryDirectoryUrl.appendingPathComponent(bundlesDirectory)
246
263
  let exists = FileManager.default.fileExists(atPath: bundlesDirectoryUrl.path)
@@ -274,7 +291,7 @@ import Alamofire
274
291
  }
275
292
  }
276
293
 
277
- private func downloadBundle(bundleId: String, url: String, completion: @escaping (Error?) -> Void) {
294
+ private func downloadBundle(bundleId: String, checksum: String?, signature: String?, url: String, completion: @escaping (Error?) -> Void) {
278
295
  // Download the bundle
279
296
  downloadFile(url: url, completion: { url, error in
280
297
  if let error = error {
@@ -282,6 +299,39 @@ import Alamofire
282
299
  return
283
300
  }
284
301
  if let url = url {
302
+ // Verify the signature
303
+ if let publicKey = self.config.publicKey {
304
+ guard let signature = signature else {
305
+ completion(CustomError.signatureMissing)
306
+ return
307
+ }
308
+ // Verify the signature
309
+ do {
310
+ let verified = try self.verifySignatureForFile(url: url, signature: signature, publicKey: publicKey)
311
+ if !verified {
312
+ completion(CustomError.signatureVerificationFailed)
313
+ return
314
+ }
315
+ } catch {
316
+ completion(CustomError.signatureVerificationFailed)
317
+ return
318
+ }
319
+ }
320
+ // Verify the checksum
321
+ else if let expectedChecksum = checksum {
322
+ // Calculate the checksum
323
+ do {
324
+ let receivedChecksum = try self.getChecksumForFile(url: url)
325
+ if receivedChecksum != expectedChecksum {
326
+ completion(CustomError.checksumMismatch)
327
+ return
328
+ }
329
+ } catch {
330
+ completion(CustomError.checksumCalculationFailed)
331
+ return
332
+ }
333
+ }
334
+
285
335
  // Add the bundle
286
336
  self.addBundle(bundleId: bundleId, zipFile: url, completion: { error in
287
337
  // Delete the temporary file
@@ -328,7 +378,13 @@ import Alamofire
328
378
  parameters["osVersion"] = UIDevice.current.systemVersion
329
379
  parameters["platform"] = "1"
330
380
  parameters["pluginVersion"] = LiveUpdatePlugin.version
331
- let url = URL(string: "https://api.cloud.capawesome.io/v1/apps/\(config.appId ?? "")/bundles/latest")!
381
+ var host = "api.cloud.capawesome.io"
382
+ if let location = config.location {
383
+ if location == "eu" {
384
+ host = "api.cloud.capawesome.eu"
385
+ }
386
+ }
387
+ let url = URL(string: "https://\(host)/v1/apps/\(config.appId ?? "")/bundles/latest")!
332
388
  AF.request(url, method: .get, parameters: parameters).validate().responseDecodable(of: GetLatestBundleResponse.self) { response in
333
389
  CAPLog.print("[", LiveUpdatePlugin.tag, "] Fetching latest bundle from ", response.request?.url?.absoluteString ?? "")
334
390
  if let error = response.error {
@@ -492,6 +548,64 @@ import Alamofire
492
548
  })
493
549
  }
494
550
 
551
+ private func verifySignatureForFile(url: URL, signature: String, publicKey: String) throws -> Bool {
552
+ let publicKeyAsBase64 = publicKey
553
+ .replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "")
554
+ .replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "")
555
+ .replacingOccurrences(of: "\n", with: "")
556
+ guard let publicKeyData = Data(base64Encoded: publicKeyAsBase64) else {
557
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to decode public key.")
558
+ return false
559
+ }
560
+ let publicKeyAttributes: [CFString: Any] = [
561
+ kSecAttrKeyType: kSecAttrKeyTypeRSA,
562
+ kSecAttrKeyClass: kSecAttrKeyClassPublic,
563
+ kSecAttrKeySizeInBits: 2048,
564
+ kSecReturnPersistentRef: true
565
+ ]
566
+ var secKeyCreateWithDataError: Unmanaged<CFError>?
567
+ guard let publicKey = SecKeyCreateWithData(publicKeyData as CFData, publicKeyAttributes as CFDictionary, &secKeyCreateWithDataError) else {
568
+ if let error = secKeyCreateWithDataError?.takeRetainedValue() {
569
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to create public key with error: \(error)")
570
+ }
571
+ return false
572
+ }
573
+ guard let signatureData = Data(base64Encoded: signature) else {
574
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to decode signature.")
575
+ return false
576
+ }
577
+
578
+ // Create SHA256 digest
579
+ var digestContext = CC_SHA256_CTX()
580
+ CC_SHA256_Init(&digestContext)
581
+
582
+ // Update the digest with the file's data
583
+ let handle = try FileHandle(forReadingFrom: url)
584
+ while autoreleasepool(invoking: {
585
+ let nextChunk = handle.readData(ofLength: 2048)
586
+ guard !nextChunk.isEmpty else { return false }
587
+ nextChunk.withUnsafeBytes {
588
+ _ = CC_SHA256_Update(&digestContext, $0.baseAddress, CC_LONG(nextChunk.count))
589
+ }
590
+ return true
591
+ }) { }
592
+
593
+ // Compute the digest
594
+ var digest = Data(count: Int(CC_SHA256_DIGEST_LENGTH))
595
+ digest.withUnsafeMutableBytes {
596
+ _ = CC_SHA256_Final($0.bindMemory(to: UInt8.self).baseAddress, &digestContext)
597
+ }
598
+
599
+ // Verify the signature
600
+ var secKeyVerifySignatureError: Unmanaged<CFError>?
601
+ let signatureAlgorithm = SecKeyAlgorithm.rsaSignatureDigestPKCS1v15SHA256
602
+ let verificationResult = SecKeyVerifySignature(publicKey, signatureAlgorithm, digest as CFData, signatureData as CFData, &secKeyVerifySignatureError)
603
+ if let error = secKeyVerifySignatureError?.takeRetainedValue() {
604
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to verify signature with error: \(error)")
605
+ }
606
+ return verificationResult
607
+ }
608
+
495
609
  private func wasUpdated() -> Bool {
496
610
  guard let lastBundleVersionString = preferences.getLastBundleVersion() else {
497
611
  return false
@@ -2,6 +2,8 @@ public struct LiveUpdateConfig {
2
2
  var appId: String?
3
3
  var autoDeleteBundles = false
4
4
  var enabled = true
5
+ var location: String?
6
+ var publicKey: String?
5
7
  var readyTimeout = 10000
6
8
  var resetOnUpdate = true
7
9
  }
@@ -8,7 +8,7 @@ import Capacitor
8
8
  @objc(LiveUpdatePlugin)
9
9
  public class LiveUpdatePlugin: CAPPlugin {
10
10
  public static let tag = "LiveUpdate"
11
- public static let version = "6.0.7"
11
+ public static let version = "6.2.0"
12
12
  public static let userDefaultsPrefix = "CapawesomeLiveUpdate" // DO NOT CHANGE
13
13
 
14
14
  private var config: LiveUpdateConfig?
@@ -42,12 +42,13 @@ public class LiveUpdatePlugin: CAPPlugin {
42
42
  call.reject(CustomError.bundleIdMissing.localizedDescription)
43
43
  return
44
44
  }
45
+ let checksum = call.getString("checksum")
45
46
  guard let url = call.getString("url") else {
46
47
  call.reject(CustomError.urlMissing.localizedDescription)
47
48
  return
48
49
  }
49
50
 
50
- let options = DownloadBundleOptions(bundleId: bundleId, url: url)
51
+ let options = DownloadBundleOptions(bundleId: bundleId, checksum: checksum, url: url)
51
52
 
52
53
  implementation?.downloadBundle(options, completion: { error in
53
54
  if let error = error {
@@ -239,6 +240,8 @@ public class LiveUpdatePlugin: CAPPlugin {
239
240
  config.appId = getConfig().getString("appId", config.appId)
240
241
  config.autoDeleteBundles = getConfig().getBoolean("autoDeleteBundles", config.autoDeleteBundles)
241
242
  config.enabled = getConfig().getBoolean("enabled", config.enabled)
243
+ config.location = getConfig().getString("location", config.location)
244
+ config.publicKey = getConfig().getString("publicKey", config.publicKey)
242
245
  config.readyTimeout = getConfig().getInt("readyTimeout", config.readyTimeout)
243
246
  config.resetOnUpdate = getConfig().getBoolean("resetOnUpdate", config.resetOnUpdate)
244
247
 
@@ -1,4 +1,6 @@
1
1
  struct GetLatestBundleResponse: Codable {
2
2
  var bundleId: String
3
+ var checksum: String?
4
+ var signature: String?
3
5
  var url: String
4
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capawesome/capacitor-live-update",
3
- "version": "6.0.8",
3
+ "version": "6.2.0",
4
4
  "description": "Capacitor plugin to update your app remotely in real-time.",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",