@capgo/capacitor-updater 7.41.1 → 7.42.3

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,7 +16,18 @@ import Version
16
16
  */
17
17
  @objc(CapacitorUpdaterPlugin)
18
18
  public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
19
- let logger = Logger(withTag: "✨ CapgoUpdater")
19
+ lazy var logger: Logger = {
20
+ // Default to true for OS logging. In test environments without a bridge,
21
+ // this will default to true. In production, it reads from config.
22
+ let osLogging: Bool
23
+ if self.bridge != nil {
24
+ osLogging = getConfig().getBoolean("osLogging", true)
25
+ } else {
26
+ osLogging = true
27
+ }
28
+ let options = Logger.Options(useSyslog: osLogging)
29
+ return Logger(withTag: "✨ CapgoUpdater", options: options)
30
+ }()
20
31
 
21
32
  public let identifier = "CapacitorUpdaterPlugin"
22
33
  public let jsName = "CapacitorUpdater"
@@ -60,7 +71,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
60
71
  CAPPluginMethod(name: "completeFlexibleUpdate", returnType: CAPPluginReturnPromise)
61
72
  ]
62
73
  public var implementation = CapgoUpdater()
63
- private let pluginVersion: String = "7.41.1"
74
+ private let pluginVersion: String = "7.42.3"
64
75
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
65
76
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
66
77
  static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
@@ -382,6 +393,32 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
382
393
  self.logger.info("Cleanup complete")
383
394
  }
384
395
 
396
+ // Michael (WcaleNieWolny) at 04.01.2026
397
+ // The following line of code contains a bug. After having evaluated it, I have decided not to fix it.
398
+ // The initial report: https://discord.com/channels/912707985829163099/1456985639345061969
399
+ // The bug happens in a very specific scenario. Here is the reproduction steps, followed by the lackof busniess impact
400
+ // Reproduction steps:
401
+ // 1. Install iOS app via app store. Version: 10.13.0. Version v10 of the app uses Capacitor 6 (6.3.13) - a version where the key was still "LatestVersionNative"
402
+ // 2. The plugin writes "10.13.0" to the key "LatestVersionNative"
403
+ // 3. Update the app to version 10.17.0 via Capgo.
404
+ // 4. Update the app via testflight to version 11.0.0. This version uses Capacitor 8 (8.41.3) - a version where the key was changed to "LatestNativeBuildVersion"
405
+ // 5. During the initial load of then new native version, the plugin will read "LatestNativeBuildVersion", not find it, read "LatestVersionNative", find it and revert to builtin version sucessfully.
406
+ // 6. The plugin writes "11.0.0" to the key "LatestNativeBuildVersion"
407
+ // 7. The app is now in a state where it is using the builtin version, but the key "LatestNativeBuildVersion" is still set to "11.0.0" and "LatestVersionNative" is still set to "10.13.0".
408
+ // 8. The user downgrades using app store back to version 10.13.0.
409
+ // 9. The old plugin reads "LatestVersionNative", finds "10.13.0," so it doesn't revert to builtin version. // <--- THIS IS THE FIRST PART OF THE BUG
410
+ // 10. "LatestVersionNative" is written to "10.13.0" but "LatestNativeBuildVersion" is not touched, and stays at "11.0.0"
411
+ // 11. A capgo update happesn to version 10.17.0.
412
+ // 12. The user updates again to version 11.0.0 via Testflight.
413
+ // 13. The plugin reads "LatestNativeBuildVersion", finds "11.0.0", so it doesn't revert to builtin version. It is unaware of the native update that happended.
414
+ // 14. Capgo loads the 10.13.0 version, while it should have loaded the builtin 11.0.0 version. // <--- THIS IS THE SECOND PART OF THE BUG
415
+ // The business impact:
416
+ // None - no one will ever be affected by this bug as reverting via app store should in practice never happen. You are not SUPPOSE to go from Capacitor v8 to v6.
417
+ // Downgrading isn't supported.
418
+ // Possible fixes:
419
+ // 1. Write "LatestVersionNative" - this fixes the part 1 of this bug
420
+ // 2. Compare both keys. If any is not equal to "currentBuildVersion", then revert to builtin version. This fixes the part 2 of this bug
421
+
385
422
  let previous = UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
386
423
  if previous != "0" && self.currentBuildVersion != previous {
387
424
  _ = self._reset(toLastSuccessful: false)
@@ -1571,6 +1608,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1571
1608
  }
1572
1609
 
1573
1610
  @objc func appMovedToBackground() {
1611
+ // Reset timeout flag at start of each background cycle
1612
+ self.autoSplashscreenTimedOut = false
1613
+
1574
1614
  let current: BundleInfo = self.implementation.getCurrentBundle()
1575
1615
  self.implementation.sendStats(action: "app_moved_to_background", versionName: current.getVersionName())
1576
1616
  logger.info("Check for pending update")
@@ -48,6 +48,12 @@ import UIKit
48
48
  // Flag to track if we've already sent the rate limit statistic - prevents infinite loop
49
49
  private static var rateLimitStatisticSent = false
50
50
 
51
+ // Stats batching - queue events and send max once per second
52
+ private var statsQueue: [StatsEvent] = []
53
+ private let statsQueueLock = NSLock()
54
+ private var statsFlushTimer: Timer?
55
+ private static let statsFlushInterval: TimeInterval = 1.0
56
+
51
57
  private var userAgent: String {
52
58
  let safePluginVersion = pluginVersion.isEmpty ? "unknown" : pluginVersion
53
59
  let safeAppId = appId.isEmpty ? "unknown" : appId
@@ -70,6 +76,15 @@ import UIKit
70
76
  self.logger = logger
71
77
  }
72
78
 
79
+ deinit {
80
+ // Invalidate the stats timer to prevent memory leaks
81
+ statsFlushTimer?.invalidate()
82
+ statsFlushTimer = nil
83
+
84
+ // Flush any remaining stats before deallocation
85
+ flushStatsQueue()
86
+ }
87
+
73
88
  private func calcTotalPercent(percent: Int, min: Int, max: Int) -> Int {
74
89
  return (percent * (max - min)) / 100 + min
75
90
  }
@@ -80,12 +95,20 @@ import UIKit
80
95
  }
81
96
 
82
97
  public func setPublicKey(_ publicKey: String) {
83
- self.publicKey = publicKey
84
- if !publicKey.isEmpty {
85
- self.cachedKeyId = CryptoCipher.calcKeyId(publicKey: publicKey)
86
- } else {
98
+ // Empty string means no encryption - proceed normally
99
+ if publicKey.isEmpty {
100
+ self.publicKey = ""
87
101
  self.cachedKeyId = nil
102
+ return
103
+ }
104
+
105
+ // Non-empty: must be a valid RSA key or crash
106
+ guard RSAPublicKey.load(rsaPublicKey: publicKey) != nil else {
107
+ fatalError("Invalid public key in capacitor.config.json: failed to parse RSA key. Remove the key or provide a valid PEM-formatted RSA public key.")
88
108
  }
109
+
110
+ self.publicKey = publicKey
111
+ self.cachedKeyId = CryptoCipher.calcKeyId(publicKey: publicKey)
89
112
  }
90
113
 
91
114
  public func getKeyId() -> String? {
@@ -360,6 +383,56 @@ import UIKit
360
383
  }
361
384
  }
362
385
 
386
+ private func populateDeltaCacheAsync(for id: String) {
387
+ DispatchQueue.global(qos: .utility).async { [weak self] in
388
+ self?.populateDeltaCache(for: id)
389
+ }
390
+ }
391
+
392
+ private func populateDeltaCache(for id: String) {
393
+ let bundleDir = self.getBundleDirectory(id: id)
394
+ let fileManager = FileManager.default
395
+
396
+ guard fileManager.fileExists(atPath: bundleDir.path) else {
397
+ logger.debug("Skip delta cache population: bundle dir missing")
398
+ return
399
+ }
400
+
401
+ do {
402
+ try fileManager.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
403
+ } catch {
404
+ logger.debug("Skip delta cache population: failed to create cache dir")
405
+ return
406
+ }
407
+
408
+ guard let enumerator = fileManager.enumerator(at: bundleDir, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) else {
409
+ return
410
+ }
411
+
412
+ for case let fileURL as URL in enumerator {
413
+ let resourceValues = try? fileURL.resourceValues(forKeys: [.isDirectoryKey])
414
+ if resourceValues?.isDirectory == true {
415
+ continue
416
+ }
417
+
418
+ let checksum = CryptoCipher.calcChecksum(filePath: fileURL)
419
+ if checksum.isEmpty {
420
+ continue
421
+ }
422
+
423
+ let cacheFile = cacheFolder.appendingPathComponent("\(checksum)_\(fileURL.lastPathComponent)")
424
+ if fileManager.fileExists(atPath: cacheFile.path) {
425
+ continue
426
+ }
427
+
428
+ do {
429
+ try fileManager.copyItem(at: fileURL, to: cacheFile)
430
+ } catch {
431
+ logger.debug("Delta cache copy failed: \(fileURL.path)")
432
+ }
433
+ }
434
+ }
435
+
363
436
  private func createInfoObject() -> InfoObject {
364
437
  return InfoObject(
365
438
  platform: "ios",
@@ -526,10 +599,11 @@ import UIKit
526
599
 
527
600
  let finalFileHash = fileHash
528
601
  let fileNameWithoutPath = (fileName as NSString).lastPathComponent
529
- let cacheFileName = "\(finalFileHash)_\(fileNameWithoutPath)"
530
- let cacheFilePath = cacheFolder.appendingPathComponent(cacheFileName)
531
-
532
602
  let isBrotli = fileName.hasSuffix(".br")
603
+ let cacheBaseName = isBrotli ? String(fileNameWithoutPath.dropLast(3)) : fileNameWithoutPath
604
+ let cacheFilePath = cacheFolder.appendingPathComponent("\(finalFileHash)_\(cacheBaseName)")
605
+ let legacyCacheFilePath: URL? = isBrotli ? cacheFolder.appendingPathComponent("\(finalFileHash)_\(fileNameWithoutPath)") : nil
606
+
533
607
  let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
534
608
  let destFilePath = destFolder.appendingPathComponent(destFileName)
535
609
  let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
@@ -548,7 +622,9 @@ import UIKit
548
622
  self.logger.info("downloadManifest \(fileName) using builtin file \(id)")
549
623
  }
550
624
  // Try cache
551
- else if self.tryCopyFromCache(from: cacheFilePath, to: destFilePath, expectedHash: finalFileHash) {
625
+ else if
626
+ self.tryCopyFromCache(from: cacheFilePath, to: destFilePath, expectedHash: finalFileHash) ||
627
+ (legacyCacheFilePath != nil && self.tryCopyFromCache(from: legacyCacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) {
552
628
  self.logger.info("downloadManifest \(fileName) copy from cache \(id)")
553
629
  }
554
630
  // Download
@@ -962,6 +1038,7 @@ import UIKit
962
1038
  CryptoCipher.logChecksumInfo(label: "Calculated bundle checksum", hexChecksum: checksum)
963
1039
  logger.info("Downloading: 80% (unzipping)")
964
1040
  try self.saveDownloaded(sourceZip: finalPath, id: id, base: self.libraryDir.appendingPathComponent(self.bundleDirectory), notify: true)
1041
+ self.populateDeltaCacheAsync(for: id)
965
1042
 
966
1043
  } catch {
967
1044
  logger.error("Failed to unzip file")
@@ -1306,7 +1383,7 @@ import UIKit
1306
1383
  let fileName = url.lastPathComponent
1307
1384
  // Only cleanup package_*.tmp and update_*.dat files
1308
1385
  let isDownloadTemp = (fileName.hasPrefix("package_") && fileName.hasSuffix(".tmp")) ||
1309
- (fileName.hasPrefix("update_") && fileName.hasSuffix(".dat"))
1386
+ (fileName.hasPrefix("update_") && fileName.hasSuffix(".dat"))
1310
1387
  if !isDownloadTemp {
1311
1388
  continue
1312
1389
  }
@@ -1667,21 +1744,70 @@ import UIKit
1667
1744
  guard !statsUrl.isEmpty else {
1668
1745
  return
1669
1746
  }
1670
- operationQueue.maxConcurrentOperationCount = 1
1671
1747
 
1672
- let versionName = versionName ?? getCurrentBundle().getVersionName()
1748
+ let resolvedVersionName = versionName ?? getCurrentBundle().getVersionName()
1749
+ let info = createInfoObject()
1750
+
1751
+ let event = StatsEvent(
1752
+ platform: info.platform,
1753
+ device_id: info.device_id,
1754
+ app_id: info.app_id,
1755
+ custom_id: info.custom_id,
1756
+ version_build: info.version_build,
1757
+ version_code: info.version_code,
1758
+ version_os: info.version_os,
1759
+ version_name: resolvedVersionName,
1760
+ old_version_name: oldVersionName ?? "",
1761
+ plugin_version: info.plugin_version,
1762
+ is_emulator: info.is_emulator,
1763
+ is_prod: info.is_prod,
1764
+ action: action,
1765
+ channel: info.channel,
1766
+ defaultChannel: info.defaultChannel,
1767
+ key_id: info.key_id,
1768
+ timestamp: Int64(Date().timeIntervalSince1970 * 1000)
1769
+ )
1770
+
1771
+ statsQueueLock.lock()
1772
+ statsQueue.append(event)
1773
+ statsQueueLock.unlock()
1673
1774
 
1674
- var parameters = createInfoObject()
1675
- parameters.action = action
1676
- parameters.version_name = versionName
1677
- parameters.old_version_name = oldVersionName ?? ""
1775
+ ensureStatsTimerStarted()
1776
+ }
1777
+
1778
+ private func ensureStatsTimerStarted() {
1779
+ DispatchQueue.main.async { [weak self] in
1780
+ guard let self = self else { return }
1781
+ if self.statsFlushTimer == nil || !self.statsFlushTimer!.isValid {
1782
+ // Use closure-based timer to avoid strong reference cycle
1783
+ self.statsFlushTimer = Timer.scheduledTimer(
1784
+ withTimeInterval: CapgoUpdater.statsFlushInterval,
1785
+ repeats: true
1786
+ ) { [weak self] _ in
1787
+ self?.flushStatsQueue()
1788
+ }
1789
+ }
1790
+ }
1791
+ }
1792
+
1793
+ private func flushStatsQueue() {
1794
+ statsQueueLock.lock()
1795
+ guard !statsQueue.isEmpty else {
1796
+ statsQueueLock.unlock()
1797
+ return
1798
+ }
1799
+ let eventsToSend = statsQueue
1800
+ statsQueue.removeAll()
1801
+ statsQueueLock.unlock()
1802
+
1803
+ operationQueue.maxConcurrentOperationCount = 1
1678
1804
 
1679
1805
  let operation = BlockOperation {
1680
1806
  let semaphore = DispatchSemaphore(value: 0)
1681
1807
  self.alamofireSession.request(
1682
1808
  self.statsUrl,
1683
1809
  method: .post,
1684
- parameters: parameters,
1810
+ parameters: eventsToSend,
1685
1811
  encoder: JSONParameterEncoder.default,
1686
1812
  requestModifier: { $0.timeoutInterval = self.timeout }
1687
1813
  ).responseData { response in
@@ -1693,10 +1819,10 @@ import UIKit
1693
1819
 
1694
1820
  switch response.result {
1695
1821
  case .success:
1696
- self.logger.info("Stats sent successfully")
1697
- self.logger.debug("Action: \(action), Version: \(versionName)")
1822
+ self.logger.info("Stats batch sent successfully")
1823
+ self.logger.debug("Sent \(eventsToSend.count) events")
1698
1824
  case let .failure(error):
1699
- self.logger.error("Error sending stats")
1825
+ self.logger.error("Error sending stats batch")
1700
1826
  self.logger.debug("Response: \(response.value?.debugDescription ?? "nil"), Error: \(error.localizedDescription)")
1701
1827
  }
1702
1828
  semaphore.signal()
@@ -1704,7 +1830,6 @@ import UIKit
1704
1830
  semaphore.wait()
1705
1831
  }
1706
1832
  operationQueue.addOperation(operation)
1707
-
1708
1833
  }
1709
1834
 
1710
1835
  public func getBundleInfo(id: String?) -> BundleInfo {
@@ -63,13 +63,21 @@ public struct CryptoCipher {
63
63
  detectedFormat = "base64"
64
64
  }
65
65
  // swiftlint:disable:next line_length
66
- logger.debug("Received encrypted checksum format: \(detectedFormat) (length: \(checksum.count) chars, \(checksumBytes.count) bytes)")
66
+ logger.debug("Received checksum format: \(detectedFormat) (length: \(checksum.count) chars, \(checksumBytes.count) bytes)")
67
67
 
68
68
  if checksumBytes.isEmpty {
69
69
  logger.error("Decoded checksum is empty")
70
70
  throw CustomError.cannotDecode
71
71
  }
72
72
 
73
+ // RSA-2048 encrypted data must be exactly 256 bytes
74
+ // If the checksum is not 256 bytes, the bundle was not encrypted properly
75
+ if checksumBytes.count != 256 {
76
+ // swiftlint:disable:next line_length
77
+ logger.error("Checksum is not RSA encrypted (size: \(checksumBytes.count) bytes, expected 256 for RSA-2048). Bundle must be uploaded with encryption when public key is configured.")
78
+ throw CustomError.cannotDecode
79
+ }
80
+
73
81
  guard let rsaPublicKey = RSAPublicKey.load(rsaPublicKey: publicKey) else {
74
82
  logger.error("The public key is not a valid RSA Public key")
75
83
  throw CustomError.cannotDecode
@@ -135,6 +135,28 @@ struct InfoObject: Codable {
135
135
  }
136
136
  // swiftlint:enable identifier_name
137
137
 
138
+ // swiftlint:disable identifier_name
139
+ struct StatsEvent: Codable {
140
+ let platform: String?
141
+ let device_id: String?
142
+ let app_id: String?
143
+ let custom_id: String?
144
+ let version_build: String?
145
+ let version_code: String?
146
+ let version_os: String?
147
+ let version_name: String?
148
+ let old_version_name: String?
149
+ let plugin_version: String?
150
+ let is_emulator: Bool?
151
+ let is_prod: Bool?
152
+ let action: String?
153
+ let channel: String?
154
+ let defaultChannel: String?
155
+ let key_id: String?
156
+ let timestamp: Int64
157
+ }
158
+ // swiftlint:enable identifier_name
159
+
138
160
  // swiftlint:disable identifier_name
139
161
  public struct ManifestEntry: Codable {
140
162
  let file_name: String?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "7.41.1",
3
+ "version": "7.42.3",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",