@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 +22 -13
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdate.java +102 -3
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdateConfig.java +26 -0
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePlugin.java +17 -7
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/api/GetLatestBundleResponse.java +29 -0
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/DownloadBundleOptions.java +11 -1
- package/dist/docs.json +62 -6
- package/dist/esm/definitions.d.ts +34 -7
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Plugin/Classes/CustomError.swift +12 -0
- package/ios/Plugin/Classes/Options/DownloadBundleOptions.swift +7 -1
- package/ios/Plugin/LiveUpdate.swift +119 -5
- package/ios/Plugin/LiveUpdateConfig.swift +2 -0
- package/ios/Plugin/LiveUpdatePlugin.swift +5 -2
- package/ios/Plugin/Protocols/Api/GetLatestBundleResponse.swift +2 -0
- package/package.json +1 -1
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
|
|
67
|
-
| ----------------------- |
|
|
68
|
-
| **`appId`** | <code>string</code>
|
|
69
|
-
| **`autoDeleteBundles`** | <code>boolean</code>
|
|
70
|
-
| **`enabled`** | <code>boolean</code>
|
|
71
|
-
| **`
|
|
72
|
-
| **`
|
|
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
|
|
534
|
-
| -------------- | ------------------- |
|
|
535
|
-
| **`
|
|
536
|
-
| **`
|
|
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
|
|
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(
|
|
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(
|
|
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;
|
package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdateConfig.java
CHANGED
|
@@ -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
|
}
|
package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePlugin.java
CHANGED
|
@@ -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
|
|
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": "
|
|
346
|
+
"name": "bundleId",
|
|
347
347
|
"tags": [
|
|
348
348
|
{
|
|
349
349
|
"text": "5.0.0",
|
|
350
350
|
"name": "since"
|
|
351
351
|
},
|
|
352
352
|
{
|
|
353
|
-
"text": "'
|
|
353
|
+
"text": "'1.0.0'",
|
|
354
354
|
"name": "example"
|
|
355
355
|
}
|
|
356
356
|
],
|
|
357
|
-
"docs": "The
|
|
357
|
+
"docs": "The unique identifier of the bundle.",
|
|
358
358
|
"complexTypes": [],
|
|
359
359
|
"type": "string"
|
|
360
360
|
},
|
|
361
361
|
{
|
|
362
|
-
"name": "
|
|
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": "'
|
|
385
|
+
"text": "'https://example.com/bundle.zip'",
|
|
370
386
|
"name": "example"
|
|
371
387
|
}
|
|
372
388
|
],
|
|
373
|
-
"docs": "The
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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
|
|