@capgo/capacitor-updater 6.34.0 → 6.37.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.
@@ -16,21 +16,25 @@ import Foundation
16
16
  private let version: String
17
17
  private let checksum: String
18
18
  private let status: BundleStatus
19
+ private let link: String?
20
+ private let comment: String?
19
21
 
20
- convenience init(id: String, version: String, status: BundleStatus, downloaded: Date, checksum: String) {
21
- self.init(id: id, version: version, status: status, downloaded: downloaded.iso8601withFractionalSeconds, checksum: checksum)
22
+ convenience init(id: String, version: String, status: BundleStatus, downloaded: Date, checksum: String, link: String? = nil, comment: String? = nil) {
23
+ self.init(id: id, version: version, status: status, downloaded: downloaded.iso8601withFractionalSeconds, checksum: checksum, link: link, comment: comment)
22
24
  }
23
25
 
24
- init(id: String, version: String, status: BundleStatus, downloaded: String = BundleInfo.DOWNLOADED_BUILTIN, checksum: String) {
26
+ init(id: String, version: String, status: BundleStatus, downloaded: String = BundleInfo.DOWNLOADED_BUILTIN, checksum: String, link: String? = nil, comment: String? = nil) {
25
27
  self.downloaded = downloaded.trim()
26
28
  self.id = id
27
29
  self.version = version
28
30
  self.checksum = checksum
29
31
  self.status = status
32
+ self.link = link
33
+ self.comment = comment
30
34
  }
31
35
 
32
36
  enum CodingKeys: String, CodingKey {
33
- case downloaded, id, version, status, checksum
37
+ case downloaded, id, version, status, checksum, link, comment
34
38
  }
35
39
 
36
40
  public func isBuiltin() -> Bool {
@@ -62,11 +66,11 @@ import Foundation
62
66
  }
63
67
 
64
68
  public func setChecksum(checksum: String) -> BundleInfo {
65
- return BundleInfo(id: self.id, version: self.version, status: self.status, downloaded: self.downloaded, checksum: checksum)
69
+ return BundleInfo(id: self.id, version: self.version, status: self.status, downloaded: self.downloaded, checksum: checksum, link: self.link, comment: self.comment)
66
70
  }
67
71
 
68
72
  public func setDownloaded(downloaded: Date) -> BundleInfo {
69
- return BundleInfo(id: self.id, version: self.version, status: self.status, downloaded: downloaded, checksum: self.checksum)
73
+ return BundleInfo(id: self.id, version: self.version, status: self.status, downloaded: downloaded, checksum: self.checksum, link: self.link, comment: self.comment)
70
74
  }
71
75
 
72
76
  public func getId() -> String {
@@ -74,7 +78,7 @@ import Foundation
74
78
  }
75
79
 
76
80
  public func setId(id: String) -> BundleInfo {
77
- return BundleInfo(id: id, version: self.version, status: self.status, downloaded: self.downloaded, checksum: self.checksum)
81
+ return BundleInfo(id: id, version: self.version, status: self.status, downloaded: self.downloaded, checksum: self.checksum, link: self.link, comment: self.comment)
78
82
  }
79
83
 
80
84
  public func getVersionName() -> String {
@@ -82,7 +86,7 @@ import Foundation
82
86
  }
83
87
 
84
88
  public func setVersionName(version: String) -> BundleInfo {
85
- return BundleInfo(id: self.id, version: version, status: self.status, downloaded: self.downloaded, checksum: self.checksum)
89
+ return BundleInfo(id: self.id, version: version, status: self.status, downloaded: self.downloaded, checksum: self.checksum, link: self.link, comment: self.comment)
86
90
  }
87
91
 
88
92
  public func getStatus() -> String {
@@ -90,17 +94,40 @@ import Foundation
90
94
  }
91
95
 
92
96
  public func setStatus(status: String) -> BundleInfo {
93
- return BundleInfo(id: self.id, version: self.version, status: BundleStatus(localizedString: status)!, downloaded: self.downloaded, checksum: self.checksum)
97
+ return BundleInfo(id: self.id, version: self.version, status: BundleStatus(localizedString: status)!, downloaded: self.downloaded, checksum: self.checksum, link: self.link, comment: self.comment)
98
+ }
99
+
100
+ public func getLink() -> String? {
101
+ return self.link
102
+ }
103
+
104
+ public func setLink(link: String?) -> BundleInfo {
105
+ return BundleInfo(id: self.id, version: self.version, status: self.status, downloaded: self.downloaded, checksum: self.checksum, link: link, comment: self.comment)
106
+ }
107
+
108
+ public func getComment() -> String? {
109
+ return self.comment
110
+ }
111
+
112
+ public func setComment(comment: String?) -> BundleInfo {
113
+ return BundleInfo(id: self.id, version: self.version, status: self.status, downloaded: self.downloaded, checksum: self.checksum, link: self.link, comment: comment)
94
114
  }
95
115
 
96
116
  public func toJSON() -> [String: String] {
97
- return [
117
+ var result: [String: String] = [
98
118
  "id": self.getId(),
99
119
  "version": self.getVersionName(),
100
120
  "downloaded": self.getDownloaded(),
101
121
  "checksum": self.getChecksum(),
102
122
  "status": self.getStatus()
103
123
  ]
124
+ if let link = self.link {
125
+ result["link"] = link
126
+ }
127
+ if let comment = self.comment {
128
+ result["comment"] = comment
129
+ }
130
+ return result
104
131
  }
105
132
 
106
133
  public static func == (lhs: BundleInfo, rhs: BundleInfo) -> Bool {
@@ -54,7 +54,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
54
54
  CAPPluginMethod(name: "isShakeMenuEnabled", returnType: CAPPluginReturnPromise)
55
55
  ]
56
56
  public var implementation = CapgoUpdater()
57
- private let pluginVersion: String = "6.27.11"
57
+ private let pluginVersion: String = "6.37.0"
58
58
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
59
59
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
60
60
  static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
@@ -92,6 +92,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
92
92
  private var backgroundWork: DispatchWorkItem?
93
93
  private var taskRunning = false
94
94
  private var periodCheckDelay = 0
95
+
96
+ // Lock to ensure cleanup completes before downloads start
97
+ private let cleanupLock = NSLock()
98
+ private var cleanupComplete = false
99
+ private var cleanupThread: Thread?
95
100
  private var persistCustomId = false
96
101
  private var persistModifyUrl = false
97
102
  private var allowManualBundleError = false
@@ -185,7 +190,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
185
190
  logger.info("Loaded persisted updateUrl")
186
191
  }
187
192
  autoUpdate = getConfig().getBoolean("autoUpdate", true)
188
- appReadyTimeout = getConfig().getInt("appReadyTimeout", 10000)
193
+ appReadyTimeout = max(1000, getConfig().getInt("appReadyTimeout", 10000)) // Minimum 1 second
189
194
  implementation.timeout = Double(getConfig().getInt("responseTimeout", 20))
190
195
  resetWhenUpdate = getConfig().getBoolean("resetWhenUpdate", true)
191
196
  shakeMenuEnabled = getConfig().getBoolean("shakeMenu", false)
@@ -208,6 +213,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
208
213
  implementation.setLogger(logger)
209
214
  CryptoCipher.setLogger(logger)
210
215
 
216
+ // Log public key prefix if encryption is enabled
217
+ if let keyId = implementation.getKeyId(), !keyId.isEmpty {
218
+ logger.info("Public key prefix: \(keyId)")
219
+ }
220
+
211
221
  // Initialize DelayUpdateUtils
212
222
  self.delayUpdateUtils = DelayUpdateUtils(currentVersionNative: currentVersionNative, logger: logger)
213
223
  let config = (self.bridge?.viewController as? CAPBridgeViewController)?.instanceDescriptor().legacyConfig
@@ -362,28 +372,73 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
362
372
  }
363
373
 
364
374
  private func cleanupObsoleteVersions() {
365
- let previous = UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? "0"
366
- if previous != "0" && self.currentBuildVersion != previous {
367
- _ = self._reset(toLastSuccessful: false)
368
- let res = implementation.list()
369
- res.forEach { version in
370
- logger.info("Deleting obsolete bundle: \(version.getId())")
371
- let res = implementation.delete(id: version.getId())
372
- if !res {
373
- logger.error("Delete failed, id \(version.getId()) doesn't exist")
375
+ cleanupThread = Thread {
376
+ self.cleanupLock.lock()
377
+ defer {
378
+ self.cleanupComplete = true
379
+ self.cleanupLock.unlock()
380
+ self.logger.info("Cleanup complete")
381
+ }
382
+
383
+ let previous = UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
384
+ if previous != "0" && self.currentBuildVersion != previous {
385
+ _ = self._reset(toLastSuccessful: false)
386
+ let res = self.implementation.list()
387
+ for version in res {
388
+ // Check if thread was cancelled
389
+ if Thread.current.isCancelled {
390
+ self.logger.warn("Cleanup was cancelled, stopping")
391
+ return
392
+ }
393
+ self.logger.info("Deleting obsolete bundle: \(version.getId())")
394
+ let res = self.implementation.delete(id: version.getId())
395
+ if !res {
396
+ self.logger.error("Delete failed, id \(version.getId()) doesn't exist")
397
+ }
374
398
  }
399
+
400
+ let storedBundles = self.implementation.list(raw: true)
401
+ let allowedIds = Set(storedBundles.compactMap { info -> String? in
402
+ let id = info.getId()
403
+ return id.isEmpty ? nil : id
404
+ })
405
+ self.implementation.cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: Thread.current)
406
+
407
+ // Check again before the expensive delta cache cleanup
408
+ if Thread.current.isCancelled {
409
+ self.logger.warn("Cleanup was cancelled before delta cache cleanup")
410
+ return
411
+ }
412
+ self.implementation.cleanupDeltaCache(threadToCheck: Thread.current)
375
413
  }
414
+ UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
415
+ UserDefaults.standard.synchronize()
416
+ }
417
+ cleanupThread?.start()
376
418
 
377
- let storedBundles = implementation.list(raw: true)
378
- let allowedIds = Set(storedBundles.compactMap { info -> String? in
379
- let id = info.getId()
380
- return id.isEmpty ? nil : id
381
- })
382
- implementation.cleanupDownloadDirectories(allowedIds: allowedIds)
383
- implementation.cleanupDeltaCache()
419
+ // Start a timeout watchdog thread to cancel cleanup if it takes too long
420
+ let timeout = Double(self.appReadyTimeout / 2) / 1000.0
421
+ Thread.detachNewThread {
422
+ Thread.sleep(forTimeInterval: timeout)
423
+ if let thread = self.cleanupThread, !thread.isFinished && !self.cleanupComplete {
424
+ self.logger.warn("Cleanup timeout exceeded (\(timeout)s), cancelling cleanup thread")
425
+ thread.cancel()
426
+ }
384
427
  }
385
- UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
386
- UserDefaults.standard.synchronize()
428
+ }
429
+
430
+ private func waitForCleanupIfNeeded() {
431
+ if cleanupComplete {
432
+ return // Already done, no need to wait
433
+ }
434
+
435
+ logger.info("Waiting for cleanup to complete before starting download...")
436
+
437
+ // Wait for cleanup to complete - blocks until lock is released
438
+ cleanupLock.lock()
439
+ cleanupLock.unlock()
440
+
441
+ logger.info("Cleanup finished, proceeding with download")
387
442
  }
388
443
 
389
444
  @objc func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
@@ -1276,6 +1331,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1276
1331
  return
1277
1332
  }
1278
1333
  DispatchQueue.global(qos: .background).async {
1334
+ // Wait for cleanup to complete before starting download
1335
+ self.waitForCleanupIfNeeded()
1279
1336
  self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "Finish Download Tasks") {
1280
1337
  // End the task if time expires.
1281
1338
  self.endBackGroundTask()
@@ -1341,9 +1398,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1341
1398
  }
1342
1399
  }
1343
1400
  if res.manifest != nil {
1344
- nextImpl = try self.implementation.downloadManifest(manifest: res.manifest!, version: latestVersionName, sessionKey: sessionKey)
1401
+ nextImpl = try self.implementation.downloadManifest(manifest: res.manifest!, version: latestVersionName, sessionKey: sessionKey, link: res.link, comment: res.comment)
1345
1402
  } else {
1346
- nextImpl = try self.implementation.download(url: downloadUrl, version: latestVersionName, sessionKey: sessionKey)
1403
+ nextImpl = try self.implementation.download(url: downloadUrl, version: latestVersionName, sessionKey: sessionKey, link: res.link, comment: res.comment)
1347
1404
  }
1348
1405
  }
1349
1406
  guard let next = nextImpl else {
@@ -44,6 +44,9 @@ import UIKit
44
44
  public var publicKey: String = ""
45
45
  public var hasOldPrivateKeyPropertyInConfig: Bool = false
46
46
 
47
+ // Cached key ID calculated once from publicKey
48
+ private var cachedKeyId: String?
49
+
47
50
  // Flag to track if we received a 429 response - stops requests until app restart
48
51
  private static var rateLimitExceeded = false
49
52
 
@@ -81,6 +84,19 @@ import UIKit
81
84
  return String((0..<length).map { _ in letters.randomElement()! })
82
85
  }
83
86
 
87
+ public func setPublicKey(_ publicKey: String) {
88
+ self.publicKey = publicKey
89
+ if !publicKey.isEmpty {
90
+ self.cachedKeyId = CryptoCipher.calcKeyId(publicKey: publicKey)
91
+ } else {
92
+ self.cachedKeyId = nil
93
+ }
94
+ }
95
+
96
+ public func getKeyId() -> String? {
97
+ return self.cachedKeyId
98
+ }
99
+
84
100
  private var isDevEnvironment: Bool {
85
101
  #if DEBUG
86
102
  return true
@@ -326,7 +342,8 @@ import UIKit
326
342
  is_prod: self.isProd(),
327
343
  action: nil,
328
344
  channel: nil,
329
- defaultChannel: self.defaultChannel
345
+ defaultChannel: self.defaultChannel,
346
+ key_id: self.cachedKeyId
330
347
  )
331
348
  }
332
349
 
@@ -374,6 +391,12 @@ import UIKit
374
391
  if let manifest = response.value?.manifest {
375
392
  latest.manifest = manifest
376
393
  }
394
+ if let link = response.value?.link {
395
+ latest.link = link
396
+ }
397
+ if let comment = response.value?.comment {
398
+ latest.comment = comment
399
+ }
377
400
  case let .failure(error):
378
401
  self.logger.error("Error getting Latest \(response.value.debugDescription) \(error)")
379
402
  latest.message = "Error getting Latest \(String(describing: response.value))"
@@ -402,11 +425,11 @@ import UIKit
402
425
  private var tempData = Data()
403
426
 
404
427
  private func verifyChecksum(file: URL, expectedHash: String) -> Bool {
405
- let actualHash = CryptoCipher.calcChecksum(filePath: file)
428
+ let actualHash = CryptoCipher.calcChecksum(filePath: file)
406
429
  return actualHash == expectedHash
407
430
  }
408
431
 
409
- public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String) throws -> BundleInfo {
432
+ public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
410
433
  let id = self.randomString(length: 10)
411
434
  logger.info("downloadManifest start \(id)")
412
435
  let destFolder = self.getBundleDirectory(id: id)
@@ -416,7 +439,7 @@ import UIKit
416
439
  try FileManager.default.createDirectory(at: destFolder, withIntermediateDirectories: true, attributes: nil)
417
440
 
418
441
  // Create and save BundleInfo before starting the download process
419
- let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: "")
442
+ let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: "", link: link, comment: comment)
420
443
  self.saveBundleInfo(id: id, bundle: bundleInfo)
421
444
 
422
445
  // Send stats for manifest download start
@@ -451,10 +474,16 @@ import UIKit
451
474
  // V1 doesn't decrypt checksum, uses different method
452
475
  }
453
476
 
477
+ // Check if file has .br extension for Brotli decompression
454
478
  let fileNameWithoutPath = (fileName as NSString).lastPathComponent
455
479
  let cacheFileName = "\(fileHash)_\(fileNameWithoutPath)"
456
480
  let cacheFilePath = cacheFolder.appendingPathComponent(cacheFileName)
457
- let destFilePath = destFolder.appendingPathComponent(fileName)
481
+
482
+ // Check if file is Brotli compressed and remove .br extension from destination
483
+ let isBrotli = fileName.hasSuffix(".br")
484
+ let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
485
+
486
+ let destFilePath = destFolder.appendingPathComponent(destFileName)
458
487
  let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
459
488
 
460
489
  // Create necessary subdirectories in the destination folder
@@ -463,13 +492,18 @@ import UIKit
463
492
  dispatchGroup.enter()
464
493
 
465
494
  if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
466
- try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
467
- logger.info("downloadManifest \(fileName) using builtin file \(id)")
468
- completedFiles += 1
469
- self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
495
+ do {
496
+ try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
497
+ logger.info("downloadManifest \(fileName) using builtin file \(id)")
498
+ completedFiles += 1
499
+ self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
500
+ } catch {
501
+ downloadError = error
502
+ logger.error("Failed to copy builtin file \(fileName): \(error.localizedDescription)")
503
+ }
470
504
  dispatchGroup.leave()
471
- } else if FileManager.default.fileExists(atPath: cacheFilePath.path) && verifyChecksum(file: cacheFilePath, expectedHash: fileHash) {
472
- try FileManager.default.copyItem(at: cacheFilePath, to: destFilePath)
505
+ } else if self.tryCopyFromCache(from: cacheFilePath, to: destFilePath, expectedHash: fileHash) {
506
+ // Successfully copied from cache
473
507
  logger.info("downloadManifest \(fileName) copy from cache \(id)")
474
508
  completedFiles += 1
475
509
  self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
@@ -510,15 +544,11 @@ import UIKit
510
544
  throw NSError(domain: "DeprecatedEncryptionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "V1 Encryption with privateKey is deprecated and not supported in this download method for file \(fileName) at url \(downloadUrl)"])
511
545
  }
512
546
 
513
- // Check if file has .br extension for Brotli decompression
514
- let isBrotli = fileName.hasSuffix(".br")
515
- let finalFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
516
- let destFilePath = destFolder.appendingPathComponent(finalFileName)
517
-
547
+ // Use the isBrotli and destFilePath already computed above
518
548
  if isBrotli {
519
549
  // Decompress the Brotli data
520
550
  guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
521
- self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(finalFileName)")
551
+ self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
522
552
  throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
523
553
  }
524
554
  finalData = decompressedData
@@ -533,7 +563,7 @@ import UIKit
533
563
  if calculatedChecksum != fileHash {
534
564
  // Delete the corrupt file before throwing error
535
565
  try? FileManager.default.removeItem(at: destFilePath)
536
- self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(finalFileName)")
566
+ self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
537
567
  throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
538
568
  }
539
569
  }
@@ -578,6 +608,30 @@ import UIKit
578
608
  return updatedBundle
579
609
  }
580
610
 
611
+ /// Atomically try to copy a file from cache - returns true if successful, false if file doesn't exist or copy failed
612
+ /// This handles the race condition where OS can delete cache files between exists() check and copy
613
+ private func tryCopyFromCache(from source: URL, to destination: URL, expectedHash: String) -> Bool {
614
+ // First quick check - if file doesn't exist, don't bother
615
+ guard FileManager.default.fileExists(atPath: source.path) else {
616
+ return false
617
+ }
618
+
619
+ // Verify checksum before copy
620
+ guard verifyChecksum(file: source, expectedHash: expectedHash) else {
621
+ return false
622
+ }
623
+
624
+ // Try to copy - if it fails (file deleted by OS between check and copy), return false
625
+ do {
626
+ try FileManager.default.copyItem(at: source, to: destination)
627
+ return true
628
+ } catch {
629
+ // File was deleted between check and copy, or other IO error - caller should download instead
630
+ logger.debug("Cache copy failed (likely OS eviction): \(error.localizedDescription)")
631
+ return false
632
+ }
633
+ }
634
+
581
635
  private func decompressBrotli(data: Data, fileName: String) -> Data? {
582
636
  // Handle empty files
583
637
  if data.count == 0 {
@@ -691,7 +745,7 @@ import UIKit
691
745
  return status == COMPRESSION_STATUS_END ? decompressedData : nil
692
746
  }
693
747
 
694
- public func download(url: URL, version: String, sessionKey: String) throws -> BundleInfo {
748
+ public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
695
749
  let id: String = self.randomString(length: 10)
696
750
  let semaphore = DispatchSemaphore(value: 0)
697
751
  if version != getLocalUpdateVersion() {
@@ -758,7 +812,7 @@ import UIKit
758
812
  semaphore.signal()
759
813
  }
760
814
  }
761
- self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum))
815
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum, link: link, comment: comment))
762
816
  let reachabilityManager = NetworkReachabilityManager()
763
817
  reachabilityManager?.startListening { status in
764
818
  switch status {
@@ -776,23 +830,18 @@ import UIKit
776
830
 
777
831
  if mainError != nil {
778
832
  logger.error("Failed to download: \(String(describing: mainError))")
779
- self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
833
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
780
834
  throw mainError!
781
835
  }
782
836
 
783
837
  let finalPath = tempDataPath.deletingLastPathComponent().appendingPathComponent("\(id)")
784
838
  do {
785
- if !self.hasOldPrivateKeyPropertyInConfig {
786
- // V2 Encryption (publicKey)
787
- try CryptoCipher.decryptFile(filePath: tempDataPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
788
- } else {
789
- // V1 Encryption (privateKey) - deprecated but supported
790
- try CryptoCipher.decryptFile(filePath: tempDataPath, privateKey: self.privateKey, sessionKey: sessionKey, version: version)
791
- }
839
+ // Decrypt file using public key (V1 encryption with privateKey is deprecated)
840
+ try CryptoCipher.decryptFile(filePath: tempDataPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
792
841
  try FileManager.default.moveItem(at: tempDataPath, to: finalPath)
793
842
  } catch {
794
843
  logger.error("Failed decrypt file : \(error)")
795
- self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
844
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
796
845
  cleanDownloadData()
797
846
  throw error
798
847
  }
@@ -805,7 +854,7 @@ import UIKit
805
854
 
806
855
  } catch {
807
856
  logger.error("Failed to unzip file: \(error)")
808
- self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
857
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
809
858
  // Best-effort cleanup of the decrypted zip file when unzip fails
810
859
  do {
811
860
  if FileManager.default.fileExists(atPath: finalPath.path) {
@@ -820,7 +869,7 @@ import UIKit
820
869
 
821
870
  self.notifyDownload(id: id, percent: 90)
822
871
  logger.info("Downloading: 90% (wrapping up)")
823
- let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum)
872
+ let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum, link: link, comment: comment)
824
873
  self.saveBundleInfo(id: id, bundle: info)
825
874
  self.cleanDownloadData()
826
875
 
@@ -995,6 +1044,16 @@ import UIKit
995
1044
  }
996
1045
 
997
1046
  public func cleanupDeltaCache() {
1047
+ cleanupDeltaCache(threadToCheck: nil)
1048
+ }
1049
+
1050
+ public func cleanupDeltaCache(threadToCheck: Thread?) {
1051
+ // Check if thread was cancelled
1052
+ if let thread = threadToCheck, thread.isCancelled {
1053
+ logger.warn("cleanupDeltaCache was cancelled before starting")
1054
+ return
1055
+ }
1056
+
998
1057
  let fileManager = FileManager.default
999
1058
  guard fileManager.fileExists(atPath: cacheFolder.path) else {
1000
1059
  return
@@ -1008,6 +1067,10 @@ import UIKit
1008
1067
  }
1009
1068
 
1010
1069
  public func cleanupDownloadDirectories(allowedIds: Set<String>) {
1070
+ cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: nil)
1071
+ }
1072
+
1073
+ public func cleanupDownloadDirectories(allowedIds: Set<String>, threadToCheck: Thread?) {
1011
1074
  let bundleRoot = libraryDir.appendingPathComponent(bundleDirectory)
1012
1075
  let fileManager = FileManager.default
1013
1076
 
@@ -1019,6 +1082,12 @@ import UIKit
1019
1082
  let contents = try fileManager.contentsOfDirectory(at: bundleRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
1020
1083
 
1021
1084
  for url in contents {
1085
+ // Check if thread was cancelled
1086
+ if let thread = threadToCheck, thread.isCancelled {
1087
+ logger.warn("cleanupDownloadDirectories was cancelled")
1088
+ return
1089
+ }
1090
+
1022
1091
  let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
1023
1092
  if resourceValues.isDirectory != true {
1024
1093
  continue
@@ -264,4 +264,23 @@ public struct CryptoCipher {
264
264
  throw CustomError.cannotDecode
265
265
  }
266
266
  }
267
+
268
+ /// Get first 4 characters of the public key for identification
269
+ /// Returns 4-character string or empty string if key is invalid/empty
270
+ public static func calcKeyId(publicKey: String) -> String {
271
+ if publicKey.isEmpty {
272
+ return ""
273
+ }
274
+
275
+ // Remove PEM headers and whitespace to get the raw key data
276
+ let cleanedKey = publicKey
277
+ .replacingOccurrences(of: "-----BEGIN RSA PUBLIC KEY-----", with: "")
278
+ .replacingOccurrences(of: "-----END RSA PUBLIC KEY-----", with: "")
279
+ .replacingOccurrences(of: "\n", with: "")
280
+ .replacingOccurrences(of: "\r", with: "")
281
+ .replacingOccurrences(of: " ", with: "")
282
+
283
+ // Return first 4 characters of the base64-encoded key
284
+ return String(cleanedKey.prefix(4))
285
+ }
267
286
  }
@@ -128,6 +128,7 @@ struct InfoObject: Codable {
128
128
  var action: String?
129
129
  var channel: String?
130
130
  var defaultChannel: String?
131
+ var key_id: String?
131
132
  }
132
133
 
133
134
  public struct ManifestEntry: Codable {
@@ -160,6 +161,8 @@ struct AppVersionDec: Decodable {
160
161
  let breaking: Bool?
161
162
  let data: [String: String]?
162
163
  let manifest: [ManifestEntry]?
164
+ let link: String?
165
+ let comment: String?
163
166
  // The HTTP status code is captured separately in CapgoUpdater; this struct only mirrors JSON.
164
167
  }
165
168
 
@@ -174,6 +177,8 @@ public class AppVersion: NSObject {
174
177
  var breaking: Bool?
175
178
  var data: [String: String]?
176
179
  var manifest: [ManifestEntry]?
180
+ var link: String?
181
+ var comment: String?
177
182
  var statusCode: Int = 0
178
183
  }
179
184
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "6.34.0",
3
+ "version": "6.37.0",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",
@@ -37,6 +37,7 @@
37
37
  "ionic",
38
38
  "appflow alternative",
39
39
  "capawesome alternative",
40
+ "@capawesome/capacitor-live-update",
40
41
  "native"
41
42
  ],
42
43
  "scripts": {