@capgo/capacitor-updater 5.35.0 → 5.39.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.
@@ -51,10 +51,16 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
51
51
  CAPPluginMethod(name: "getNextBundle", returnType: CAPPluginReturnPromise),
52
52
  CAPPluginMethod(name: "getFailedUpdate", returnType: CAPPluginReturnPromise),
53
53
  CAPPluginMethod(name: "setShakeMenu", returnType: CAPPluginReturnPromise),
54
- CAPPluginMethod(name: "isShakeMenuEnabled", returnType: CAPPluginReturnPromise)
54
+ CAPPluginMethod(name: "isShakeMenuEnabled", returnType: CAPPluginReturnPromise),
55
+ // App Store update methods
56
+ CAPPluginMethod(name: "getAppUpdateInfo", returnType: CAPPluginReturnPromise),
57
+ CAPPluginMethod(name: "openAppStore", returnType: CAPPluginReturnPromise),
58
+ CAPPluginMethod(name: "performImmediateUpdate", returnType: CAPPluginReturnPromise),
59
+ CAPPluginMethod(name: "startFlexibleUpdate", returnType: CAPPluginReturnPromise),
60
+ CAPPluginMethod(name: "completeFlexibleUpdate", returnType: CAPPluginReturnPromise)
55
61
  ]
56
62
  public var implementation = CapgoUpdater()
57
- private let pluginVersion: String = "5.35.0"
63
+ private let pluginVersion: String = "5.39.0"
58
64
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
59
65
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
60
66
  static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
@@ -92,6 +98,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
92
98
  private var backgroundWork: DispatchWorkItem?
93
99
  private var taskRunning = false
94
100
  private var periodCheckDelay = 0
101
+
102
+ // Lock to ensure cleanup completes before downloads start
103
+ private let cleanupLock = NSLock()
104
+ private var cleanupComplete = false
105
+ private var cleanupThread: Thread?
95
106
  private var persistCustomId = false
96
107
  private var persistModifyUrl = false
97
108
  private var allowManualBundleError = false
@@ -185,7 +196,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
185
196
  logger.info("Loaded persisted updateUrl")
186
197
  }
187
198
  autoUpdate = getConfig().getBoolean("autoUpdate", true)
188
- appReadyTimeout = getConfig().getInt("appReadyTimeout", 10000)
199
+ appReadyTimeout = max(1000, getConfig().getInt("appReadyTimeout", 10000)) // Minimum 1 second
189
200
  implementation.timeout = Double(getConfig().getInt("responseTimeout", 20))
190
201
  resetWhenUpdate = getConfig().getBoolean("resetWhenUpdate", true)
191
202
  shakeMenuEnabled = getConfig().getBoolean("shakeMenu", false)
@@ -196,7 +207,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
196
207
  periodCheckDelay = periodCheckDelayValue
197
208
  }
198
209
 
199
- implementation.publicKey = getConfig().getString("publicKey", "")!
210
+ implementation.setPublicKey(getConfig().getString("publicKey") ?? "")
200
211
  implementation.notifyDownloadRaw = notifyDownload
201
212
  implementation.pluginVersion = self.pluginVersion
202
213
 
@@ -204,6 +215,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
204
215
  implementation.setLogger(logger)
205
216
  CryptoCipher.setLogger(logger)
206
217
 
218
+ // Log public key prefix if encryption is enabled
219
+ if let keyId = implementation.getKeyId(), !keyId.isEmpty {
220
+ logger.info("Public key prefix: \(keyId)")
221
+ }
222
+
207
223
  // Initialize DelayUpdateUtils
208
224
  self.delayUpdateUtils = DelayUpdateUtils(currentVersionNative: currentVersionNative, logger: logger)
209
225
  let config = (self.bridge?.viewController as? CAPBridgeViewController)?.instanceDescriptor().legacyConfig
@@ -358,28 +374,74 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
358
374
  }
359
375
 
360
376
  private func cleanupObsoleteVersions() {
361
- let previous = UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "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")
377
+ cleanupThread = Thread {
378
+ self.cleanupLock.lock()
379
+ defer {
380
+ self.cleanupComplete = true
381
+ self.cleanupLock.unlock()
382
+ self.logger.info("Cleanup complete")
383
+ }
384
+
385
+ let previous = UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
386
+ if previous != "0" && self.currentBuildVersion != previous {
387
+ _ = self._reset(toLastSuccessful: false)
388
+ let res = self.implementation.list()
389
+ for version in res {
390
+ // Check if thread was cancelled
391
+ if Thread.current.isCancelled {
392
+ self.logger.warn("Cleanup was cancelled, stopping")
393
+ return
394
+ }
395
+ self.logger.info("Deleting obsolete bundle: \(version.getId())")
396
+ let res = self.implementation.delete(id: version.getId())
397
+ if !res {
398
+ self.logger.error("Delete failed, id \(version.getId()) doesn't exist")
399
+ }
400
+ }
401
+
402
+ let storedBundles = self.implementation.list(raw: true)
403
+ let allowedIds = Set(storedBundles.compactMap { info -> String? in
404
+ let id = info.getId()
405
+ return id.isEmpty ? nil : id
406
+ })
407
+ self.implementation.cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: Thread.current)
408
+ self.implementation.cleanupOrphanedTempFolders(threadToCheck: Thread.current)
409
+
410
+ // Check again before the expensive delta cache cleanup
411
+ if Thread.current.isCancelled {
412
+ self.logger.warn("Cleanup was cancelled before delta cache cleanup")
413
+ return
370
414
  }
415
+ self.implementation.cleanupDeltaCache(threadToCheck: Thread.current)
371
416
  }
417
+ UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
418
+ UserDefaults.standard.synchronize()
419
+ }
420
+ cleanupThread?.start()
372
421
 
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()
422
+ // Start a timeout watchdog thread to cancel cleanup if it takes too long
423
+ let timeout = Double(self.appReadyTimeout / 2) / 1000.0
424
+ Thread.detachNewThread {
425
+ Thread.sleep(forTimeInterval: timeout)
426
+ if let thread = self.cleanupThread, !thread.isFinished && !self.cleanupComplete {
427
+ self.logger.warn("Cleanup timeout exceeded (\(timeout)s), cancelling cleanup thread")
428
+ thread.cancel()
429
+ }
380
430
  }
381
- UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
382
- UserDefaults.standard.synchronize()
431
+ }
432
+
433
+ private func waitForCleanupIfNeeded() {
434
+ if cleanupComplete {
435
+ return // Already done, no need to wait
436
+ }
437
+
438
+ logger.info("Waiting for cleanup to complete before starting download...")
439
+
440
+ // Wait for cleanup to complete - blocks until lock is released
441
+ cleanupLock.lock()
442
+ cleanupLock.unlock()
443
+
444
+ logger.info("Cleanup finished, proceeding with download")
383
445
  }
384
446
 
385
447
  @objc func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
@@ -1272,6 +1334,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1272
1334
  return
1273
1335
  }
1274
1336
  DispatchQueue.global(qos: .background).async {
1337
+ // Wait for cleanup to complete before starting download
1338
+ self.waitForCleanupIfNeeded()
1275
1339
  self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "Finish Download Tasks") {
1276
1340
  // End the task if time expires.
1277
1341
  self.endBackGroundTask()
@@ -1602,4 +1666,208 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1602
1666
  implementation.appId = appId
1603
1667
  call.resolve()
1604
1668
  }
1669
+
1670
+ // MARK: - App Store Update Methods
1671
+
1672
+ /// AppUpdateAvailability enum values matching TypeScript definitions
1673
+ private enum AppUpdateAvailability: Int {
1674
+ case unknown = 0
1675
+ case updateNotAvailable = 1
1676
+ case updateAvailable = 2
1677
+ case updateInProgress = 3
1678
+ }
1679
+
1680
+ @objc func getAppUpdateInfo(_ call: CAPPluginCall) {
1681
+ let country = call.getString("country", "US")
1682
+ let bundleId = implementation.appId
1683
+
1684
+ logger.info("Getting App Store update info for \(bundleId) in country \(country)")
1685
+
1686
+ DispatchQueue.global(qos: .background).async {
1687
+ let urlString = "https://itunes.apple.com/lookup?bundleId=\(bundleId)&country=\(country)"
1688
+ guard let url = URL(string: urlString) else {
1689
+ call.reject("Invalid URL for App Store lookup")
1690
+ return
1691
+ }
1692
+
1693
+ let task = URLSession.shared.dataTask(with: url) { data, response, error in
1694
+ if let error = error {
1695
+ self.logger.error("App Store lookup failed: \(error.localizedDescription)")
1696
+ call.reject("App Store lookup failed: \(error.localizedDescription)")
1697
+ return
1698
+ }
1699
+
1700
+ guard let data = data else {
1701
+ call.reject("No data received from App Store")
1702
+ return
1703
+ }
1704
+
1705
+ do {
1706
+ guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
1707
+ let resultCount = json["resultCount"] as? Int else {
1708
+ call.reject("Invalid response from App Store")
1709
+ return
1710
+ }
1711
+
1712
+ let currentVersionName = Bundle.main.versionName ?? "0.0.0"
1713
+ let currentVersionCode = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0"
1714
+
1715
+ var result: [String: Any] = [
1716
+ "currentVersionName": currentVersionName,
1717
+ "currentVersionCode": currentVersionCode,
1718
+ "updateAvailability": AppUpdateAvailability.unknown.rawValue
1719
+ ]
1720
+
1721
+ if resultCount > 0,
1722
+ let results = json["results"] as? [[String: Any]],
1723
+ let appInfo = results.first {
1724
+
1725
+ let availableVersion = appInfo["version"] as? String
1726
+ let releaseDate = appInfo["currentVersionReleaseDate"] as? String
1727
+ let minimumOsVersion = appInfo["minimumOsVersion"] as? String
1728
+
1729
+ result["availableVersionName"] = availableVersion
1730
+ result["availableVersionCode"] = availableVersion // iOS doesn't have separate version code
1731
+ result["availableVersionReleaseDate"] = releaseDate
1732
+ result["minimumOsVersion"] = minimumOsVersion
1733
+
1734
+ // Determine update availability by comparing versions
1735
+ if let availableVersion = availableVersion {
1736
+ do {
1737
+ let currentVer = try Version(currentVersionName)
1738
+ let availableVer = try Version(availableVersion)
1739
+ if availableVer > currentVer {
1740
+ result["updateAvailability"] = AppUpdateAvailability.updateAvailable.rawValue
1741
+ } else {
1742
+ result["updateAvailability"] = AppUpdateAvailability.updateNotAvailable.rawValue
1743
+ }
1744
+ } catch {
1745
+ // If version parsing fails, do string comparison
1746
+ if availableVersion != currentVersionName {
1747
+ result["updateAvailability"] = AppUpdateAvailability.updateAvailable.rawValue
1748
+ } else {
1749
+ result["updateAvailability"] = AppUpdateAvailability.updateNotAvailable.rawValue
1750
+ }
1751
+ }
1752
+ } else {
1753
+ result["updateAvailability"] = AppUpdateAvailability.updateNotAvailable.rawValue
1754
+ }
1755
+
1756
+ // iOS doesn't support in-app updates like Android
1757
+ result["immediateUpdateAllowed"] = false
1758
+ result["flexibleUpdateAllowed"] = false
1759
+ } else {
1760
+ // App not found in App Store (maybe not published yet)
1761
+ result["updateAvailability"] = AppUpdateAvailability.updateNotAvailable.rawValue
1762
+ self.logger.info("App not found in App Store for bundleId: \(bundleId)")
1763
+ }
1764
+
1765
+ call.resolve(result)
1766
+ } catch {
1767
+ self.logger.error("Failed to parse App Store response: \(error.localizedDescription)")
1768
+ call.reject("Failed to parse App Store response: \(error.localizedDescription)")
1769
+ }
1770
+ }
1771
+ task.resume()
1772
+ }
1773
+ }
1774
+
1775
+ @objc func openAppStore(_ call: CAPPluginCall) {
1776
+ let appId = call.getString("appId")
1777
+
1778
+ if let appId = appId {
1779
+ // Open App Store with provided app ID
1780
+ let urlString = "https://apps.apple.com/app/id\(appId)"
1781
+ guard let url = URL(string: urlString) else {
1782
+ call.reject("Invalid App Store URL")
1783
+ return
1784
+ }
1785
+ DispatchQueue.main.async {
1786
+ UIApplication.shared.open(url) { success in
1787
+ if success {
1788
+ call.resolve()
1789
+ } else {
1790
+ call.reject("Failed to open App Store")
1791
+ }
1792
+ }
1793
+ }
1794
+ } else {
1795
+ // Look up app ID using bundle identifier
1796
+ let bundleId = implementation.appId
1797
+ let lookupUrl = "https://itunes.apple.com/lookup?bundleId=\(bundleId)"
1798
+
1799
+ DispatchQueue.global(qos: .background).async {
1800
+ guard let url = URL(string: lookupUrl) else {
1801
+ call.reject("Invalid lookup URL")
1802
+ return
1803
+ }
1804
+
1805
+ let task = URLSession.shared.dataTask(with: url) { data, _, error in
1806
+ if let error = error {
1807
+ call.reject("Failed to lookup app: \(error.localizedDescription)")
1808
+ return
1809
+ }
1810
+
1811
+ guard let data = data,
1812
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1813
+ let results = json["results"] as? [[String: Any]],
1814
+ let appInfo = results.first,
1815
+ let trackId = appInfo["trackId"] as? Int else {
1816
+ // If lookup fails, try opening the generic App Store app page using bundle ID
1817
+ let fallbackUrlString = "https://apps.apple.com/app/\(bundleId)"
1818
+ guard let fallbackUrl = URL(string: fallbackUrlString) else {
1819
+ call.reject("Failed to find app in App Store and fallback URL is invalid")
1820
+ return
1821
+ }
1822
+ DispatchQueue.main.async {
1823
+ UIApplication.shared.open(fallbackUrl) { success in
1824
+ if success {
1825
+ call.resolve()
1826
+ } else {
1827
+ call.reject("Failed to open App Store")
1828
+ }
1829
+ }
1830
+ }
1831
+ return
1832
+ }
1833
+
1834
+ let appStoreUrl = "https://apps.apple.com/app/id\(trackId)"
1835
+ guard let url = URL(string: appStoreUrl) else {
1836
+ call.reject("Invalid App Store URL")
1837
+ return
1838
+ }
1839
+
1840
+ DispatchQueue.main.async {
1841
+ UIApplication.shared.open(url) { success in
1842
+ if success {
1843
+ call.resolve()
1844
+ } else {
1845
+ call.reject("Failed to open App Store")
1846
+ }
1847
+ }
1848
+ }
1849
+ }
1850
+ task.resume()
1851
+ }
1852
+ }
1853
+ }
1854
+
1855
+ @objc func performImmediateUpdate(_ call: CAPPluginCall) {
1856
+ // iOS doesn't support in-app updates like Android's Play Store
1857
+ // Redirect users to the App Store instead
1858
+ logger.warn("performImmediateUpdate is not supported on iOS. Use openAppStore() instead.")
1859
+ call.reject("In-app updates are not supported on iOS. Use openAppStore() to direct users to the App Store.", "NOT_SUPPORTED")
1860
+ }
1861
+
1862
+ @objc func startFlexibleUpdate(_ call: CAPPluginCall) {
1863
+ // iOS doesn't support flexible in-app updates
1864
+ logger.warn("startFlexibleUpdate is not supported on iOS. Use openAppStore() instead.")
1865
+ call.reject("Flexible updates are not supported on iOS. Use openAppStore() to direct users to the App Store.", "NOT_SUPPORTED")
1866
+ }
1867
+
1868
+ @objc func completeFlexibleUpdate(_ call: CAPPluginCall) {
1869
+ // iOS doesn't support flexible in-app updates
1870
+ logger.warn("completeFlexibleUpdate is not supported on iOS.")
1871
+ call.reject("Flexible updates are not supported on iOS.", "NOT_SUPPORTED")
1872
+ }
1605
1873
  }
@@ -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
 
@@ -479,16 +497,11 @@ import UIKit
479
497
  logger.error("Failed to copy builtin file \(fileName): \(error.localizedDescription)")
480
498
  }
481
499
  dispatchGroup.leave()
482
- } else if FileManager.default.fileExists(atPath: cacheFilePath.path) && verifyChecksum(file: cacheFilePath, expectedHash: fileHash) {
483
- do {
484
- try FileManager.default.copyItem(at: cacheFilePath, to: destFilePath)
485
- logger.info("downloadManifest \(fileName) copy from cache \(id)")
486
- completedFiles += 1
487
- self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
488
- } catch {
489
- downloadError = error
490
- logger.error("Failed to copy cached file \(fileName): \(error.localizedDescription)")
491
- }
500
+ } else if self.tryCopyFromCache(from: cacheFilePath, to: destFilePath, expectedHash: fileHash) {
501
+ // Successfully copied from cache
502
+ logger.info("downloadManifest \(fileName) copy from cache \(id)")
503
+ completedFiles += 1
504
+ self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
492
505
  dispatchGroup.leave()
493
506
  } else {
494
507
  // File not in cache, download, decompress, and save to both cache and destination
@@ -588,6 +601,30 @@ import UIKit
588
601
  return updatedBundle
589
602
  }
590
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
+
591
628
  private func decompressBrotli(data: Data, fileName: String) -> Data? {
592
629
  // Handle empty files
593
630
  if data.count == 0 {
@@ -999,6 +1036,16 @@ import UIKit
999
1036
  }
1000
1037
 
1001
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
+
1002
1049
  let fileManager = FileManager.default
1003
1050
  guard fileManager.fileExists(atPath: cacheFolder.path) else {
1004
1051
  return
@@ -1012,6 +1059,10 @@ import UIKit
1012
1059
  }
1013
1060
 
1014
1061
  public func cleanupDownloadDirectories(allowedIds: Set<String>) {
1062
+ cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: nil)
1063
+ }
1064
+
1065
+ public func cleanupDownloadDirectories(allowedIds: Set<String>, threadToCheck: Thread?) {
1015
1066
  let bundleRoot = libraryDir.appendingPathComponent(bundleDirectory)
1016
1067
  let fileManager = FileManager.default
1017
1068
 
@@ -1023,6 +1074,12 @@ import UIKit
1023
1074
  let contents = try fileManager.contentsOfDirectory(at: bundleRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
1024
1075
 
1025
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
+
1026
1083
  let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
1027
1084
  if resourceValues.isDirectory != true {
1028
1085
  continue
@@ -1047,6 +1104,43 @@ import UIKit
1047
1104
  }
1048
1105
  }
1049
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
+
1050
1144
  public func getBundleDirectory(id: String) -> URL {
1051
1145
  return libraryDir.appendingPathComponent(self.bundleDirectory).appendingPathComponent(id)
1052
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "5.35.0",
3
+ "version": "5.39.0",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",