@capawesome/capacitor-live-update 6.0.7 → 6.1.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
@@ -12,6 +12,8 @@ Capacitor plugin to update your app remotely in real-time.
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
 
@@ -68,6 +70,7 @@ We recommend to declare [`CA92.1`](https://developer.apple.com/documentation/bun
68
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 |
69
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 |
70
72
  | **`enabled`** | <code>boolean</code> | Whether or not the plugin is enabled. | <code>true</code> | 5.0.0 |
73
+ | **`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 |
71
74
  | **`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
75
  | **`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
76
 
@@ -82,6 +85,7 @@ In `capacitor.config.json`:
82
85
  "appId": '4100e356-e851-47fe-9d3c-b411eb325a99',
83
86
  "autoDeleteBundles": undefined,
84
87
  "enabled": undefined,
88
+ "publicKey": '-----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDodf1SD0OOn6hIlDuKBza0Ed0OqtwyVJwiyjmE9BJaZ7y8ZUfcF+SKmd0l2cDPM45XIg2tAFux5n29uoKyHwSt+6tCi5CJA5Z1/1eZruRRqABLonV77KS3HUtvOgqRLDnKSV89dYZkM++NwmzOPgIF422mvc+VukcVOBfc8/AHQIDAQAB-----END PUBLIC KEY-----',
85
89
  "readyTimeout": undefined,
86
90
  "resetOnUpdate": undefined
87
91
  }
@@ -102,6 +106,7 @@ const config: CapacitorConfig = {
102
106
  appId: '4100e356-e851-47fe-9d3c-b411eb325a99',
103
107
  autoDeleteBundles: undefined,
104
108
  enabled: undefined,
109
+ publicKey: '-----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDodf1SD0OOn6hIlDuKBza0Ed0OqtwyVJwiyjmE9BJaZ7y8ZUfcF+SKmd0l2cDPM45XIg2tAFux5n29uoKyHwSt+6tCi5CJA5Z1/1eZruRRqABLonV77KS3HUtvOgqRLDnKSV89dYZkM++NwmzOPgIF422mvc+VukcVOBfc8/AHQIDAQAB-----END PUBLIC KEY-----',
105
110
  readyTimeout: undefined,
106
111
  resetOnUpdate: undefined,
107
112
  },
@@ -398,6 +403,9 @@ ready() => Promise<void>
398
403
 
399
404
  Notify the plugin that the app is ready to use and no rollback is needed.
400
405
 
406
+ **Attention**: This method should be called as soon as the app is ready to use
407
+ to prevent the app from being reset to the default bundle.
408
+
401
409
  Only available on Android and iOS.
402
410
 
403
411
  **Since:** 5.0.0
@@ -527,10 +535,11 @@ Only available on Android and iOS.
527
535
 
528
536
  #### DownloadBundleOptions
529
537
 
530
- | Prop | Type | Description | Since |
531
- | -------------- | ------------------- | --------------------------------------------------------------------------------------------------------- | ----- |
532
- | **`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 |
533
- | **`bundleId`** | <code>string</code> | The unique identifier of the bundle. | 5.0.0 |
538
+ | Prop | Type | Description | Since |
539
+ | -------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------- | ----- |
540
+ | **`bundleId`** | <code>string</code> | The unique identifier of the bundle. | 5.0.0 |
541
+ | **`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 |
542
+ | **`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 |
534
543
 
535
544
 
536
545
  #### GetBundleResult
@@ -627,7 +636,7 @@ If you want to disable the plugin to test other parts of your app, you can set t
627
636
 
628
637
  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).
629
638
  If you change native code, such as adding a new plugin, you need to resubmit your app to the app stores.
630
- For this reason, you must be careful to limit live updates to compatible native versions of your app.
639
+ 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.
631
640
 
632
641
  ## Changelog
633
642
 
@@ -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) {
@@ -164,9 +171,7 @@ public class LiveUpdate {
164
171
  }
165
172
 
166
173
  public void reload() {
167
- String currentPath = getCurrentCapacitorServerPath();
168
- String nextPath = getNextCapacitorServerPath();
169
- String path = nextPath == null ? currentPath : nextPath;
174
+ String path = getNextCapacitorServerPath();
170
175
  setCurrentCapacitorServerPath(path);
171
176
  startRollbackTimer();
172
177
  }
@@ -217,6 +222,8 @@ public class LiveUpdate {
217
222
  @Override
218
223
  public void success(@NonNull GetLatestBundleResponse result) {
219
224
  String latestBundleId = result.getBundleId();
225
+ String checksum = result.getChecksum();
226
+ String signature = result.getSignature();
220
227
  String url = result.getUrl();
221
228
 
222
229
  // Check if the bundle already exists
@@ -235,6 +242,8 @@ public class LiveUpdate {
235
242
  // Download and unzip the bundle
236
243
  downloadBundle(
237
244
  latestBundleId,
245
+ checksum,
246
+ signature,
238
247
  url,
239
248
  new EmptyCallback() {
240
249
  @Override
@@ -292,6 +301,31 @@ public class LiveUpdate {
292
301
  return new File(plugin.getContext().getFilesDir(), bundlesDirectory + "/" + bundle);
293
302
  }
294
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
+
295
329
  private void createBundlesDirectory() {
296
330
  File bundlesDirectory = buildBundlesDirectory();
297
331
  if (!bundlesDirectory.exists()) {
@@ -299,6 +333,19 @@ public class LiveUpdate {
299
333
  }
300
334
  }
301
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
+
302
349
  private void deleteBundle(@NonNull String bundleId) {
303
350
  File bundleDirectory = buildBundleDirectoryFor(bundleId);
304
351
  deleteFileRecursively(bundleDirectory);
@@ -322,7 +369,13 @@ public class LiveUpdate {
322
369
  }
323
370
  }
324
371
 
325
- 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
+ ) {
326
379
  downloadFile(
327
380
  url,
328
381
  new NonEmptyCallback<File>() {
@@ -334,6 +387,28 @@ public class LiveUpdate {
334
387
  @Override
335
388
  public void success(@NonNull File result) {
336
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
+
337
412
  // Add the bundle
338
413
  addBundle(bundleId, result);
339
414
 
@@ -415,6 +490,7 @@ public class LiveUpdate {
415
490
  .addQueryParameter("platform", "0")
416
491
  .addQueryParameter("pluginVersion", LiveUpdatePlugin.VERSION)
417
492
  .build();
493
+ Logger.debug(LiveUpdatePlugin.TAG, "Fetching latest bundle from " + url);
418
494
  OkHttpClient client = new OkHttpClient();
419
495
  okhttp3.Request request = new okhttp3.Request.Builder().url(url).build();
420
496
  client
@@ -423,6 +499,7 @@ public class LiveUpdate {
423
499
  new okhttp3.Callback() {
424
500
  @Override
425
501
  public void onFailure(@NonNull okhttp3.Call call, @NonNull IOException exception) {
502
+ Logger.error(LiveUpdatePlugin.TAG, exception.getMessage(), exception);
426
503
  callback.error(exception);
427
504
  }
428
505
 
@@ -472,6 +549,9 @@ public class LiveUpdate {
472
549
  return bundleIds;
473
550
  }
474
551
 
552
+ /**
553
+ * @return The current bundle ID (`public` for the built-in bundle).
554
+ */
475
555
  private String getCurrentBundleId() {
476
556
  String currentPath = getCurrentCapacitorServerPath();
477
557
  if (currentPath.equals(defaultWebAssetDir)) {
@@ -480,6 +560,9 @@ public class LiveUpdate {
480
560
  return new File(currentPath).getName();
481
561
  }
482
562
 
563
+ /**
564
+ * @return The absolute path to the current bundle directory (`public` for the built-in bundle).
565
+ */
483
566
  private String getCurrentCapacitorServerPath() {
484
567
  return plugin.getBridge().getServerBasePath();
485
568
  }
@@ -494,23 +577,27 @@ public class LiveUpdate {
494
577
  return deviceId;
495
578
  }
496
579
 
580
+ /**
581
+ * @return The next bundle ID (`public` for the built-in bundle).
582
+ */
583
+ @NonNull
497
584
  private String getNextBundleId() {
498
585
  String nextPath = getNextCapacitorServerPath();
499
- if (nextPath == null) {
500
- return null;
501
- }
502
586
  if (nextPath.equals(defaultWebAssetDir)) {
503
587
  return defaultWebAssetDir;
504
588
  }
505
589
  return new File(nextPath).getName();
506
590
  }
507
591
 
508
- @Nullable
592
+ /**
593
+ * @return The absolute path to the next bundle directory (`public` for the built-in bundle).
594
+ */
595
+ @NonNull
509
596
  private String getNextCapacitorServerPath() {
510
597
  return plugin
511
598
  .getContext()
512
599
  .getSharedPreferences(WebView.WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE)
513
- .getString(WebView.CAP_SERVER_PATH, null);
600
+ .getString(WebView.CAP_SERVER_PATH, defaultWebAssetDir);
514
601
  }
515
602
 
516
603
  private boolean hasBundle(@NonNull String bundleId) {
@@ -607,6 +694,24 @@ public class LiveUpdate {
607
694
  return destination;
608
695
  }
609
696
 
697
+ private boolean verifySignatureForFile(@NonNull File file, @NonNull String signature, @NonNull PublicKey key) throws Exception {
698
+ try {
699
+ byte[] signatureBytes = Base64.decode(signature, Base64.DEFAULT);
700
+ Signature sig = Signature.getInstance("SHA256withRSA");
701
+ sig.initVerify(key);
702
+ BufferedSource source = Okio.buffer(Okio.source(file));
703
+ Buffer buffer = new Buffer();
704
+ for (long bytesRead; (bytesRead = source.read(buffer, 2048)) != -1;) {
705
+ sig.update(buffer.readByteArray());
706
+ }
707
+ source.close();
708
+ return sig.verify(signatureBytes);
709
+ } catch (Exception exception) {
710
+ Logger.error(LiveUpdatePlugin.TAG, exception.getMessage(), exception);
711
+ throw new Exception(LiveUpdatePlugin.ERROR_SIGNATURE_VERIFICATION_FAILED);
712
+ }
713
+ }
714
+
610
715
  private boolean wasUpdated() throws PackageManager.NameNotFoundException {
611
716
  int lastVersionCode = preferences.getLastVersionCode();
612
717
  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,10 @@ public class LiveUpdateConfig {
9
10
 
10
11
  private boolean autoDeleteBundles = false;
11
12
  private boolean enabled = true;
13
+
14
+ @Nullable
15
+ private String publicKey = null;
16
+
12
17
  private int readyTimeout = 10000;
13
18
  private boolean resetOnUpdate = true;
14
19
 
@@ -25,6 +30,11 @@ public class LiveUpdateConfig {
25
30
  return enabled;
26
31
  }
27
32
 
33
+ @Nullable
34
+ public String getPublicKey() {
35
+ return publicKey;
36
+ }
37
+
28
38
  public int getReadyTimeout() {
29
39
  return readyTimeout;
30
40
  }
@@ -45,6 +55,10 @@ public class LiveUpdateConfig {
45
55
  this.enabled = enabled;
46
56
  }
47
57
 
58
+ public void setPublicKey(@Nullable String publicKey) {
59
+ this.publicKey = publicKey;
60
+ }
61
+
48
62
  public void setReadyTimeout(int readyTimeout) {
49
63
  this.readyTimeout = readyTimeout;
50
64
  }
@@ -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.1.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,8 @@ 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 publicKey = getConfig().getString("publicKey", config.getPublicKey());
437
+ config.setPublicKey(publicKey);
430
438
  int readyTimeout = getConfig().getInt("readyTimeout", config.getReadyTimeout());
431
439
  config.setReadyTimeout(readyTimeout);
432
440
  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
@@ -181,7 +181,7 @@
181
181
  "text": "5.0.0"
182
182
  }
183
183
  ],
184
- "docs": "Notify the plugin that the app is ready to use and no rollback is needed.\n\nOnly available on Android and iOS.",
184
+ "docs": "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\nto prevent the app from being reset to the default bundle.\n\nOnly available on Android and iOS.",
185
185
  "complexTypes": [],
186
186
  "slug": "ready"
187
187
  },
@@ -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,22 @@
746
762
  "complexTypes": [],
747
763
  "type": "boolean | undefined"
748
764
  },
765
+ {
766
+ "name": "publicKey",
767
+ "tags": [
768
+ {
769
+ "text": "6.1.0",
770
+ "name": "since"
771
+ },
772
+ {
773
+ "text": "'-----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDodf1SD0OOn6hIlDuKBza0Ed0OqtwyVJwiyjmE9BJaZ7y8ZUfcF+SKmd0l2cDPM45XIg2tAFux5n29uoKyHwSt+6tCi5CJA5Z1/1eZruRRqABLonV77KS3HUtvOgqRLDnKSV89dYZkM++NwmzOPgIF422mvc+VukcVOBfc8/AHQIDAQAB-----END PUBLIC KEY-----'",
774
+ "name": "example"
775
+ }
776
+ ],
777
+ "docs": "The public key to verify the integrity of the bundle.\n\nThe public key must be a PEM-encoded RSA public key.",
778
+ "complexTypes": [],
779
+ "type": "string | undefined"
780
+ },
749
781
  {
750
782
  "name": "readyTimeout",
751
783
  "tags": [
@@ -24,6 +24,15 @@ declare module '@capacitor/cli' {
24
24
  * @default true
25
25
  */
26
26
  enabled?: boolean;
27
+ /**
28
+ * The public key to verify the integrity of the bundle.
29
+ *
30
+ * The public key must be a PEM-encoded RSA public key.
31
+ *
32
+ * @since 6.1.0
33
+ * @example '-----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDodf1SD0OOn6hIlDuKBza0Ed0OqtwyVJwiyjmE9BJaZ7y8ZUfcF+SKmd0l2cDPM45XIg2tAFux5n29uoKyHwSt+6tCi5CJA5Z1/1eZruRRqABLonV77KS3HUtvOgqRLDnKSV89dYZkM++NwmzOPgIF422mvc+VukcVOBfc8/AHQIDAQAB-----END PUBLIC KEY-----'
34
+ */
35
+ publicKey?: string;
27
36
  /**
28
37
  * The timeout in milliseconds to wait for the app to be ready before resetting to the default bundle.
29
38
  *
@@ -117,6 +126,9 @@ export interface LiveUpdatePlugin {
117
126
  /**
118
127
  * Notify the plugin that the app is ready to use and no rollback is needed.
119
128
  *
129
+ * **Attention**: This method should be called as soon as the app is ready to use
130
+ * to prevent the app from being reset to the default bundle.
131
+ *
120
132
  * Only available on Android and iOS.
121
133
  *
122
134
  * @since 5.0.0
@@ -190,6 +202,22 @@ export interface DeleteBundleOptions {
190
202
  bundleId: string;
191
203
  }
192
204
  export interface DownloadBundleOptions {
205
+ /**
206
+ * The unique identifier of the bundle.
207
+ *
208
+ * @since 5.0.0
209
+ * @example '1.0.0'
210
+ */
211
+ bundleId: string;
212
+ /**
213
+ * The checksum of the bundle to verify the integrity of the ZIP file.
214
+ *
215
+ * Must be a SHA-256 hash in hexadecimal format.
216
+ *
217
+ * @since 6.1.0
218
+ * @example 'aab8e0a312e807c8eaeb923e2bdccd7731c5508869c62ad8e6e43ee881414c84'
219
+ */
220
+ checksum?: string;
193
221
  /**
194
222
  * The URL of the bundle to download.
195
223
  *
@@ -199,13 +227,6 @@ export interface DownloadBundleOptions {
199
227
  * @example 'https://example.com/bundle.zip'
200
228
  */
201
229
  url: string;
202
- /**
203
- * The unique identifier of the bundle.
204
- *
205
- * @since 5.0.0
206
- * @example '1.0.0'
207
- */
208
- bundleId: string;
209
230
  }
210
231
  /**
211
232
  * @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 * 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 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
@@ -125,17 +128,9 @@ import Alamofire
125
128
  }
126
129
 
127
130
  @objc public func reload() {
128
- var path: String?
129
- if let currentPath = getCurrentCapacitorServerPath() {
130
- path = currentPath
131
- }
132
- if let nextPath = getNextCapacitorServerPath() {
133
- path = nextPath
134
- }
135
- if let path = path {
136
- setCurrentCapacitorServerPath(path: path)
137
- startRollbackTimer()
138
- }
131
+ let path = getNextCapacitorServerPath()
132
+ setCurrentCapacitorServerPath(path: path)
133
+ startRollbackTimer()
139
134
  }
140
135
 
141
136
  @objc public func reset() {
@@ -195,7 +190,7 @@ import Alamofire
195
190
  completion(result, nil)
196
191
  } else {
197
192
  // Download and unzip the bundle
198
- 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
199
194
  if let error = error {
200
195
  completion(nil, error)
201
196
  return
@@ -249,6 +244,20 @@ import Alamofire
249
244
  return url.path
250
245
  }
251
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
+
252
261
  private func createBundlesDirectory() {
253
262
  let bundlesDirectoryUrl = libraryDirectoryUrl.appendingPathComponent(bundlesDirectory)
254
263
  let exists = FileManager.default.fileExists(atPath: bundlesDirectoryUrl.path)
@@ -282,7 +291,7 @@ import Alamofire
282
291
  }
283
292
  }
284
293
 
285
- 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) {
286
295
  // Download the bundle
287
296
  downloadFile(url: url, completion: { url, error in
288
297
  if let error = error {
@@ -290,6 +299,39 @@ import Alamofire
290
299
  return
291
300
  }
292
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
+
293
335
  // Add the bundle
294
336
  self.addBundle(bundleId: bundleId, zipFile: url, completion: { error in
295
337
  // Delete the temporary file
@@ -338,8 +380,12 @@ import Alamofire
338
380
  parameters["pluginVersion"] = LiveUpdatePlugin.version
339
381
  let url = URL(string: "https://api.cloud.capawesome.io/v1/apps/\(config.appId ?? "")/bundles/latest")!
340
382
  AF.request(url, method: .get, parameters: parameters).validate().responseDecodable(of: GetLatestBundleResponse.self) { response in
383
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] Fetching latest bundle from ", response.request?.url?.absoluteString ?? "")
341
384
  if let error = response.error {
342
385
  CAPLog.print("[", LiveUpdatePlugin.tag, "] ", error)
386
+ if let data = response.data {
387
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", String(decoding: data, as: UTF8.self))
388
+ }
343
389
  completion(nil, error)
344
390
  return
345
391
  }
@@ -375,6 +421,7 @@ import Alamofire
375
421
  }
376
422
  }
377
423
 
424
+ /// - Returns: The current bundle ID (`public` for the built-in bundle) or `nil` if no view controller was found.
378
425
  private func getCurrentBundleId() -> String? {
379
426
  guard let path = getCurrentCapacitorServerPath() else {
380
427
  return nil
@@ -382,6 +429,7 @@ import Alamofire
382
429
  return URL(fileURLWithPath: path).lastPathComponent
383
430
  }
384
431
 
432
+ /// - Returns: The path to the current bundle directory or `nil` if no view controller was found.
385
433
  private func getCurrentCapacitorServerPath() -> String? {
386
434
  guard let viewController = self.plugin.bridge?.viewController as? CAPBridgeViewController else {
387
435
  return nil
@@ -394,18 +442,19 @@ import Alamofire
394
442
  return deviceId.lowercased()
395
443
  }
396
444
 
397
- private func getNextBundleId() -> String? {
398
- guard let path = getNextCapacitorServerPath() else {
399
- return nil
400
- }
445
+ /// - Returns: The next bundle ID (`public` for the built-in bundle).
446
+ private func getNextBundleId() -> String {
447
+ let path = getNextCapacitorServerPath()
401
448
  return URL(fileURLWithPath: path).lastPathComponent
402
449
  }
403
450
 
404
- private func getNextCapacitorServerPath() -> String? {
451
+ /// - Returns: The absolute path to the next bundle directory.
452
+ private func getNextCapacitorServerPath() -> String {
453
+ let defaultCapacitorServerPath = buildCapacitorServerPathFor(bundleId: defaultWebAssetDir)
405
454
  if let path = KeyValueStore.standard[self.defaultServerPathKey, as: String.self] {
406
- return path.isEmpty ? nil : path
455
+ return path.isEmpty ? defaultCapacitorServerPath : path
407
456
  }
408
- return nil
457
+ return defaultCapacitorServerPath
409
458
  }
410
459
 
411
460
  private func hasBundle(bundleId: String) -> Bool {
@@ -459,7 +508,7 @@ import Alamofire
459
508
 
460
509
  private func setNextCapacitorServerPath(path: String) {
461
510
  if path.hasSuffix("/public") {
462
- // Reset the custom server base path
511
+ // Must set an empty string to reset the custom server base path
463
512
  KeyValueStore.standard[self.defaultServerPathKey] = ""
464
513
  } else {
465
514
  // Attention: Only the lastPathComponent is used (see https://dub.sh/BLluidt)
@@ -493,6 +542,64 @@ import Alamofire
493
542
  })
494
543
  }
495
544
 
545
+ private func verifySignatureForFile(url: URL, signature: String, publicKey: String) throws -> Bool {
546
+ let publicKeyAsBase64 = publicKey
547
+ .replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "")
548
+ .replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "")
549
+ .replacingOccurrences(of: "\n", with: "")
550
+ guard let publicKeyData = Data(base64Encoded: publicKeyAsBase64) else {
551
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to decode public key.")
552
+ return false
553
+ }
554
+ let publicKeyAttributes: [CFString: Any] = [
555
+ kSecAttrKeyType: kSecAttrKeyTypeRSA,
556
+ kSecAttrKeyClass: kSecAttrKeyClassPublic,
557
+ kSecAttrKeySizeInBits: 2048,
558
+ kSecReturnPersistentRef: true
559
+ ]
560
+ var secKeyCreateWithDataError: Unmanaged<CFError>?
561
+ guard let publicKey = SecKeyCreateWithData(publicKeyData as CFData, publicKeyAttributes as CFDictionary, &secKeyCreateWithDataError) else {
562
+ if let error = secKeyCreateWithDataError?.takeRetainedValue() {
563
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to create public key with error: \(error)")
564
+ }
565
+ return false
566
+ }
567
+ guard let signatureData = Data(base64Encoded: signature) else {
568
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to decode signature.")
569
+ return false
570
+ }
571
+
572
+ // Create SHA256 digest
573
+ var digestContext = CC_SHA256_CTX()
574
+ CC_SHA256_Init(&digestContext)
575
+
576
+ // Update the digest with the file's data
577
+ let handle = try FileHandle(forReadingFrom: url)
578
+ while autoreleasepool(invoking: {
579
+ let nextChunk = handle.readData(ofLength: 2048)
580
+ guard !nextChunk.isEmpty else { return false }
581
+ nextChunk.withUnsafeBytes {
582
+ _ = CC_SHA256_Update(&digestContext, $0.baseAddress, CC_LONG(nextChunk.count))
583
+ }
584
+ return true
585
+ }) { }
586
+
587
+ // Compute the digest
588
+ var digest = Data(count: Int(CC_SHA256_DIGEST_LENGTH))
589
+ digest.withUnsafeMutableBytes {
590
+ _ = CC_SHA256_Final($0.bindMemory(to: UInt8.self).baseAddress, &digestContext)
591
+ }
592
+
593
+ // Verify the signature
594
+ var secKeyVerifySignatureError: Unmanaged<CFError>?
595
+ let signatureAlgorithm = SecKeyAlgorithm.rsaSignatureDigestPKCS1v15SHA256
596
+ let verificationResult = SecKeyVerifySignature(publicKey, signatureAlgorithm, digest as CFData, signatureData as CFData, &secKeyVerifySignatureError)
597
+ if let error = secKeyVerifySignatureError?.takeRetainedValue() {
598
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to verify signature with error: \(error)")
599
+ }
600
+ return verificationResult
601
+ }
602
+
496
603
  private func wasUpdated() -> Bool {
497
604
  guard let lastBundleVersionString = preferences.getLastBundleVersion() else {
498
605
  return false
@@ -2,6 +2,7 @@ public struct LiveUpdateConfig {
2
2
  var appId: String?
3
3
  var autoDeleteBundles = false
4
4
  var enabled = true
5
+ var publicKey: String?
5
6
  var readyTimeout = 10000
6
7
  var resetOnUpdate = true
7
8
  }
@@ -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.1.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,7 @@ 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.publicKey = getConfig().getString("publicKey", config.publicKey)
242
244
  config.readyTimeout = getConfig().getInt("readyTimeout", config.readyTimeout)
243
245
  config.resetOnUpdate = getConfig().getBoolean("resetOnUpdate", config.resetOnUpdate)
244
246
 
@@ -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.7",
3
+ "version": "6.1.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",
@@ -36,7 +36,14 @@
36
36
  "keywords": [
37
37
  "capacitor",
38
38
  "plugin",
39
- "native"
39
+ "native",
40
+ "ionic",
41
+ "android",
42
+ "ios",
43
+ "live update",
44
+ "remote update",
45
+ "ota",
46
+ "capgo"
40
47
  ],
41
48
  "scripts": {
42
49
  "verify": "npm run verify:ios && npm run verify:android && npm run verify:web",