@capgo/capacitor-updater 5.41.1 → 5.42.9

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 = "5.41.1"
74
+ private let pluginVersion: String = "5.42.9"
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",
@@ -504,10 +577,15 @@ import UIKit
504
577
 
505
578
  for entry in manifest {
506
579
  guard let fileName = entry.file_name,
507
- var fileHash = entry.file_hash,
508
580
  let downloadUrl = entry.download_url else {
509
581
  continue
510
582
  }
583
+ guard let entryFileHash = entry.file_hash, !entryFileHash.isEmpty else {
584
+ logger.error("Missing file_hash for manifest entry: \(entry.file_name ?? "unknown")")
585
+ hasError.value = true
586
+ continue
587
+ }
588
+ var fileHash = entryFileHash
511
589
 
512
590
  // Decrypt checksum if needed (done before creating operation)
513
591
  if !self.publicKey.isEmpty && !sessionKey.isEmpty {
@@ -526,10 +604,11 @@ import UIKit
526
604
 
527
605
  let finalFileHash = fileHash
528
606
  let fileNameWithoutPath = (fileName as NSString).lastPathComponent
529
- let cacheFileName = "\(finalFileHash)_\(fileNameWithoutPath)"
530
- let cacheFilePath = cacheFolder.appendingPathComponent(cacheFileName)
531
-
532
607
  let isBrotli = fileName.hasSuffix(".br")
608
+ let cacheBaseName = isBrotli ? String(fileNameWithoutPath.dropLast(3)) : fileNameWithoutPath
609
+ let cacheFilePath = cacheFolder.appendingPathComponent("\(finalFileHash)_\(cacheBaseName)")
610
+ let legacyCacheFilePath: URL? = isBrotli ? cacheFolder.appendingPathComponent("\(finalFileHash)_\(fileNameWithoutPath)") : nil
611
+
533
612
  let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
534
613
  let destFilePath = destFolder.appendingPathComponent(destFileName)
535
614
  let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
@@ -548,7 +627,9 @@ import UIKit
548
627
  self.logger.info("downloadManifest \(fileName) using builtin file \(id)")
549
628
  }
550
629
  // Try cache
551
- else if self.tryCopyFromCache(from: cacheFilePath, to: destFilePath, expectedHash: finalFileHash) {
630
+ else if
631
+ self.tryCopyFromCache(from: cacheFilePath, to: destFilePath, expectedHash: finalFileHash) ||
632
+ (legacyCacheFilePath != nil && self.tryCopyFromCache(from: legacyCacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) {
552
633
  self.logger.info("downloadManifest \(fileName) copy from cache \(id)")
553
634
  }
554
635
  // Download
@@ -668,16 +749,14 @@ import UIKit
668
749
  // Write to destination
669
750
  try finalData.write(to: destFilePath)
670
751
 
671
- // Verify checksum if encryption is enabled
672
- if !self.publicKey.isEmpty && !sessionKey.isEmpty {
673
- let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
674
- CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
675
- CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
676
- if calculatedChecksum != fileHash {
677
- try? FileManager.default.removeItem(at: destFilePath)
678
- self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
679
- throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
680
- }
752
+ // Always verify checksum when file_hash is present
753
+ let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
754
+ CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
755
+ CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
756
+ if calculatedChecksum != fileHash {
757
+ try? FileManager.default.removeItem(at: destFilePath)
758
+ self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
759
+ throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
681
760
  }
682
761
 
683
762
  // Save to cache
@@ -962,6 +1041,7 @@ import UIKit
962
1041
  CryptoCipher.logChecksumInfo(label: "Calculated bundle checksum", hexChecksum: checksum)
963
1042
  logger.info("Downloading: 80% (unzipping)")
964
1043
  try self.saveDownloaded(sourceZip: finalPath, id: id, base: self.libraryDir.appendingPathComponent(self.bundleDirectory), notify: true)
1044
+ self.populateDeltaCacheAsync(for: id)
965
1045
 
966
1046
  } catch {
967
1047
  logger.error("Failed to unzip file")
@@ -1306,7 +1386,7 @@ import UIKit
1306
1386
  let fileName = url.lastPathComponent
1307
1387
  // Only cleanup package_*.tmp and update_*.dat files
1308
1388
  let isDownloadTemp = (fileName.hasPrefix("package_") && fileName.hasSuffix(".tmp")) ||
1309
- (fileName.hasPrefix("update_") && fileName.hasSuffix(".dat"))
1389
+ (fileName.hasPrefix("update_") && fileName.hasSuffix(".dat"))
1310
1390
  if !isDownloadTemp {
1311
1391
  continue
1312
1392
  }
@@ -1472,6 +1552,15 @@ import UIKit
1472
1552
  if let responseValue = response.value {
1473
1553
  if let error = responseValue.error {
1474
1554
  setChannel.error = error
1555
+ } else if responseValue.unset == true {
1556
+ // Server requested to unset channel (public channel was requested)
1557
+ // Clear persisted defaultChannel and revert to config value
1558
+ UserDefaults.standard.removeObject(forKey: defaultChannelKey)
1559
+ UserDefaults.standard.synchronize()
1560
+ self.logger.info("Public channel requested, channel override removed")
1561
+
1562
+ setChannel.status = responseValue.status ?? "ok"
1563
+ setChannel.message = responseValue.message ?? "Public channel requested, channel override removed. Device will use public channel automatically."
1475
1564
  } else {
1476
1565
  // Success - persist defaultChannel
1477
1566
  self.defaultChannel = channel
@@ -1667,21 +1756,70 @@ import UIKit
1667
1756
  guard !statsUrl.isEmpty else {
1668
1757
  return
1669
1758
  }
1670
- operationQueue.maxConcurrentOperationCount = 1
1671
1759
 
1672
- let versionName = versionName ?? getCurrentBundle().getVersionName()
1760
+ let resolvedVersionName = versionName ?? getCurrentBundle().getVersionName()
1761
+ let info = createInfoObject()
1762
+
1763
+ let event = StatsEvent(
1764
+ platform: info.platform,
1765
+ device_id: info.device_id,
1766
+ app_id: info.app_id,
1767
+ custom_id: info.custom_id,
1768
+ version_build: info.version_build,
1769
+ version_code: info.version_code,
1770
+ version_os: info.version_os,
1771
+ version_name: resolvedVersionName,
1772
+ old_version_name: oldVersionName ?? "",
1773
+ plugin_version: info.plugin_version,
1774
+ is_emulator: info.is_emulator,
1775
+ is_prod: info.is_prod,
1776
+ action: action,
1777
+ channel: info.channel,
1778
+ defaultChannel: info.defaultChannel,
1779
+ key_id: info.key_id,
1780
+ timestamp: Int64(Date().timeIntervalSince1970 * 1000)
1781
+ )
1673
1782
 
1674
- var parameters = createInfoObject()
1675
- parameters.action = action
1676
- parameters.version_name = versionName
1677
- parameters.old_version_name = oldVersionName ?? ""
1783
+ statsQueueLock.lock()
1784
+ statsQueue.append(event)
1785
+ statsQueueLock.unlock()
1786
+
1787
+ ensureStatsTimerStarted()
1788
+ }
1789
+
1790
+ private func ensureStatsTimerStarted() {
1791
+ DispatchQueue.main.async { [weak self] in
1792
+ guard let self = self else { return }
1793
+ if self.statsFlushTimer == nil || !self.statsFlushTimer!.isValid {
1794
+ // Use closure-based timer to avoid strong reference cycle
1795
+ self.statsFlushTimer = Timer.scheduledTimer(
1796
+ withTimeInterval: CapgoUpdater.statsFlushInterval,
1797
+ repeats: true
1798
+ ) { [weak self] _ in
1799
+ self?.flushStatsQueue()
1800
+ }
1801
+ }
1802
+ }
1803
+ }
1804
+
1805
+ private func flushStatsQueue() {
1806
+ statsQueueLock.lock()
1807
+ guard !statsQueue.isEmpty else {
1808
+ statsQueueLock.unlock()
1809
+ return
1810
+ }
1811
+ let eventsToSend = statsQueue
1812
+ statsQueue.removeAll()
1813
+ statsQueueLock.unlock()
1814
+
1815
+ operationQueue.maxConcurrentOperationCount = 1
1678
1816
 
1679
1817
  let operation = BlockOperation {
1680
1818
  let semaphore = DispatchSemaphore(value: 0)
1681
1819
  self.alamofireSession.request(
1682
1820
  self.statsUrl,
1683
1821
  method: .post,
1684
- parameters: parameters,
1822
+ parameters: eventsToSend,
1685
1823
  encoder: JSONParameterEncoder.default,
1686
1824
  requestModifier: { $0.timeoutInterval = self.timeout }
1687
1825
  ).responseData { response in
@@ -1693,10 +1831,10 @@ import UIKit
1693
1831
 
1694
1832
  switch response.result {
1695
1833
  case .success:
1696
- self.logger.info("Stats sent successfully")
1697
- self.logger.debug("Action: \(action), Version: \(versionName)")
1834
+ self.logger.info("Stats batch sent successfully")
1835
+ self.logger.debug("Sent \(eventsToSend.count) events")
1698
1836
  case let .failure(error):
1699
- self.logger.error("Error sending stats")
1837
+ self.logger.error("Error sending stats batch")
1700
1838
  self.logger.debug("Response: \(response.value?.debugDescription ?? "nil"), Error: \(error.localizedDescription)")
1701
1839
  }
1702
1840
  semaphore.signal()
@@ -1704,7 +1842,6 @@ import UIKit
1704
1842
  semaphore.wait()
1705
1843
  }
1706
1844
  operationQueue.addOperation(operation)
1707
-
1708
1845
  }
1709
1846
 
1710
1847
  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
@@ -23,6 +23,7 @@ struct SetChannelDec: Decodable {
23
23
  let status: String?
24
24
  let error: String?
25
25
  let message: String?
26
+ let unset: Bool?
26
27
  }
27
28
  public class SetChannel: NSObject {
28
29
  var status: String = ""
@@ -135,6 +136,28 @@ struct InfoObject: Codable {
135
136
  }
136
137
  // swiftlint:enable identifier_name
137
138
 
139
+ // swiftlint:disable identifier_name
140
+ struct StatsEvent: Codable {
141
+ let platform: String?
142
+ let device_id: String?
143
+ let app_id: String?
144
+ let custom_id: String?
145
+ let version_build: String?
146
+ let version_code: String?
147
+ let version_os: String?
148
+ let version_name: String?
149
+ let old_version_name: String?
150
+ let plugin_version: String?
151
+ let is_emulator: Bool?
152
+ let is_prod: Bool?
153
+ let action: String?
154
+ let channel: String?
155
+ let defaultChannel: String?
156
+ let key_id: String?
157
+ let timestamp: Int64
158
+ }
159
+ // swiftlint:enable identifier_name
160
+
138
161
  // swiftlint:disable identifier_name
139
162
  public struct ManifestEntry: Codable {
140
163
  let file_name: String?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "5.41.1",
3
+ "version": "5.42.9",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",