@capgo/capacitor-updater 5.34.0 → 5.38.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 = "5.31.0"
57
+ private let pluginVersion: String = "5.38.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)
@@ -196,7 +201,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
196
201
  periodCheckDelay = periodCheckDelayValue
197
202
  }
198
203
 
199
- implementation.publicKey = getConfig().getString("publicKey", "")!
204
+ implementation.setPublicKey(getConfig().getString("publicKey") ?? "")
200
205
  implementation.notifyDownloadRaw = notifyDownload
201
206
  implementation.pluginVersion = self.pluginVersion
202
207
 
@@ -204,6 +209,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
204
209
  implementation.setLogger(logger)
205
210
  CryptoCipher.setLogger(logger)
206
211
 
212
+ // Log public key prefix if encryption is enabled
213
+ if let keyId = implementation.getKeyId(), !keyId.isEmpty {
214
+ logger.info("Public key prefix: \(keyId)")
215
+ }
216
+
207
217
  // Initialize DelayUpdateUtils
208
218
  self.delayUpdateUtils = DelayUpdateUtils(currentVersionNative: currentVersionNative, logger: logger)
209
219
  let config = (self.bridge?.viewController as? CAPBridgeViewController)?.instanceDescriptor().legacyConfig
@@ -358,28 +368,74 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
358
368
  }
359
369
 
360
370
  private func cleanupObsoleteVersions() {
361
- let previous = UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? "0"
362
- if previous != "0" && self.currentBuildVersion != previous {
363
- _ = self._reset(toLastSuccessful: false)
364
- let res = implementation.list()
365
- res.forEach { version in
366
- logger.info("Deleting obsolete bundle: \(version.getId())")
367
- let res = implementation.delete(id: version.getId())
368
- if !res {
369
- logger.error("Delete failed, id \(version.getId()) doesn't exist")
371
+ cleanupThread = Thread {
372
+ self.cleanupLock.lock()
373
+ defer {
374
+ self.cleanupComplete = true
375
+ self.cleanupLock.unlock()
376
+ self.logger.info("Cleanup complete")
377
+ }
378
+
379
+ let previous = UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
380
+ if previous != "0" && self.currentBuildVersion != previous {
381
+ _ = self._reset(toLastSuccessful: false)
382
+ let res = self.implementation.list()
383
+ for version in res {
384
+ // Check if thread was cancelled
385
+ if Thread.current.isCancelled {
386
+ self.logger.warn("Cleanup was cancelled, stopping")
387
+ return
388
+ }
389
+ self.logger.info("Deleting obsolete bundle: \(version.getId())")
390
+ let res = self.implementation.delete(id: version.getId())
391
+ if !res {
392
+ self.logger.error("Delete failed, id \(version.getId()) doesn't exist")
393
+ }
394
+ }
395
+
396
+ let storedBundles = self.implementation.list(raw: true)
397
+ let allowedIds = Set(storedBundles.compactMap { info -> String? in
398
+ let id = info.getId()
399
+ return id.isEmpty ? nil : id
400
+ })
401
+ self.implementation.cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: Thread.current)
402
+ self.implementation.cleanupOrphanedTempFolders(threadToCheck: Thread.current)
403
+
404
+ // Check again before the expensive delta cache cleanup
405
+ if Thread.current.isCancelled {
406
+ self.logger.warn("Cleanup was cancelled before delta cache cleanup")
407
+ return
370
408
  }
409
+ self.implementation.cleanupDeltaCache(threadToCheck: Thread.current)
371
410
  }
411
+ UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
412
+ UserDefaults.standard.synchronize()
413
+ }
414
+ cleanupThread?.start()
372
415
 
373
- let storedBundles = implementation.list(raw: true)
374
- let allowedIds = Set(storedBundles.compactMap { info -> String? in
375
- let id = info.getId()
376
- return id.isEmpty ? nil : id
377
- })
378
- implementation.cleanupDownloadDirectories(allowedIds: allowedIds)
379
- implementation.cleanupDeltaCache()
416
+ // Start a timeout watchdog thread to cancel cleanup if it takes too long
417
+ let timeout = Double(self.appReadyTimeout / 2) / 1000.0
418
+ Thread.detachNewThread {
419
+ Thread.sleep(forTimeInterval: timeout)
420
+ if let thread = self.cleanupThread, !thread.isFinished && !self.cleanupComplete {
421
+ self.logger.warn("Cleanup timeout exceeded (\(timeout)s), cancelling cleanup thread")
422
+ thread.cancel()
423
+ }
380
424
  }
381
- UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
382
- UserDefaults.standard.synchronize()
425
+ }
426
+
427
+ private func waitForCleanupIfNeeded() {
428
+ if cleanupComplete {
429
+ return // Already done, no need to wait
430
+ }
431
+
432
+ logger.info("Waiting for cleanup to complete before starting download...")
433
+
434
+ // Wait for cleanup to complete - blocks until lock is released
435
+ cleanupLock.lock()
436
+ cleanupLock.unlock()
437
+
438
+ logger.info("Cleanup finished, proceeding with download")
383
439
  }
384
440
 
385
441
  @objc func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
@@ -1272,6 +1328,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1272
1328
  return
1273
1329
  }
1274
1330
  DispatchQueue.global(qos: .background).async {
1331
+ // Wait for cleanup to complete before starting download
1332
+ self.waitForCleanupIfNeeded()
1275
1333
  self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "Finish Download Tasks") {
1276
1334
  // End the task if time expires.
1277
1335
  self.endBackGroundTask()
@@ -1337,9 +1395,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1337
1395
  }
1338
1396
  }
1339
1397
  if res.manifest != nil {
1340
- nextImpl = try self.implementation.downloadManifest(manifest: res.manifest!, version: latestVersionName, sessionKey: sessionKey)
1398
+ nextImpl = try self.implementation.downloadManifest(manifest: res.manifest!, version: latestVersionName, sessionKey: sessionKey, link: res.link, comment: res.comment)
1341
1399
  } else {
1342
- nextImpl = try self.implementation.download(url: downloadUrl, version: latestVersionName, sessionKey: sessionKey)
1400
+ nextImpl = try self.implementation.download(url: downloadUrl, version: latestVersionName, sessionKey: sessionKey, link: res.link, comment: res.comment)
1343
1401
  }
1344
1402
  }
1345
1403
  guard let next = nextImpl else {
@@ -26,6 +26,7 @@ import UIKit
26
26
  private let FALLBACK_VERSION: String = "pastVersion"
27
27
  private let NEXT_VERSION: String = "nextVersion"
28
28
  private var unzipPercent = 0
29
+ private let TEMP_UNZIP_PREFIX: String = "capgo_unzip_"
29
30
 
30
31
  // Add this line to declare cacheFolder
31
32
  private let cacheFolder: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("capgo_downloads")
@@ -42,6 +43,9 @@ import UIKit
42
43
  public var deviceID = ""
43
44
  public var publicKey: String = ""
44
45
 
46
+ // Cached key ID calculated once from publicKey
47
+ private var cachedKeyId: String?
48
+
45
49
  // Flag to track if we received a 429 response - stops requests until app restart
46
50
  private static var rateLimitExceeded = false
47
51
 
@@ -79,6 +83,19 @@ import UIKit
79
83
  return String((0..<length).map { _ in letters.randomElement()! })
80
84
  }
81
85
 
86
+ public func setPublicKey(_ publicKey: String) {
87
+ self.publicKey = publicKey
88
+ if !publicKey.isEmpty {
89
+ self.cachedKeyId = CryptoCipher.calcKeyId(publicKey: publicKey)
90
+ } else {
91
+ self.cachedKeyId = nil
92
+ }
93
+ }
94
+
95
+ public func getKeyId() -> String? {
96
+ return self.cachedKeyId
97
+ }
98
+
82
99
  private var isDevEnvironment: Bool {
83
100
  #if DEBUG
84
101
  return true
@@ -258,7 +275,7 @@ import UIKit
258
275
  private func saveDownloaded(sourceZip: URL, id: String, base: URL, notify: Bool) throws {
259
276
  try prepareFolder(source: base)
260
277
  let destPersist: URL = base.appendingPathComponent(id)
261
- let destUnZip: URL = libraryDir.appendingPathComponent(randomString(length: 10))
278
+ let destUnZip: URL = libraryDir.appendingPathComponent(TEMP_UNZIP_PREFIX + randomString(length: 10))
262
279
 
263
280
  self.unzipPercent = 0
264
281
  self.notifyDownload(id: id, percent: 75)
@@ -324,7 +341,8 @@ import UIKit
324
341
  is_prod: self.isProd(),
325
342
  action: nil,
326
343
  channel: nil,
327
- defaultChannel: self.defaultChannel
344
+ defaultChannel: self.defaultChannel,
345
+ key_id: self.cachedKeyId
328
346
  )
329
347
  }
330
348
 
@@ -372,6 +390,12 @@ import UIKit
372
390
  if let manifest = response.value?.manifest {
373
391
  latest.manifest = manifest
374
392
  }
393
+ if let link = response.value?.link {
394
+ latest.link = link
395
+ }
396
+ if let comment = response.value?.comment {
397
+ latest.comment = comment
398
+ }
375
399
  case let .failure(error):
376
400
  self.logger.error("Error getting Latest \(response.value.debugDescription) \(error)")
377
401
  latest.message = "Error getting Latest \(String(describing: response.value))"
@@ -400,11 +424,11 @@ import UIKit
400
424
  private var tempData = Data()
401
425
 
402
426
  private func verifyChecksum(file: URL, expectedHash: String) -> Bool {
403
- let actualHash = CryptoCipher.calcChecksum(filePath: file)
427
+ let actualHash = CryptoCipher.calcChecksum(filePath: file)
404
428
  return actualHash == expectedHash
405
429
  }
406
430
 
407
- public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String) throws -> BundleInfo {
431
+ public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
408
432
  let id = self.randomString(length: 10)
409
433
  logger.info("downloadManifest start \(id)")
410
434
  let destFolder = self.getBundleDirectory(id: id)
@@ -414,7 +438,7 @@ import UIKit
414
438
  try FileManager.default.createDirectory(at: destFolder, withIntermediateDirectories: true, attributes: nil)
415
439
 
416
440
  // Create and save BundleInfo before starting the download process
417
- let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: "")
441
+ let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: "", link: link, comment: comment)
418
442
  self.saveBundleInfo(id: id, bundle: bundleInfo)
419
443
 
420
444
  // Send stats for manifest download start
@@ -445,10 +469,16 @@ import UIKit
445
469
  }
446
470
  }
447
471
 
472
+ // Check if file has .br extension for Brotli decompression
448
473
  let fileNameWithoutPath = (fileName as NSString).lastPathComponent
449
474
  let cacheFileName = "\(fileHash)_\(fileNameWithoutPath)"
450
475
  let cacheFilePath = cacheFolder.appendingPathComponent(cacheFileName)
451
- let destFilePath = destFolder.appendingPathComponent(fileName)
476
+
477
+ // Check if file is Brotli compressed and remove .br extension from destination
478
+ let isBrotli = fileName.hasSuffix(".br")
479
+ let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
480
+
481
+ let destFilePath = destFolder.appendingPathComponent(destFileName)
452
482
  let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
453
483
 
454
484
  // Create necessary subdirectories in the destination folder
@@ -457,13 +487,18 @@ import UIKit
457
487
  dispatchGroup.enter()
458
488
 
459
489
  if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
460
- try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
461
- logger.info("downloadManifest \(fileName) using builtin file \(id)")
462
- completedFiles += 1
463
- self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
490
+ do {
491
+ try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
492
+ logger.info("downloadManifest \(fileName) using builtin file \(id)")
493
+ completedFiles += 1
494
+ self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
495
+ } catch {
496
+ downloadError = error
497
+ logger.error("Failed to copy builtin file \(fileName): \(error.localizedDescription)")
498
+ }
464
499
  dispatchGroup.leave()
465
- } else if FileManager.default.fileExists(atPath: cacheFilePath.path) && verifyChecksum(file: cacheFilePath, expectedHash: fileHash) {
466
- try FileManager.default.copyItem(at: cacheFilePath, to: destFilePath)
500
+ } else if self.tryCopyFromCache(from: cacheFilePath, to: destFilePath, expectedHash: fileHash) {
501
+ // Successfully copied from cache
467
502
  logger.info("downloadManifest \(fileName) copy from cache \(id)")
468
503
  completedFiles += 1
469
504
  self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
@@ -502,15 +537,11 @@ import UIKit
502
537
  try FileManager.default.removeItem(at: tempFile)
503
538
  }
504
539
 
505
- // Check if file has .br extension for Brotli decompression
506
- let isBrotli = fileName.hasSuffix(".br")
507
- let finalFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
508
- let destFilePath = destFolder.appendingPathComponent(finalFileName)
509
-
540
+ // Use the isBrotli and destFilePath already computed above
510
541
  if isBrotli {
511
542
  // Decompress the Brotli data
512
543
  guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
513
- self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(finalFileName)")
544
+ self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
514
545
  throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
515
546
  }
516
547
  finalData = decompressedData
@@ -525,7 +556,7 @@ import UIKit
525
556
  if calculatedChecksum != fileHash {
526
557
  // Delete the corrupt file before throwing error
527
558
  try? FileManager.default.removeItem(at: destFilePath)
528
- self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(finalFileName)")
559
+ self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
529
560
  throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
530
561
  }
531
562
  }
@@ -570,6 +601,30 @@ import UIKit
570
601
  return updatedBundle
571
602
  }
572
603
 
604
+ /// Atomically try to copy a file from cache - returns true if successful, false if file doesn't exist or copy failed
605
+ /// This handles the race condition where OS can delete cache files between exists() check and copy
606
+ private func tryCopyFromCache(from source: URL, to destination: URL, expectedHash: String) -> Bool {
607
+ // First quick check - if file doesn't exist, don't bother
608
+ guard FileManager.default.fileExists(atPath: source.path) else {
609
+ return false
610
+ }
611
+
612
+ // Verify checksum before copy
613
+ guard verifyChecksum(file: source, expectedHash: expectedHash) else {
614
+ return false
615
+ }
616
+
617
+ // Try to copy - if it fails (file deleted by OS between check and copy), return false
618
+ do {
619
+ try FileManager.default.copyItem(at: source, to: destination)
620
+ return true
621
+ } catch {
622
+ // File was deleted between check and copy, or other IO error - caller should download instead
623
+ logger.debug("Cache copy failed (likely OS eviction): \(error.localizedDescription)")
624
+ return false
625
+ }
626
+ }
627
+
573
628
  private func decompressBrotli(data: Data, fileName: String) -> Data? {
574
629
  // Handle empty files
575
630
  if data.count == 0 {
@@ -683,7 +738,7 @@ import UIKit
683
738
  return status == COMPRESSION_STATUS_END ? decompressedData : nil
684
739
  }
685
740
 
686
- public func download(url: URL, version: String, sessionKey: String) throws -> BundleInfo {
741
+ public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
687
742
  let id: String = self.randomString(length: 10)
688
743
  let semaphore = DispatchSemaphore(value: 0)
689
744
  if version != getLocalUpdateVersion() {
@@ -750,7 +805,7 @@ import UIKit
750
805
  semaphore.signal()
751
806
  }
752
807
  }
753
- self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum))
808
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum, link: link, comment: comment))
754
809
  let reachabilityManager = NetworkReachabilityManager()
755
810
  reachabilityManager?.startListening { status in
756
811
  switch status {
@@ -768,7 +823,7 @@ import UIKit
768
823
 
769
824
  if mainError != nil {
770
825
  logger.error("Failed to download: \(String(describing: mainError))")
771
- self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
826
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
772
827
  throw mainError!
773
828
  }
774
829
 
@@ -778,7 +833,7 @@ import UIKit
778
833
  try FileManager.default.moveItem(at: tempDataPath, to: finalPath)
779
834
  } catch {
780
835
  logger.error("Failed decrypt file : \(error)")
781
- self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
836
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
782
837
  cleanDownloadData()
783
838
  throw error
784
839
  }
@@ -791,7 +846,7 @@ import UIKit
791
846
 
792
847
  } catch {
793
848
  logger.error("Failed to unzip file: \(error)")
794
- self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
849
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
795
850
  // Best-effort cleanup of the decrypted zip file when unzip fails
796
851
  do {
797
852
  if FileManager.default.fileExists(atPath: finalPath.path) {
@@ -806,7 +861,7 @@ import UIKit
806
861
 
807
862
  self.notifyDownload(id: id, percent: 90)
808
863
  logger.info("Downloading: 90% (wrapping up)")
809
- let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum)
864
+ let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum, link: link, comment: comment)
810
865
  self.saveBundleInfo(id: id, bundle: info)
811
866
  self.cleanDownloadData()
812
867
 
@@ -981,6 +1036,16 @@ import UIKit
981
1036
  }
982
1037
 
983
1038
  public func cleanupDeltaCache() {
1039
+ cleanupDeltaCache(threadToCheck: nil)
1040
+ }
1041
+
1042
+ public func cleanupDeltaCache(threadToCheck: Thread?) {
1043
+ // Check if thread was cancelled
1044
+ if let thread = threadToCheck, thread.isCancelled {
1045
+ logger.warn("cleanupDeltaCache was cancelled before starting")
1046
+ return
1047
+ }
1048
+
984
1049
  let fileManager = FileManager.default
985
1050
  guard fileManager.fileExists(atPath: cacheFolder.path) else {
986
1051
  return
@@ -994,6 +1059,10 @@ import UIKit
994
1059
  }
995
1060
 
996
1061
  public func cleanupDownloadDirectories(allowedIds: Set<String>) {
1062
+ cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: nil)
1063
+ }
1064
+
1065
+ public func cleanupDownloadDirectories(allowedIds: Set<String>, threadToCheck: Thread?) {
997
1066
  let bundleRoot = libraryDir.appendingPathComponent(bundleDirectory)
998
1067
  let fileManager = FileManager.default
999
1068
 
@@ -1005,6 +1074,12 @@ import UIKit
1005
1074
  let contents = try fileManager.contentsOfDirectory(at: bundleRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
1006
1075
 
1007
1076
  for url in contents {
1077
+ // Check if thread was cancelled
1078
+ if let thread = threadToCheck, thread.isCancelled {
1079
+ logger.warn("cleanupDownloadDirectories was cancelled")
1080
+ return
1081
+ }
1082
+
1008
1083
  let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
1009
1084
  if resourceValues.isDirectory != true {
1010
1085
  continue
@@ -1029,6 +1104,43 @@ import UIKit
1029
1104
  }
1030
1105
  }
1031
1106
 
1107
+ public func cleanupOrphanedTempFolders(threadToCheck: Thread?) {
1108
+ let fileManager = FileManager.default
1109
+
1110
+ do {
1111
+ let contents = try fileManager.contentsOfDirectory(at: libraryDir, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
1112
+
1113
+ for url in contents {
1114
+ // Check if thread was cancelled
1115
+ if let thread = threadToCheck, thread.isCancelled {
1116
+ logger.warn("cleanupOrphanedTempFolders was cancelled")
1117
+ return
1118
+ }
1119
+
1120
+ let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
1121
+ if resourceValues.isDirectory != true {
1122
+ continue
1123
+ }
1124
+
1125
+ let folderName = url.lastPathComponent
1126
+
1127
+ // Only delete folders with the temp unzip prefix
1128
+ if !folderName.hasPrefix(TEMP_UNZIP_PREFIX) {
1129
+ continue
1130
+ }
1131
+
1132
+ do {
1133
+ try fileManager.removeItem(at: url)
1134
+ logger.info("Deleted orphaned temp unzip folder: \(folderName)")
1135
+ } catch {
1136
+ logger.error("Failed to delete orphaned temp folder: \(folderName) \(error.localizedDescription)")
1137
+ }
1138
+ }
1139
+ } catch {
1140
+ logger.error("Failed to enumerate library directory for temp folder cleanup: \(error.localizedDescription)")
1141
+ }
1142
+ }
1143
+
1032
1144
  public func getBundleDirectory(id: String) -> URL {
1033
1145
  return libraryDir.appendingPathComponent(self.bundleDirectory).appendingPathComponent(id)
1034
1146
  }
@@ -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": "5.34.0",
3
+ "version": "5.38.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": {