@capawesome/capacitor-live-update 6.0.8 → 6.2.0-dev.0a2a6a5.1722676620

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.
Files changed (24) hide show
  1. package/README.md +34 -14
  2. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdate.java +136 -19
  3. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdateConfig.java +26 -0
  4. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePlugin.java +31 -9
  5. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePreferences.java +17 -0
  6. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/api/GetLatestBundleResponse.java +29 -0
  7. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/DownloadBundleOptions.java +11 -1
  8. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/GetBundleResult.java +2 -1
  9. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/ReadyResult.java +32 -0
  10. package/dist/docs.json +117 -9
  11. package/dist/esm/definitions.d.ts +62 -8
  12. package/dist/esm/definitions.js.map +1 -1
  13. package/dist/esm/web.d.ts +2 -2
  14. package/dist/esm/web.js.map +1 -1
  15. package/ios/Plugin/Classes/CustomError.swift +12 -0
  16. package/ios/Plugin/Classes/Options/DownloadBundleOptions.swift +7 -1
  17. package/ios/Plugin/Classes/Results/GetBundleResult.swift +1 -1
  18. package/ios/Plugin/Classes/Results/ReadyResult.swift +22 -0
  19. package/ios/Plugin/LiveUpdate.swift +145 -15
  20. package/ios/Plugin/LiveUpdateConfig.swift +2 -0
  21. package/ios/Plugin/LiveUpdatePlugin.swift +15 -4
  22. package/ios/Plugin/LiveUpdatePreferences.swift +14 -0
  23. package/ios/Plugin/Protocols/Api/GetLatestBundleResponse.swift +2 -0
  24. package/package.json +1 -1
@@ -1,13 +1,16 @@
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 {
10
+ public static let defaultWebAssetDir = "public" // DO NOT CHANGE (See https://dub.sh/Buvz4yj)
11
+
8
12
  private let cachesDirectoryUrl = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
9
13
  private let config: LiveUpdateConfig
10
- private let defaultWebAssetDir = "public" // DO NOT CHANGE (See https://dub.sh/Buvz4yj)
11
14
  private let defaultServerPathKey = "serverBasePath" // DO NOT CHANGE (See https://dub.sh/ceDl0zT)
12
15
  private let libraryDirectoryUrl = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first!
13
16
  private let plugin: LiveUpdatePlugin
@@ -15,6 +18,7 @@ import Alamofire
15
18
  private let preferences: LiveUpdatePreferences
16
19
 
17
20
  private var rollbackDispatchWorkItem: DispatchWorkItem?
21
+ private var rollbackPerformed = false
18
22
 
19
23
  init(config: LiveUpdateConfig, plugin: LiveUpdatePlugin) {
20
24
  self.config = config
@@ -50,8 +54,9 @@ import Alamofire
50
54
  }
51
55
 
52
56
  @objc public func downloadBundle(_ options: DownloadBundleOptions, completion: @escaping (Error?) -> Void) {
53
- let url = options.getUrl()
54
57
  let bundleId = options.getBundleId()
58
+ let checksum = options.getChecksum()
59
+ let url = options.getUrl()
55
60
 
56
61
  // Check if the bundle already exists
57
62
  if hasBundle(bundleId: bundleId) {
@@ -61,7 +66,7 @@ import Alamofire
61
66
  }
62
67
 
63
68
  // Download the bundle
64
- downloadBundle(bundleId: bundleId, url: url, completion: { error in
69
+ downloadBundle(bundleId: bundleId, checksum: checksum, signature: nil, url: url, completion: { error in
65
70
  if let error = error {
66
71
  completion(error)
67
72
  return
@@ -71,10 +76,7 @@ import Alamofire
71
76
  }
72
77
 
73
78
  @objc public func getBundle(completion: @escaping (Result?, Error?) -> Void) {
74
- var bundleId = getCurrentBundleId()
75
- if bundleId == defaultWebAssetDir {
76
- bundleId = nil
77
- }
79
+ let bundleId = getCurrentBundleId()
78
80
  let result = GetBundleResult(bundleId: bundleId)
79
81
  completion(result, nil)
80
82
  }
@@ -116,12 +118,18 @@ import Alamofire
116
118
  completion(result, nil)
117
119
  }
118
120
 
119
- @objc public func ready() {
121
+ @objc public func ready(completion: @escaping (Result?, Error?) -> Void) {
120
122
  CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "App is ready.")
121
123
  stopRollbackTimer()
122
124
  if config.autoDeleteBundles {
123
125
  deleteUnusedBundles()
124
126
  }
127
+ let currentBundleId = getCurrentBundleId()
128
+ let previousBundleId = getPreviousBundleId()
129
+ let result = ReadyResult(currentBundleId: currentBundleId, previousBundleId: previousBundleId, rollback: rollbackPerformed)
130
+ completion(result, nil)
131
+ setPreviousBundleId(bundleId: currentBundleId)
132
+ rollbackPerformed = false
125
133
  }
126
134
 
127
135
  @objc public func reload() {
@@ -187,7 +195,7 @@ import Alamofire
187
195
  completion(result, nil)
188
196
  } else {
189
197
  // Download and unzip the bundle
190
- self.downloadBundle(bundleId: latestBundleId, url: response.url, completion: { error in
198
+ self.downloadBundle(bundleId: latestBundleId, checksum: response.checksum, signature: response.signature, url: response.url, completion: { error in
191
199
  if let error = error {
192
200
  completion(nil, error)
193
201
  return
@@ -241,6 +249,20 @@ import Alamofire
241
249
  return url.path
242
250
  }
243
251
 
252
+ /// - Returns: The sha256 checksum of the file at the given URL.
253
+ private func getChecksumForFile(url: URL) throws -> String {
254
+ let handle = try FileHandle(forReadingFrom: url)
255
+ var hasher = SHA256()
256
+ while autoreleasepool(invoking: {
257
+ let nextChunk = handle.readData(ofLength: 2048)
258
+ guard !nextChunk.isEmpty else { return false }
259
+ hasher.update(data: nextChunk)
260
+ return true
261
+ }) { }
262
+ let digest = hasher.finalize()
263
+ return digest.map { String(format: "%02hhx", $0) }.joined()
264
+ }
265
+
244
266
  private func createBundlesDirectory() {
245
267
  let bundlesDirectoryUrl = libraryDirectoryUrl.appendingPathComponent(bundlesDirectory)
246
268
  let exists = FileManager.default.fileExists(atPath: bundlesDirectoryUrl.path)
@@ -274,7 +296,7 @@ import Alamofire
274
296
  }
275
297
  }
276
298
 
277
- private func downloadBundle(bundleId: String, url: String, completion: @escaping (Error?) -> Void) {
299
+ private func downloadBundle(bundleId: String, checksum: String?, signature: String?, url: String, completion: @escaping (Error?) -> Void) {
278
300
  // Download the bundle
279
301
  downloadFile(url: url, completion: { url, error in
280
302
  if let error = error {
@@ -282,6 +304,39 @@ import Alamofire
282
304
  return
283
305
  }
284
306
  if let url = url {
307
+ // Verify the signature
308
+ if let publicKey = self.config.publicKey {
309
+ guard let signature = signature else {
310
+ completion(CustomError.signatureMissing)
311
+ return
312
+ }
313
+ // Verify the signature
314
+ do {
315
+ let verified = try self.verifySignatureForFile(url: url, signature: signature, publicKey: publicKey)
316
+ if !verified {
317
+ completion(CustomError.signatureVerificationFailed)
318
+ return
319
+ }
320
+ } catch {
321
+ completion(CustomError.signatureVerificationFailed)
322
+ return
323
+ }
324
+ }
325
+ // Verify the checksum
326
+ else if let expectedChecksum = checksum {
327
+ // Calculate the checksum
328
+ do {
329
+ let receivedChecksum = try self.getChecksumForFile(url: url)
330
+ if receivedChecksum != expectedChecksum {
331
+ completion(CustomError.checksumMismatch)
332
+ return
333
+ }
334
+ } catch {
335
+ completion(CustomError.checksumCalculationFailed)
336
+ return
337
+ }
338
+ }
339
+
285
340
  // Add the bundle
286
341
  self.addBundle(bundleId: bundleId, zipFile: url, completion: { error in
287
342
  // Delete the temporary file
@@ -328,7 +383,13 @@ import Alamofire
328
383
  parameters["osVersion"] = UIDevice.current.systemVersion
329
384
  parameters["platform"] = "1"
330
385
  parameters["pluginVersion"] = LiveUpdatePlugin.version
331
- let url = URL(string: "https://api.cloud.capawesome.io/v1/apps/\(config.appId ?? "")/bundles/latest")!
386
+ var host = "api.cloud.capawesome.io"
387
+ if let location = config.location {
388
+ if location == "eu" {
389
+ host = "api.cloud.capawesome.eu"
390
+ }
391
+ }
392
+ let url = URL(string: "https://\(host)/v1/apps/\(config.appId ?? "")/bundles/latest")!
332
393
  AF.request(url, method: .get, parameters: parameters).validate().responseDecodable(of: GetLatestBundleResponse.self) { response in
333
394
  CAPLog.print("[", LiveUpdatePlugin.tag, "] Fetching latest bundle from ", response.request?.url?.absoluteString ?? "")
334
395
  if let error = response.error {
@@ -400,20 +461,27 @@ import Alamofire
400
461
 
401
462
  /// - Returns: The absolute path to the next bundle directory.
402
463
  private func getNextCapacitorServerPath() -> String {
403
- let defaultCapacitorServerPath = buildCapacitorServerPathFor(bundleId: defaultWebAssetDir)
464
+ let defaultCapacitorServerPath = buildCapacitorServerPathFor(bundleId: LiveUpdate.defaultWebAssetDir)
404
465
  if let path = KeyValueStore.standard[self.defaultServerPathKey, as: String.self] {
405
466
  return path.isEmpty ? defaultCapacitorServerPath : path
406
467
  }
407
468
  return defaultCapacitorServerPath
408
469
  }
409
470
 
471
+ private func getPreviousBundleId() -> String? {
472
+ return preferences.getPreviousBundleId()
473
+ }
474
+
410
475
  private func hasBundle(bundleId: String) -> Bool {
411
476
  let path = buildBundlePathFor(bundleId: bundleId)
412
477
  return FileManager.default.fileExists(atPath: path)
413
478
  }
414
479
 
415
480
  private func rollback() {
416
- if getCurrentBundleId() == defaultWebAssetDir {
481
+ rollbackPerformed = true
482
+ let currentBundleId = getCurrentBundleId()
483
+ setPreviousBundleId(bundleId: currentBundleId)
484
+ if currentBundleId == LiveUpdate.defaultWebAssetDir {
417
485
  CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "App is not ready. Default bundle is already in use.")
418
486
  return
419
487
  }
@@ -447,7 +515,7 @@ import Alamofire
447
515
  }
448
516
 
449
517
  private func setCurrentCapacitorServerPathToDefaultWebAssetDir() {
450
- let path = buildCapacitorServerPathFor(bundleId: defaultWebAssetDir)
518
+ let path = buildCapacitorServerPathFor(bundleId: LiveUpdate.defaultWebAssetDir)
451
519
  setCurrentCapacitorServerPath(path: path)
452
520
  }
453
521
 
@@ -467,10 +535,14 @@ import Alamofire
467
535
  }
468
536
 
469
537
  private func setNextCapacitorServerPathToDefaultWebAssetDir() {
470
- let path = buildCapacitorServerPathFor(bundleId: defaultWebAssetDir)
538
+ let path = buildCapacitorServerPathFor(bundleId: LiveUpdate.defaultWebAssetDir)
471
539
  setNextCapacitorServerPath(path: path)
472
540
  }
473
541
 
542
+ private func setPreviousBundleId(bundleId: String?) {
543
+ preferences.setPreviousBundleId(bundleId)
544
+ }
545
+
474
546
  private func startRollbackTimer() {
475
547
  stopRollbackTimer()
476
548
  rollbackDispatchWorkItem = DispatchWorkItem { [weak self] in
@@ -492,6 +564,64 @@ import Alamofire
492
564
  })
493
565
  }
494
566
 
567
+ private func verifySignatureForFile(url: URL, signature: String, publicKey: String) throws -> Bool {
568
+ let publicKeyAsBase64 = publicKey
569
+ .replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "")
570
+ .replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "")
571
+ .replacingOccurrences(of: "\n", with: "")
572
+ guard let publicKeyData = Data(base64Encoded: publicKeyAsBase64) else {
573
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to decode public key.")
574
+ return false
575
+ }
576
+ let publicKeyAttributes: [CFString: Any] = [
577
+ kSecAttrKeyType: kSecAttrKeyTypeRSA,
578
+ kSecAttrKeyClass: kSecAttrKeyClassPublic,
579
+ kSecAttrKeySizeInBits: 2048,
580
+ kSecReturnPersistentRef: true
581
+ ]
582
+ var secKeyCreateWithDataError: Unmanaged<CFError>?
583
+ guard let publicKey = SecKeyCreateWithData(publicKeyData as CFData, publicKeyAttributes as CFDictionary, &secKeyCreateWithDataError) else {
584
+ if let error = secKeyCreateWithDataError?.takeRetainedValue() {
585
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to create public key with error: \(error)")
586
+ }
587
+ return false
588
+ }
589
+ guard let signatureData = Data(base64Encoded: signature) else {
590
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to decode signature.")
591
+ return false
592
+ }
593
+
594
+ // Create SHA256 digest
595
+ var digestContext = CC_SHA256_CTX()
596
+ CC_SHA256_Init(&digestContext)
597
+
598
+ // Update the digest with the file's data
599
+ let handle = try FileHandle(forReadingFrom: url)
600
+ while autoreleasepool(invoking: {
601
+ let nextChunk = handle.readData(ofLength: 2048)
602
+ guard !nextChunk.isEmpty else { return false }
603
+ nextChunk.withUnsafeBytes {
604
+ _ = CC_SHA256_Update(&digestContext, $0.baseAddress, CC_LONG(nextChunk.count))
605
+ }
606
+ return true
607
+ }) { }
608
+
609
+ // Compute the digest
610
+ var digest = Data(count: Int(CC_SHA256_DIGEST_LENGTH))
611
+ digest.withUnsafeMutableBytes {
612
+ _ = CC_SHA256_Final($0.bindMemory(to: UInt8.self).baseAddress, &digestContext)
613
+ }
614
+
615
+ // Verify the signature
616
+ var secKeyVerifySignatureError: Unmanaged<CFError>?
617
+ let signatureAlgorithm = SecKeyAlgorithm.rsaSignatureDigestPKCS1v15SHA256
618
+ let verificationResult = SecKeyVerifySignature(publicKey, signatureAlgorithm, digest as CFData, signatureData as CFData, &secKeyVerifySignatureError)
619
+ if let error = secKeyVerifySignatureError?.takeRetainedValue() {
620
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to verify signature with error: \(error)")
621
+ }
622
+ return verificationResult
623
+ }
624
+
495
625
  private func wasUpdated() -> Bool {
496
626
  guard let lastBundleVersionString = preferences.getLastBundleVersion() else {
497
627
  return false
@@ -2,6 +2,8 @@ public struct LiveUpdateConfig {
2
2
  var appId: String?
3
3
  var autoDeleteBundles = false
4
4
  var enabled = true
5
+ var location: String?
6
+ var publicKey: String?
5
7
  var readyTimeout = 10000
6
8
  var resetOnUpdate = true
7
9
  }
@@ -8,7 +8,7 @@ import Capacitor
8
8
  @objc(LiveUpdatePlugin)
9
9
  public class LiveUpdatePlugin: CAPPlugin {
10
10
  public static let tag = "LiveUpdate"
11
- public static let version = "6.0.7"
11
+ public static let version = "6.2.0-dev.0a2a6a5.1722676620"
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 {
@@ -151,8 +152,16 @@ public class LiveUpdatePlugin: CAPPlugin {
151
152
  }
152
153
 
153
154
  @objc func ready(_ call: CAPPluginCall) {
154
- implementation?.ready()
155
- call.resolve()
155
+ implementation?.ready(completion: { result, error in
156
+ if let error = error {
157
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", error)
158
+ call.reject(error.localizedDescription)
159
+ return
160
+ }
161
+ if let result = result?.toJSObject() as? JSObject {
162
+ call.resolve(result)
163
+ }
164
+ })
156
165
  }
157
166
 
158
167
  @objc func reload(_ call: CAPPluginCall) {
@@ -239,6 +248,8 @@ public class LiveUpdatePlugin: CAPPlugin {
239
248
  config.appId = getConfig().getString("appId", config.appId)
240
249
  config.autoDeleteBundles = getConfig().getBoolean("autoDeleteBundles", config.autoDeleteBundles)
241
250
  config.enabled = getConfig().getBoolean("enabled", config.enabled)
251
+ config.location = getConfig().getString("location", config.location)
252
+ config.publicKey = getConfig().getString("publicKey", config.publicKey)
242
253
  config.readyTimeout = getConfig().getInt("readyTimeout", config.readyTimeout)
243
254
  config.resetOnUpdate = getConfig().getBoolean("resetOnUpdate", config.resetOnUpdate)
244
255
 
@@ -4,6 +4,7 @@ public class LiveUpdatePreferences: NSObject {
4
4
  private let channelKey = "channel" // DO NOT CHANGE
5
5
  private let customIdKey = "customId" // DO NOT CHANGE
6
6
  private let lastBundleVersionKey = "lastBundleVersion" // DO NOT CHANGE
7
+ private let previousBundleIdKey = "previousBundleIdKey" // DO NOT CHANGE
7
8
 
8
9
  public func getChannel() -> String? {
9
10
  return UserDefaults.standard.string(forKey: applyPrefix(to: channelKey))
@@ -17,6 +18,10 @@ public class LiveUpdatePreferences: NSObject {
17
18
  return UserDefaults.standard.string(forKey: applyPrefix(to: lastBundleVersionKey))
18
19
  }
19
20
 
21
+ public func getPreviousBundleId() -> String? {
22
+ return UserDefaults.standard.string(forKey: applyPrefix(to: previousBundleIdKey))
23
+ }
24
+
20
25
  public func setChannel(_ value: String?) {
21
26
  if let value = value {
22
27
  UserDefaults.standard.set(value, forKey: applyPrefix(to: channelKey))
@@ -36,6 +41,15 @@ public class LiveUpdatePreferences: NSObject {
36
41
  UserDefaults.standard.synchronize()
37
42
  }
38
43
 
44
+ public func setPreviousBundleId(_ value: String?) {
45
+ if let value = value {
46
+ UserDefaults.standard.set(value, forKey: applyPrefix(to: previousBundleIdKey))
47
+ } else {
48
+ UserDefaults.standard.removeObject(forKey: applyPrefix(to: previousBundleIdKey))
49
+ }
50
+ UserDefaults.standard.synchronize()
51
+ }
52
+
39
53
  private func applyPrefix(to key: String) -> String {
40
54
  return LiveUpdatePlugin.userDefaultsPrefix + "." + key
41
55
  }
@@ -1,4 +1,6 @@
1
1
  struct GetLatestBundleResponse: Codable {
2
2
  var bundleId: String
3
+ var checksum: String?
4
+ var signature: String?
3
5
  var url: String
4
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capawesome/capacitor-live-update",
3
- "version": "6.0.8",
3
+ "version": "6.2.0-dev.0a2a6a5.1722676620",
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",