@capgo/capacitor-updater 6.14.26 → 6.14.33

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.
Files changed (56) hide show
  1. package/CapgoCapacitorUpdater.podspec +3 -2
  2. package/Package.swift +2 -2
  3. package/README.md +350 -74
  4. package/android/build.gradle +20 -8
  5. package/android/proguard-rules.pro +22 -5
  6. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +52 -16
  7. package/android/src/main/java/ee/forgr/capacitor_updater/Callback.java +2 -2
  8. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +1202 -510
  9. package/android/src/main/java/ee/forgr/capacitor_updater/{CapacitorUpdater.java → CapgoUpdater.java} +566 -154
  10. package/android/src/main/java/ee/forgr/capacitor_updater/{CryptoCipher.java → CryptoCipherV1.java} +17 -9
  11. package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipherV2.java +15 -26
  12. package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +0 -3
  13. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +260 -0
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +221 -0
  15. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +300 -119
  16. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +63 -25
  17. package/android/src/main/java/ee/forgr/capacitor_updater/Logger.java +338 -0
  18. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +72 -0
  19. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +169 -0
  20. package/dist/docs.json +652 -63
  21. package/dist/esm/definitions.d.ts +274 -15
  22. package/dist/esm/definitions.js.map +1 -1
  23. package/dist/esm/history.d.ts +1 -0
  24. package/dist/esm/history.js +283 -0
  25. package/dist/esm/history.js.map +1 -0
  26. package/dist/esm/index.d.ts +1 -0
  27. package/dist/esm/index.js +1 -0
  28. package/dist/esm/index.js.map +1 -1
  29. package/dist/esm/web.d.ts +12 -1
  30. package/dist/esm/web.js +29 -2
  31. package/dist/esm/web.js.map +1 -1
  32. package/dist/plugin.cjs.js +311 -2
  33. package/dist/plugin.cjs.js.map +1 -1
  34. package/dist/plugin.js +311 -2
  35. package/dist/plugin.js.map +1 -1
  36. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/AES.swift +6 -3
  37. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1578 -0
  38. package/ios/{Plugin/CapacitorUpdater.swift → Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift} +408 -139
  39. package/ios/{Plugin/CryptoCipher.swift → Sources/CapacitorUpdaterPlugin/CryptoCipherV1.swift} +13 -6
  40. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/CryptoCipherV2.swift +33 -27
  41. package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +220 -0
  42. package/ios/Sources/CapacitorUpdaterPlugin/DeviceIdHelper.swift +120 -0
  43. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/InternalUtils.swift +47 -0
  44. package/ios/Sources/CapacitorUpdaterPlugin/Logger.swift +310 -0
  45. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/RSA.swift +1 -0
  46. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +112 -0
  47. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/UserDefaultsExtension.swift +0 -2
  48. package/package.json +20 -16
  49. package/ios/Plugin/CapacitorUpdaterPlugin.swift +0 -1030
  50. /package/{LICENCE → LICENSE} +0 -0
  51. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BigInt.swift +0 -0
  52. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleInfo.swift +0 -0
  53. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleStatus.swift +0 -0
  54. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayCondition.swift +0 -0
  55. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayUntilNext.swift +0 -0
  56. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/Info.plist +0 -0
@@ -14,7 +14,8 @@ import Alamofire
14
14
  import Compression
15
15
  import UIKit
16
16
 
17
- @objc public class CapacitorUpdater: NSObject {
17
+ @objc public class CapgoUpdater: NSObject {
18
+ private var logger: Logger!
18
19
 
19
20
  private let versionCode: String = Bundle.main.versionCode ?? ""
20
21
  private let versionOs = UIDevice.current.systemVersion
@@ -29,7 +30,6 @@ import UIKit
29
30
  // Add this line to declare cacheFolder
30
31
  private let cacheFolder: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("capgo_downloads")
31
32
 
32
- public static let TAG: String = "✨ Capacitor-updater:"
33
33
  public let CAP_SERVER_PATH: String = "serverBasePath"
34
34
  public var versionBuild: String = ""
35
35
  public var customId: String = ""
@@ -44,12 +44,34 @@ import UIKit
44
44
  public var publicKey: String = ""
45
45
  public var hasOldPrivateKeyPropertyInConfig: Bool = false
46
46
 
47
+ // Flag to track if we received a 429 response - stops requests until app restart
48
+ private static var rateLimitExceeded = false
49
+
50
+ // Flag to track if we've already sent the rate limit statistic - prevents infinite loop
51
+ private static var rateLimitStatisticSent = false
52
+
53
+ private var userAgent: String {
54
+ let safePluginVersion = PLUGIN_VERSION.isEmpty ? "unknown" : PLUGIN_VERSION
55
+ let safeAppId = appId.isEmpty ? "unknown" : appId
56
+ return "CapacitorUpdater/\(safePluginVersion) (\(safeAppId)) ios/\(versionOs)"
57
+ }
58
+
59
+ private lazy var alamofireSession: Session = {
60
+ let configuration = URLSessionConfiguration.default
61
+ configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
62
+ return Session(configuration: configuration)
63
+ }()
64
+
47
65
  public var notifyDownloadRaw: (String, Int, Bool) -> Void = { _, _, _ in }
48
66
  public func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false) {
49
67
  notifyDownloadRaw(id, percent, ignoreMultipleOfTen)
50
68
  }
51
69
  public var notifyDownload: (String, Int) -> Void = { _, _ in }
52
70
 
71
+ public func setLogger(_ logger: Logger) {
72
+ self.logger = logger
73
+ }
74
+
53
75
  private func calcTotalPercent(percent: Int, min: Int, max: Int) -> Int {
54
76
  return (percent * (max - min)) / 100 + min
55
77
  }
@@ -71,6 +93,58 @@ import UIKit
71
93
  return !self.isDevEnvironment && !self.isAppStoreReceiptSandbox() && !self.hasEmbeddedMobileProvision()
72
94
  }
73
95
 
96
+ /**
97
+ * Check if a 429 (Too Many Requests) response was received and set the flag
98
+ */
99
+ private func checkAndHandleRateLimitResponse(statusCode: Int?) -> Bool {
100
+ if statusCode == 429 {
101
+ // Send a statistic about the rate limit BEFORE setting the flag
102
+ // Only send once to prevent infinite loop if the stat request itself gets rate limited
103
+ if !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
104
+ CapgoUpdater.rateLimitStatisticSent = true
105
+ self.sendRateLimitStatistic()
106
+ }
107
+ CapgoUpdater.rateLimitExceeded = true
108
+ logger.warn("Rate limit exceeded (429). Stopping all stats and channel requests until app restart.")
109
+ return true
110
+ }
111
+ return false
112
+ }
113
+
114
+ /**
115
+ * Send a synchronous statistic about rate limiting
116
+ */
117
+ private func sendRateLimitStatistic() {
118
+ guard !statsUrl.isEmpty else {
119
+ return
120
+ }
121
+
122
+ let current = getCurrentBundle()
123
+ var parameters = createInfoObject()
124
+ parameters.action = "rate_limit_reached"
125
+ parameters.version_name = current.getVersionName()
126
+ parameters.old_version_name = ""
127
+
128
+ // Send synchronously to ensure it goes out before the flag is set
129
+ let semaphore = DispatchSemaphore(value: 0)
130
+ self.alamofireSession.request(
131
+ self.statsUrl,
132
+ method: .post,
133
+ parameters: parameters,
134
+ encoder: JSONParameterEncoder.default,
135
+ requestModifier: { $0.timeoutInterval = self.timeout }
136
+ ).responseData { response in
137
+ switch response.result {
138
+ case .success:
139
+ self.logger.info("Rate limit statistic sent")
140
+ case let .failure(error):
141
+ self.logger.error("Error sending rate limit statistic: \(error.localizedDescription)")
142
+ }
143
+ semaphore.signal()
144
+ }
145
+ semaphore.wait()
146
+ }
147
+
74
148
  // MARK: Private
75
149
  private func hasEmbeddedMobileProvision() -> Bool {
76
150
  guard Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") == nil else {
@@ -110,7 +184,7 @@ import UIKit
110
184
  do {
111
185
  try FileManager.default.createDirectory(atPath: source.path, withIntermediateDirectories: true, attributes: nil)
112
186
  } catch {
113
- print("\(CapacitorUpdater.TAG) Cannot createDirectory \(source.path)")
187
+ logger.error("Cannot createDirectory \(source.path)")
114
188
  throw CustomError.cannotCreateDirectory
115
189
  }
116
190
  }
@@ -120,7 +194,7 @@ import UIKit
120
194
  do {
121
195
  try FileManager.default.removeItem(atPath: source.path)
122
196
  } catch {
123
- print("\(CapacitorUpdater.TAG) File not removed. \(source.path)")
197
+ logger.error("File not removed. \(source.path)")
124
198
  throw CustomError.cannotDeleteDirectory
125
199
  }
126
200
  }
@@ -137,14 +211,14 @@ import UIKit
137
211
  return false
138
212
  }
139
213
  } catch {
140
- print("\(CapacitorUpdater.TAG) File not moved. source: \(source.path) dest: \(dest.path)")
214
+ logger.error("File not moved. source: \(source.path) dest: \(dest.path)")
141
215
  throw CustomError.cannotUnflat
142
216
  }
143
217
  }
144
218
 
145
219
  private func unzipProgressHandler(entry: String, zipInfo: unz_file_info, entryNumber: Int, total: Int, destUnZip: URL, id: String, unzipError: inout NSError?) {
146
220
  if entry.contains("\\") {
147
- print("\(CapacitorUpdater.TAG) unzip: Windows path is not supported, please use unix path as required by zip RFC: \(entry)")
221
+ logger.error("unzip: Windows path is not supported, please use unix path as required by zip RFC: \(entry)")
148
222
  self.sendStats(action: "windows_path_fail")
149
223
  }
150
224
 
@@ -227,7 +301,7 @@ import UIKit
227
301
  try FileManager.default.removeItem(at: sourceZip)
228
302
  }
229
303
  } catch {
230
- print("\(CapacitorUpdater.TAG) Could not delete source zip at \(sourceZip.path): \(error)")
304
+ logger.error("Could not delete source zip at \(sourceZip.path): \(error)")
231
305
  }
232
306
  }
233
307
 
@@ -257,8 +331,8 @@ import UIKit
257
331
  if let channel = channel {
258
332
  parameters.defaultChannel = channel
259
333
  }
260
- print("\(CapacitorUpdater.TAG) Auto-update parameters: \(parameters)")
261
- let request = AF.request(url, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
334
+ logger.info("Auto-update parameters: \(parameters)")
335
+ let request = alamofireSession.request(url, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
262
336
 
263
337
  request.validate().responseDecodable(of: AppVersionDec.self) { response in
264
338
  switch response.result {
@@ -275,6 +349,9 @@ import UIKit
275
349
  if let major = response.value?.major {
276
350
  latest.major = major
277
351
  }
352
+ if let breaking = response.value?.breaking {
353
+ latest.breaking = breaking
354
+ }
278
355
  if let error = response.value?.error {
279
356
  latest.error = error
280
357
  }
@@ -291,7 +368,7 @@ import UIKit
291
368
  latest.manifest = manifest
292
369
  }
293
370
  case let .failure(error):
294
- print("\(CapacitorUpdater.TAG) Error getting Latest", response.value ?? "", error )
371
+ self.logger.error("Error getting Latest \(response.value.debugDescription) \(error)")
295
372
  latest.message = "Error getting Latest \(String(describing: response.value))"
296
373
  latest.error = "response_error"
297
374
  }
@@ -304,7 +381,7 @@ import UIKit
304
381
  private func setCurrentBundle(bundle: String) {
305
382
  UserDefaults.standard.set(bundle, forKey: self.CAP_SERVER_PATH)
306
383
  UserDefaults.standard.synchronize()
307
- print("\(CapacitorUpdater.TAG) Current bundle set to: \((bundle ).isEmpty ? BundleInfo.ID_BUILTIN : bundle)")
384
+ logger.info("Current bundle set to: \((bundle ).isEmpty ? BundleInfo.ID_BUILTIN : bundle)")
308
385
  }
309
386
 
310
387
  private var tempDataPath: URL {
@@ -323,7 +400,7 @@ import UIKit
323
400
 
324
401
  public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String) throws -> BundleInfo {
325
402
  let id = self.randomString(length: 10)
326
- print("\(CapacitorUpdater.TAG) downloadManifest start \(id)")
403
+ logger.info("downloadManifest start \(id)")
327
404
  let destFolder = self.getBundleDirectory(id: id)
328
405
  let builtinFolder = Bundle.main.bundleURL.appendingPathComponent("public")
329
406
 
@@ -351,12 +428,16 @@ import UIKit
351
428
  }
352
429
 
353
430
  if !self.hasOldPrivateKeyPropertyInConfig && !self.publicKey.isEmpty && !sessionKey.isEmpty {
431
+ // V2 Encryption (publicKey)
354
432
  do {
355
- fileHash = try CryptoCipherV2.decryptChecksum(checksum: fileHash, publicKey: self.publicKey, version: version)
433
+ fileHash = try CryptoCipherV2.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
356
434
  } catch {
357
435
  downloadError = error
358
- print("\(CapacitorUpdater.TAG) CryptoCipherV2.decryptChecksum error \(id) \(fileName) error: \(error)")
436
+ logger.error("CryptoCipherV2.decryptChecksum error \(id) \(fileName) error: \(error)")
359
437
  }
438
+ } else if self.hasOldPrivateKeyPropertyInConfig {
439
+ // V1 Encryption (privateKey) - deprecated but supported
440
+ // V1 doesn't decrypt checksum, uses different method
360
441
  }
361
442
 
362
443
  let fileNameWithoutPath = (fileName as NSString).lastPathComponent
@@ -372,19 +453,19 @@ import UIKit
372
453
 
373
454
  if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
374
455
  try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
375
- print("\(CapacitorUpdater.TAG) downloadManifest \(fileName) using builtin file \(id)")
456
+ logger.info("downloadManifest \(fileName) using builtin file \(id)")
376
457
  completedFiles += 1
377
458
  self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
378
459
  dispatchGroup.leave()
379
460
  } else if FileManager.default.fileExists(atPath: cacheFilePath.path) && verifyChecksum(file: cacheFilePath, expectedHash: fileHash) {
380
461
  try FileManager.default.copyItem(at: cacheFilePath, to: destFilePath)
381
- print("\(CapacitorUpdater.TAG) downloadManifest \(fileName) copy from cache \(id)")
462
+ logger.info("downloadManifest \(fileName) copy from cache \(id)")
382
463
  completedFiles += 1
383
464
  self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
384
465
  dispatchGroup.leave()
385
466
  } else {
386
467
  // File not in cache, download, decompress, and save to both cache and destination
387
- AF.download(downloadUrl).responseData { response in
468
+ self.alamofireSession.download(downloadUrl).responseData { response in
388
469
  defer { dispatchGroup.leave() }
389
470
 
390
471
  switch response.result {
@@ -399,9 +480,10 @@ import UIKit
399
480
  }
400
481
  }
401
482
 
402
- // Add decryption step if public key is set and sessionKey is provided
483
+ // Add decryption step if encryption keys are set
403
484
  var finalData = data
404
- if !self.publicKey.isEmpty && !sessionKey.isEmpty {
485
+ if !self.hasOldPrivateKeyPropertyInConfig && !self.publicKey.isEmpty && !sessionKey.isEmpty {
486
+ // V2 Encryption (publicKey)
405
487
  let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
406
488
  try finalData.write(to: tempFile)
407
489
  do {
@@ -410,19 +492,36 @@ import UIKit
410
492
  self.sendStats(action: "decrypt_fail", versionName: version)
411
493
  throw error
412
494
  }
413
- // TODO: try and do self.sendStats(action: "decrypt_fail", versionName: version) if fail
495
+ finalData = try Data(contentsOf: tempFile)
496
+ } else if self.hasOldPrivateKeyPropertyInConfig && !sessionKey.isEmpty {
497
+ // V1 Encryption (privateKey) - deprecated but supported
498
+ let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
499
+ try finalData.write(to: tempFile)
500
+ do {
501
+ try CryptoCipherV1.decryptFile(filePath: tempFile, privateKey: self.privateKey, sessionKey: sessionKey, version: version)
502
+ } catch {
503
+ self.sendStats(action: "decrypt_fail", versionName: version)
504
+ throw error
505
+ }
414
506
  finalData = try Data(contentsOf: tempFile)
415
507
  try FileManager.default.removeItem(at: tempFile)
416
508
  }
417
509
 
418
- // Decompress the Brotli data
419
- guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
420
- throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
510
+ // Check if file has .br extension for Brotli decompression
511
+ let isBrotli = fileName.hasSuffix(".br")
512
+ let finalFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
513
+ let destFilePath = destFolder.appendingPathComponent(finalFileName)
514
+
515
+ if isBrotli {
516
+ // Decompress the Brotli data
517
+ guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
518
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
519
+ }
520
+ finalData = decompressedData
421
521
  }
422
- finalData = decompressedData
423
522
 
424
523
  try finalData.write(to: destFilePath)
425
- if !self.hasOldPrivateKeyPropertyInConfig && !self.publicKey.isEmpty && !sessionKey.isEmpty {
524
+ if !self.publicKey.isEmpty && !sessionKey.isEmpty {
426
525
  // assume that calcChecksum != null
427
526
  let calculatedChecksum = CryptoCipherV2.calcChecksum(filePath: destFilePath)
428
527
  if calculatedChecksum != fileHash {
@@ -435,13 +534,13 @@ import UIKit
435
534
 
436
535
  completedFiles += 1
437
536
  self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
438
- print("\(CapacitorUpdater.TAG) downloadManifest \(id) \(fileName) downloaded, decompressed\(!self.publicKey.isEmpty && !sessionKey.isEmpty ? ", decrypted" : ""), and cached")
537
+ self.logger.info("downloadManifest \(id) \(fileName) downloaded\(isBrotli ? ", decompressed" : "")\(!self.publicKey.isEmpty && !sessionKey.isEmpty ? ", decrypted" : ""), and cached")
439
538
  } catch {
440
539
  downloadError = error
441
- NSLog("\(CapacitorUpdater.TAG) downloadManifest \(id) \(fileName) error: \(error.localizedDescription)")
540
+ self.logger.error("downloadManifest \(id) \(fileName) error: \(error.localizedDescription)")
442
541
  }
443
542
  case .failure(let error):
444
- NSLog("\(CapacitorUpdater.TAG) downloadManifest \(id) \(fileName) download error: \(error.localizedDescription). Debug response: \(response.debugDescription).")
543
+ self.logger.error("downloadManifest \(id) \(fileName) download error: \(error.localizedDescription). Debug response: \(response.debugDescription).")
445
544
  }
446
545
  }
447
546
  }
@@ -460,7 +559,7 @@ import UIKit
460
559
  let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.localizedString)
461
560
  self.saveBundleInfo(id: id, bundle: updatedBundle)
462
561
 
463
- print("\(CapacitorUpdater.TAG) downloadManifest done \(id)")
562
+ logger.info("downloadManifest done \(id)")
464
563
  return updatedBundle
465
564
  }
466
565
 
@@ -501,7 +600,7 @@ import UIKit
501
600
  var status = compression_stream_init(streamPointer, COMPRESSION_STREAM_DECODE, COMPRESSION_BROTLI)
502
601
 
503
602
  guard status != COMPRESSION_STATUS_ERROR else {
504
- print("\(CapacitorUpdater.TAG) Error: Failed to initialize Brotli stream for \(fileName). Status: \(status)")
603
+ logger.error("Error: Failed to initialize Brotli stream for \(fileName). Status: \(status)")
505
604
  return nil
506
605
  }
507
606
 
@@ -523,7 +622,7 @@ import UIKit
523
622
  if let baseAddress = rawBufferPointer.baseAddress {
524
623
  streamPointer.pointee.src_ptr = baseAddress.assumingMemoryBound(to: UInt8.self)
525
624
  } else {
526
- print("\(CapacitorUpdater.TAG) Error: Failed to get base address for \(fileName)")
625
+ logger.error("Error: Failed to get base address for \(fileName)")
527
626
  status = COMPRESSION_STATUS_ERROR
528
627
  return
529
628
  }
@@ -533,7 +632,7 @@ import UIKit
533
632
  if status == COMPRESSION_STATUS_ERROR {
534
633
  let maxBytes = min(32, data.count)
535
634
  let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
536
- print("\(CapacitorUpdater.TAG) Error: Brotli decompression failed for \(fileName). First \(maxBytes) bytes: \(hexDump)")
635
+ logger.error("Error: Brotli decompression failed for \(fileName). First \(maxBytes) bytes: \(hexDump)")
537
636
  break
538
637
  }
539
638
 
@@ -547,18 +646,18 @@ import UIKit
547
646
  if status == COMPRESSION_STATUS_END {
548
647
  break
549
648
  } else if status == COMPRESSION_STATUS_ERROR {
550
- print("\(CapacitorUpdater.TAG) Error: Brotli process failed for \(fileName). Status: \(status)")
649
+ logger.error("Error: Brotli process failed for \(fileName). Status: \(status)")
551
650
  if let text = String(data: data, encoding: .utf8) {
552
651
  let asciiCount = text.unicodeScalars.filter { $0.isASCII }.count
553
652
  let totalCount = text.unicodeScalars.count
554
653
  if totalCount > 0 && Double(asciiCount) / Double(totalCount) >= 0.8 {
555
- print("\(CapacitorUpdater.TAG) Error: Input appears to be plain text: \(text)")
654
+ logger.error("Error: Input appears to be plain text: \(text)")
556
655
  }
557
656
  }
558
657
 
559
658
  let maxBytes = min(32, data.count)
560
659
  let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
561
- print("\(CapacitorUpdater.TAG) Error: Raw data (\(fileName)): \(hexDump)")
660
+ logger.error("Error: Raw data (\(fileName)): \(hexDump)")
562
661
 
563
662
  return nil
564
663
  }
@@ -569,7 +668,7 @@ import UIKit
569
668
  }
570
669
 
571
670
  if input.count == 0 {
572
- print("\(CapacitorUpdater.TAG) Error: Zero input size for \(fileName)")
671
+ logger.error("Error: Zero input size for \(fileName)")
573
672
  break
574
673
  }
575
674
  }
@@ -598,11 +697,13 @@ import UIKit
598
697
  let monitor = ClosureEventMonitor()
599
698
  monitor.requestDidCompleteTaskWithError = { (_, _, error) in
600
699
  if error != nil {
601
- print("\(CapacitorUpdater.TAG) Downloading failed - ClosureEventMonitor activated")
700
+ self.logger.error("Downloading failed - ClosureEventMonitor activated")
602
701
  mainError = error as NSError?
603
702
  }
604
703
  }
605
- let session = Session(eventMonitors: [monitor])
704
+ let configuration = URLSessionConfiguration.default
705
+ configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
706
+ let session = Session(configuration: configuration, eventMonitors: [monitor])
606
707
 
607
708
  let request = session.streamRequest(url, headers: requestHeaders).validate().onHTTPResponse(perform: { response in
608
709
  if let contentLength = response.headers.value(for: "Content-Length") {
@@ -629,11 +730,11 @@ import UIKit
629
730
  }
630
731
 
631
732
  } else {
632
- print("\(CapacitorUpdater.TAG) Download failed")
733
+ self.logger.error("Download failed")
633
734
  }
634
735
 
635
736
  case .complete:
636
- print("\(CapacitorUpdater.TAG) Download complete, total received bytes: \(totalReceivedBytes)")
737
+ self.logger.info("Download complete, total received bytes: \(totalReceivedBytes)")
637
738
  self.notifyDownload(id: id, percent: 70, ignoreMultipleOfTen: true)
638
739
  semaphore.signal()
639
740
  }
@@ -655,69 +756,68 @@ import UIKit
655
756
  reachabilityManager?.stopListening()
656
757
 
657
758
  if mainError != nil {
658
- print("\(CapacitorUpdater.TAG) Failed to download: \(String(describing: mainError))")
759
+ logger.error("Failed to download: \(String(describing: mainError))")
659
760
  self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
660
761
  throw mainError!
661
762
  }
662
763
 
663
764
  let finalPath = tempDataPath.deletingLastPathComponent().appendingPathComponent("\(id)")
664
765
  do {
665
- var checksumDecrypted = checksum
666
- do {
667
- if !self.hasOldPrivateKeyPropertyInConfig {
668
- try CryptoCipherV2.decryptFile(filePath: tempDataPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
669
- } else {
670
- try CryptoCipher.decryptFile(filePath: tempDataPath, privateKey: self.privateKey, sessionKey: sessionKey, version: version)
671
- }
672
- } catch {
673
- self.sendStats(action: "decrypt_fail", versionName: version)
674
- throw error
766
+ if !self.hasOldPrivateKeyPropertyInConfig {
767
+ // V2 Encryption (publicKey)
768
+ try CryptoCipherV2.decryptFile(filePath: tempDataPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
769
+ } else {
770
+ // V1 Encryption (privateKey) - deprecated but supported
771
+ try CryptoCipherV1.decryptFile(filePath: tempDataPath, privateKey: self.privateKey, sessionKey: sessionKey, version: version)
675
772
  }
676
773
  try FileManager.default.moveItem(at: tempDataPath, to: finalPath)
677
774
  } catch {
678
- print("\(CapacitorUpdater.TAG) Failed decrypt file : \(error)")
775
+ logger.error("Failed decrypt file : \(error)")
679
776
  self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
680
777
  cleanDownloadData()
681
778
  throw error
682
779
  }
683
780
 
684
781
  do {
685
- if !self.hasOldPrivateKeyPropertyInConfig && !sessionKey.isEmpty {
686
- checksum = CryptoCipherV2.calcChecksum(filePath: finalPath)
687
- } else {
688
- checksum = CryptoCipher.calcChecksum(filePath: finalPath)
689
- }
690
- print("\(CapacitorUpdater.TAG) Downloading: 80% (unzipping)")
782
+ checksum = CryptoCipherV2.calcChecksum(filePath: finalPath)
783
+ logger.info("Downloading: 80% (unzipping)")
691
784
  try self.saveDownloaded(sourceZip: finalPath, id: id, base: self.libraryDir.appendingPathComponent(self.bundleDirectory), notify: true)
692
785
 
693
786
  } catch {
694
- print("\(CapacitorUpdater.TAG) Failed to unzip file: \(error)")
787
+ logger.error("Failed to unzip file: \(error)")
695
788
  self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
789
+ // Best-effort cleanup of the decrypted zip file when unzip fails
790
+ do {
791
+ if FileManager.default.fileExists(atPath: finalPath.path) {
792
+ try FileManager.default.removeItem(at: finalPath)
793
+ }
794
+ } catch {
795
+ logger.error("Could not delete failed zip at \(finalPath.path): \(error)")
796
+ }
696
797
  cleanDownloadData()
697
- // todo: cleanup zip attempts
698
798
  throw error
699
799
  }
700
800
 
701
801
  self.notifyDownload(id: id, percent: 90)
702
- print("\(CapacitorUpdater.TAG) Downloading: 90% (wrapping up)")
802
+ logger.info("Downloading: 90% (wrapping up)")
703
803
  let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum)
704
804
  self.saveBundleInfo(id: id, bundle: info)
705
805
  self.cleanDownloadData()
706
806
  self.notifyDownload(id: id, percent: 100)
707
- print("\(CapacitorUpdater.TAG) Downloading: 100% (complete)")
807
+ logger.info("Downloading: 100% (complete)")
708
808
  return info
709
809
  }
710
810
  private func ensureResumableFilesExist() {
711
811
  let fileManager = FileManager.default
712
812
  if !fileManager.fileExists(atPath: tempDataPath.path) {
713
813
  if !fileManager.createFile(atPath: tempDataPath.path, contents: Data()) {
714
- print("\(CapacitorUpdater.TAG) Cannot ensure that a file at \(tempDataPath.path) exists")
814
+ logger.error("Cannot ensure that a file at \(tempDataPath.path) exists")
715
815
  }
716
816
  }
717
817
 
718
818
  if !fileManager.fileExists(atPath: updateInfo.path) {
719
819
  if !fileManager.createFile(atPath: updateInfo.path, contents: Data()) {
720
- print("\(CapacitorUpdater.TAG) Cannot ensure that a file at \(updateInfo.path) exists")
820
+ logger.error("Cannot ensure that a file at \(updateInfo.path) exists")
721
821
  }
722
822
  }
723
823
  }
@@ -729,7 +829,7 @@ import UIKit
729
829
  do {
730
830
  try fileManager.removeItem(at: tempDataPath)
731
831
  } catch {
732
- print("\(CapacitorUpdater.TAG) Could not delete file at \(tempDataPath): \(error)")
832
+ logger.error("Could not delete file at \(tempDataPath): \(error)")
733
833
  }
734
834
  }
735
835
  // Deleting update.dat
@@ -737,7 +837,7 @@ import UIKit
737
837
  do {
738
838
  try fileManager.removeItem(at: updateInfo)
739
839
  } catch {
740
- print("\(CapacitorUpdater.TAG) Could not delete file at \(updateInfo): \(error)")
840
+ logger.error("Could not delete file at \(updateInfo): \(error)")
741
841
  }
742
842
  }
743
843
  }
@@ -756,7 +856,7 @@ import UIKit
756
856
  fileHandle.closeFile()
757
857
  }
758
858
  } catch {
759
- print("Failed to write data starting at byte \(byteOffset): \(error)")
859
+ logger.error("Failed to write data starting at byte \(byteOffset): \(error)")
760
860
  }
761
861
  self.tempData.removeAll() // Clearing tempData to avoid writing the same data multiple times
762
862
  }
@@ -765,7 +865,7 @@ import UIKit
765
865
  do {
766
866
  try "\(version)".write(to: updateInfo, atomically: true, encoding: .utf8)
767
867
  } catch {
768
- print("\(CapacitorUpdater.TAG) Failed to save progress: \(error)")
868
+ logger.error("Failed to save progress: \(error)")
769
869
  }
770
870
  }
771
871
  private func getLocalUpdateVersion() -> String { // Return the version that was tried to be downloaded on last download attempt
@@ -787,7 +887,7 @@ import UIKit
787
887
  return fileSize.int64Value
788
888
  }
789
889
  } catch {
790
- print("\(CapacitorUpdater.TAG) Could not retrieve already downloaded data size : \(error)")
890
+ logger.error("Could not retrieve already downloaded data size : \(error)")
791
891
  }
792
892
  return 0
793
893
  }
@@ -799,7 +899,7 @@ import UIKit
799
899
  do {
800
900
  let files: [String] = try FileManager.default.contentsOfDirectory(atPath: dest.path)
801
901
  var res: [BundleInfo] = []
802
- print("\(CapacitorUpdater.TAG) list File : \(dest.path)")
902
+ logger.info("list File : \(dest.path)")
803
903
  if dest.exist {
804
904
  for id: String in files {
805
905
  res.append(self.getBundleInfo(id: id))
@@ -807,12 +907,12 @@ import UIKit
807
907
  }
808
908
  return res
809
909
  } catch {
810
- print("\(CapacitorUpdater.TAG) No version available \(dest.path)")
910
+ logger.info("No version available \(dest.path)")
811
911
  return []
812
912
  }
813
913
  } else {
814
914
  guard let regex = try? NSRegularExpression(pattern: "^[0-9A-Za-z]{10}_info$") else {
815
- print("\(CapacitorUpdater.TAG) Invald regex ?????")
915
+ logger.error("Invalid regex ?????")
816
916
  return []
817
917
  }
818
918
  return UserDefaults.standard.dictionaryRepresentation().keys.filter {
@@ -831,7 +931,7 @@ import UIKit
831
931
  public func delete(id: String, removeInfo: Bool) -> Bool {
832
932
  let deleted: BundleInfo = self.getBundleInfo(id: id)
833
933
  if deleted.isBuiltin() || self.getCurrentBundleId() == id {
834
- print("\(CapacitorUpdater.TAG) Cannot delete \(id)")
934
+ logger.info("Cannot delete \(id)")
835
935
  return false
836
936
  }
837
937
 
@@ -840,7 +940,7 @@ import UIKit
840
940
  !next.isDeleted() &&
841
941
  !next.isErrorStatus() &&
842
942
  next.getId() == id {
843
- print("\(CapacitorUpdater.TAG) Cannot delete the next bundle \(id)")
943
+ logger.info("Cannot delete the next bundle \(id)")
844
944
  return false
845
945
  }
846
946
 
@@ -848,7 +948,7 @@ import UIKit
848
948
  do {
849
949
  try FileManager.default.removeItem(atPath: destPersist.path)
850
950
  } catch {
851
- print("\(CapacitorUpdater.TAG) Folder \(destPersist.path), not removed.")
951
+ logger.error("Folder \(destPersist.path), not removed.")
852
952
  // even if, we don;t care. Android doesn't care
853
953
  if removeInfo {
854
954
  self.removeBundleInfo(id: id)
@@ -861,7 +961,7 @@ import UIKit
861
961
  } else {
862
962
  self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.localizedString))
863
963
  }
864
- print("\(CapacitorUpdater.TAG) bundle delete \(deleted.getVersionName())")
964
+ logger.info("bundle delete \(deleted.getVersionName())")
865
965
  self.sendStats(action: "delete", versionName: deleted.getVersionName())
866
966
  return true
867
967
  }
@@ -870,6 +970,42 @@ import UIKit
870
970
  return self.delete(id: id, removeInfo: true)
871
971
  }
872
972
 
973
+ public func cleanupDownloadDirectories(allowedIds: Set<String>) {
974
+ let bundleRoot = libraryDir.appendingPathComponent(bundleDirectory)
975
+ let fileManager = FileManager.default
976
+
977
+ guard fileManager.fileExists(atPath: bundleRoot.path) else {
978
+ return
979
+ }
980
+
981
+ do {
982
+ let contents = try fileManager.contentsOfDirectory(at: bundleRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
983
+
984
+ for url in contents {
985
+ let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
986
+ if resourceValues.isDirectory != true {
987
+ continue
988
+ }
989
+
990
+ let id = url.lastPathComponent
991
+
992
+ if allowedIds.contains(id) {
993
+ continue
994
+ }
995
+
996
+ do {
997
+ try fileManager.removeItem(at: url)
998
+ self.removeBundleInfo(id: id)
999
+ logger.info("Deleted orphan bundle directory: \(id)")
1000
+ } catch {
1001
+ logger.error("Failed to delete orphan bundle directory: \(id) \(error.localizedDescription)")
1002
+ }
1003
+ }
1004
+ } catch {
1005
+ logger.error("Failed to enumerate bundle directory for cleanup: \(error.localizedDescription)")
1006
+ }
1007
+ }
1008
+
873
1009
  public func getBundleDirectory(id: String) -> URL {
874
1010
  return libraryDir.appendingPathComponent(self.bundleDirectory).appendingPathComponent(id)
875
1011
  }
@@ -914,7 +1050,7 @@ import UIKit
914
1050
  public func autoReset() {
915
1051
  let currentBundle: BundleInfo = self.getCurrentBundle()
916
1052
  if !currentBundle.isBuiltin() && !self.bundleExists(id: currentBundle.getId()) {
917
- print("\(CapacitorUpdater.TAG) Folder at bundle path does not exist. Triggering reset.")
1053
+ logger.info("Folder at bundle path does not exist. Triggering reset.")
918
1054
  self.reset()
919
1055
  }
920
1056
  }
@@ -924,7 +1060,7 @@ import UIKit
924
1060
  }
925
1061
 
926
1062
  public func reset(isInternal: Bool) {
927
- print("\(CapacitorUpdater.TAG) reset: \(isInternal)")
1063
+ logger.info("reset: \(isInternal)")
928
1064
  let currentBundleName = self.getCurrentBundle().getVersionName()
929
1065
  self.setCurrentBundle(bundle: "")
930
1066
  self.setFallbackBundle(fallback: Optional<BundleInfo>.none)
@@ -937,14 +1073,14 @@ import UIKit
937
1073
  public func setSuccess(bundle: BundleInfo, autoDeletePrevious: Bool) {
938
1074
  self.setBundleStatus(id: bundle.getId(), status: BundleStatus.SUCCESS)
939
1075
  let fallback: BundleInfo = self.getFallbackBundle()
940
- print("\(CapacitorUpdater.TAG) Fallback bundle is: \(fallback.toString())")
941
- print("\(CapacitorUpdater.TAG) Version successfully loaded: \(bundle.toString())")
1076
+ logger.info("Fallback bundle is: \(fallback.toString())")
1077
+ logger.info("Version successfully loaded: \(bundle.toString())")
942
1078
  if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() {
943
1079
  let res = self.delete(id: fallback.getId())
944
1080
  if res {
945
- print("\(CapacitorUpdater.TAG) Deleted previous bundle: \(fallback.toString())")
1081
+ logger.info("Deleted previous bundle: \(fallback.toString())")
946
1082
  } else {
947
- print("\(CapacitorUpdater.TAG) Failed to delete previous bundle: \(fallback.toString())")
1083
+ logger.error("Failed to delete previous bundle: \(fallback.toString())")
948
1084
  }
949
1085
  }
950
1086
  self.setFallbackBundle(fallback: bundle)
@@ -956,8 +1092,17 @@ import UIKit
956
1092
 
957
1093
  func unsetChannel() -> SetChannel {
958
1094
  let setChannel: SetChannel = SetChannel()
1095
+
1096
+ // Check if rate limit was exceeded
1097
+ if CapgoUpdater.rateLimitExceeded {
1098
+ logger.debug("Skipping unsetChannel due to rate limit (429). Requests will resume after app restart.")
1099
+ setChannel.message = "Rate limit exceeded"
1100
+ setChannel.error = "rate_limit_exceeded"
1101
+ return setChannel
1102
+ }
1103
+
959
1104
  if (self.channelUrl ).isEmpty {
960
- print("\(CapacitorUpdater.TAG) Channel URL is not set")
1105
+ logger.error("Channel URL is not set")
961
1106
  setChannel.message = "Channel URL is not set"
962
1107
  setChannel.error = "missing_config"
963
1108
  return setChannel
@@ -965,24 +1110,30 @@ import UIKit
965
1110
  let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
966
1111
  let parameters: InfoObject = self.createInfoObject()
967
1112
 
968
- let request = AF.request(self.channelUrl, method: .delete, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1113
+ let request = alamofireSession.request(self.channelUrl, method: .delete, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
969
1114
 
970
1115
  request.validate().responseDecodable(of: SetChannelDec.self) { response in
1116
+ // Check for 429 rate limit
1117
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1118
+ setChannel.message = "Rate limit exceeded"
1119
+ setChannel.error = "rate_limit_exceeded"
1120
+ semaphore.signal()
1121
+ return
1122
+ }
1123
+
971
1124
  switch response.result {
972
1125
  case .success:
973
- if let status = response.value?.status {
974
- setChannel.status = status
975
- }
976
- if let error = response.value?.error {
977
- setChannel.error = error
978
- }
979
- if let message = response.value?.message {
980
- setChannel.message = message
1126
+ if let responseValue = response.value {
1127
+ if let error = responseValue.error {
1128
+ setChannel.error = error
1129
+ } else {
1130
+ setChannel.status = responseValue.status ?? ""
1131
+ setChannel.message = responseValue.message ?? ""
1132
+ }
981
1133
  }
982
1134
  case let .failure(error):
983
- print("\(CapacitorUpdater.TAG) Error unset Channel", response.value ?? "", error)
984
- setChannel.message = "Error unset Channel \(String(describing: response.value))"
985
- setChannel.error = "response_error"
1135
+ self.logger.error("Error unset Channel \(error)")
1136
+ setChannel.error = "Request failed: \(error.localizedDescription)"
986
1137
  }
987
1138
  semaphore.signal()
988
1139
  }
@@ -992,8 +1143,17 @@ import UIKit
992
1143
 
993
1144
  func setChannel(channel: String) -> SetChannel {
994
1145
  let setChannel: SetChannel = SetChannel()
1146
+
1147
+ // Check if rate limit was exceeded
1148
+ if CapgoUpdater.rateLimitExceeded {
1149
+ logger.debug("Skipping setChannel due to rate limit (429). Requests will resume after app restart.")
1150
+ setChannel.message = "Rate limit exceeded"
1151
+ setChannel.error = "rate_limit_exceeded"
1152
+ return setChannel
1153
+ }
1154
+
995
1155
  if (self.channelUrl ).isEmpty {
996
- print("\(CapacitorUpdater.TAG) Channel URL is not set")
1156
+ logger.error("Channel URL is not set")
997
1157
  setChannel.message = "Channel URL is not set"
998
1158
  setChannel.error = "missing_config"
999
1159
  return setChannel
@@ -1002,24 +1162,30 @@ import UIKit
1002
1162
  var parameters: InfoObject = self.createInfoObject()
1003
1163
  parameters.channel = channel
1004
1164
 
1005
- let request = AF.request(self.channelUrl, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1165
+ let request = alamofireSession.request(self.channelUrl, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1006
1166
 
1007
1167
  request.validate().responseDecodable(of: SetChannelDec.self) { response in
1168
+ // Check for 429 rate limit
1169
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1170
+ setChannel.message = "Rate limit exceeded"
1171
+ setChannel.error = "rate_limit_exceeded"
1172
+ semaphore.signal()
1173
+ return
1174
+ }
1175
+
1008
1176
  switch response.result {
1009
1177
  case .success:
1010
- if let status = response.value?.status {
1011
- setChannel.status = status
1012
- }
1013
- if let error = response.value?.error {
1014
- setChannel.error = error
1015
- }
1016
- if let message = response.value?.message {
1017
- setChannel.message = message
1178
+ if let responseValue = response.value {
1179
+ if let error = responseValue.error {
1180
+ setChannel.error = error
1181
+ } else {
1182
+ setChannel.status = responseValue.status ?? ""
1183
+ setChannel.message = responseValue.message ?? ""
1184
+ }
1018
1185
  }
1019
1186
  case let .failure(error):
1020
- print("\(CapacitorUpdater.TAG) Error set Channel", response.value ?? "", error)
1021
- setChannel.message = "Error set Channel \(String(describing: response.value))"
1022
- setChannel.error = "response_error"
1187
+ self.logger.error("Error set Channel \(error)")
1188
+ setChannel.error = "Request failed: \(error.localizedDescription)"
1023
1189
  }
1024
1190
  semaphore.signal()
1025
1191
  }
@@ -1029,36 +1195,48 @@ import UIKit
1029
1195
 
1030
1196
  func getChannel() -> GetChannel {
1031
1197
  let getChannel: GetChannel = GetChannel()
1198
+
1199
+ // Check if rate limit was exceeded
1200
+ if CapgoUpdater.rateLimitExceeded {
1201
+ logger.debug("Skipping getChannel due to rate limit (429). Requests will resume after app restart.")
1202
+ getChannel.message = "Rate limit exceeded"
1203
+ getChannel.error = "rate_limit_exceeded"
1204
+ return getChannel
1205
+ }
1206
+
1032
1207
  if (self.channelUrl ).isEmpty {
1033
- print("\(CapacitorUpdater.TAG) Channel URL is not set")
1208
+ logger.error("Channel URL is not set")
1034
1209
  getChannel.message = "Channel URL is not set"
1035
1210
  getChannel.error = "missing_config"
1036
1211
  return getChannel
1037
1212
  }
1038
1213
  let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
1039
1214
  let parameters: InfoObject = self.createInfoObject()
1040
- let request = AF.request(self.channelUrl, method: .put, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1215
+ let request = alamofireSession.request(self.channelUrl, method: .put, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1041
1216
 
1042
1217
  request.validate().responseDecodable(of: GetChannelDec.self) { response in
1043
1218
  defer {
1044
1219
  semaphore.signal()
1045
1220
  }
1221
+
1222
+ // Check for 429 rate limit
1223
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1224
+ getChannel.message = "Rate limit exceeded"
1225
+ getChannel.error = "rate_limit_exceeded"
1226
+ return
1227
+ }
1228
+
1046
1229
  switch response.result {
1047
1230
  case .success:
1048
- if let status = response.value?.status {
1049
- getChannel.status = status
1050
- }
1051
- if let error = response.value?.error {
1052
- getChannel.error = error
1053
- }
1054
- if let message = response.value?.message {
1055
- getChannel.message = message
1056
- }
1057
- if let channel = response.value?.channel {
1058
- getChannel.channel = channel
1059
- }
1060
- if let allowSet = response.value?.allowSet {
1061
- getChannel.allowSet = allowSet
1231
+ if let responseValue = response.value {
1232
+ if let error = responseValue.error {
1233
+ getChannel.error = error
1234
+ } else {
1235
+ getChannel.status = responseValue.status ?? ""
1236
+ getChannel.message = responseValue.message ?? ""
1237
+ getChannel.channel = responseValue.channel ?? ""
1238
+ getChannel.allowSet = responseValue.allowSet ?? true
1239
+ }
1062
1240
  }
1063
1241
  case let .failure(error):
1064
1242
  if let data = response.data, let bodyString = String(data: data, encoding: .utf8) {
@@ -1069,18 +1247,105 @@ import UIKit
1069
1247
  }
1070
1248
  }
1071
1249
 
1072
- print("\(CapacitorUpdater.TAG) Error get Channel", response.value ?? "", error)
1073
- getChannel.message = "Error get Channel \(String(describing: response.value)))"
1074
- getChannel.error = "response_error"
1250
+ self.logger.error("Error get Channel \(error)")
1251
+ getChannel.error = "Request failed: \(error.localizedDescription)"
1075
1252
  }
1076
1253
  }
1077
1254
  semaphore.wait()
1078
1255
  return getChannel
1079
1256
  }
1080
1257
 
1258
+ func listChannels() -> ListChannels {
1259
+ let listChannels: ListChannels = ListChannels()
1260
+
1261
+ // Check if rate limit was exceeded
1262
+ if CapgoUpdater.rateLimitExceeded {
1263
+ logger.debug("Skipping listChannels due to rate limit (429). Requests will resume after app restart.")
1264
+ listChannels.error = "rate_limit_exceeded"
1265
+ return listChannels
1266
+ }
1267
+
1268
+ if (self.channelUrl).isEmpty {
1269
+ logger.error("Channel URL is not set")
1270
+ listChannels.error = "Channel URL is not set"
1271
+ return listChannels
1272
+ }
1273
+
1274
+ let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
1275
+
1276
+ // Auto-detect values
1277
+ let appId = self.appId
1278
+ let platform = "ios"
1279
+ let isEmulator = self.isEmulator()
1280
+ let isProd = self.isProd()
1281
+
1282
+ // Create query parameters
1283
+ var urlComponents = URLComponents(string: self.channelUrl)
1284
+ urlComponents?.queryItems = [
1285
+ URLQueryItem(name: "app_id", value: appId),
1286
+ URLQueryItem(name: "platform", value: platform),
1287
+ URLQueryItem(name: "is_emulator", value: String(isEmulator)),
1288
+ URLQueryItem(name: "is_prod", value: String(isProd))
1289
+ ]
1290
+
1291
+ guard let url = urlComponents?.url else {
1292
+ logger.error("Invalid channel URL")
1293
+ listChannels.error = "Invalid channel URL"
1294
+ return listChannels
1295
+ }
1296
+
1297
+ let request = alamofireSession.request(url, method: .get, requestModifier: { $0.timeoutInterval = self.timeout })
1298
+
1299
+ request.validate().responseDecodable(of: ListChannelsDec.self) { response in
1300
+ defer {
1301
+ semaphore.signal()
1302
+ }
1303
+
1304
+ // Check for 429 rate limit
1305
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1306
+ listChannels.error = "rate_limit_exceeded"
1307
+ return
1308
+ }
1309
+
1310
+ switch response.result {
1311
+ case .success:
1312
+ if let responseValue = response.value {
1313
+ // Check for server-side errors
1314
+ if let error = responseValue.error {
1315
+ listChannels.error = error
1316
+ return
1317
+ }
1318
+
1319
+ // Backend returns direct array, so channels should be populated by our custom decoder
1320
+ if let channels = responseValue.channels {
1321
+ listChannels.channels = channels.map { channel in
1322
+ var channelDict: [String: Any] = [:]
1323
+ channelDict["id"] = channel.id ?? ""
1324
+ channelDict["name"] = channel.name ?? ""
1325
+ channelDict["public"] = channel.public ?? false
1326
+ channelDict["allow_self_set"] = channel.allow_self_set ?? false
1327
+ return channelDict
1328
+ }
1329
+ }
1330
+ }
1331
+ case let .failure(error):
1332
+ self.logger.error("Error list channels \(error)")
1333
+ listChannels.error = "Request failed: \(error.localizedDescription)"
1334
+ }
1335
+ }
1336
+ semaphore.wait()
1337
+ return listChannels
1338
+ }
1339
+
1081
1340
  private let operationQueue = OperationQueue()
1082
1341
 
1083
1342
  func sendStats(action: String, versionName: String? = nil, oldVersionName: String? = "") {
1343
+ // Check if rate limit was exceeded
1344
+ if CapgoUpdater.rateLimitExceeded {
1345
+ logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.")
1346
+ return
1347
+ }
1348
+
1084
1349
  guard !statsUrl.isEmpty else {
1085
1350
  return
1086
1351
  }
@@ -1095,22 +1360,28 @@ import UIKit
1095
1360
 
1096
1361
  let operation = BlockOperation {
1097
1362
  let semaphore = DispatchSemaphore(value: 0)
1098
- AF.request(
1363
+ self.alamofireSession.request(
1099
1364
  self.statsUrl,
1100
1365
  method: .post,
1101
1366
  parameters: parameters,
1102
1367
  encoder: JSONParameterEncoder.default,
1103
1368
  requestModifier: { $0.timeoutInterval = self.timeout }
1104
1369
  ).responseData { response in
1370
+ // Check for 429 rate limit
1371
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1372
+ semaphore.signal()
1373
+ return
1374
+ }
1375
+
1105
1376
  switch response.result {
1106
1377
  case .success:
1107
- print("\(CapacitorUpdater.TAG) Stats sent for \(action), version \(versionName)")
1378
+ self.logger.info("Stats sent for \(action), version \(versionName)")
1108
1379
  case let .failure(error):
1109
- print("\(CapacitorUpdater.TAG) Error sending stats: ", response.value ?? "", error.localizedDescription)
1380
+ self.logger.error("Error sending stats: \(response.value?.debugDescription ?? "") \(error.localizedDescription)")
1110
1381
  }
1111
1382
  semaphore.signal()
1112
1383
  }
1113
- semaphore.signal()
1384
+ semaphore.wait()
1114
1385
  }
1115
1386
  operationQueue.addOperation(operation)
1116
1387
 
@@ -1121,7 +1392,6 @@ import UIKit
1121
1392
  if id != nil {
1122
1393
  trueId = id!
1123
1394
  }
1124
- // print("\(CapacitorUpdater.TAG) Getting info for bundle [\(trueId)]")
1125
1395
  let result: BundleInfo
1126
1396
  if BundleInfo.ID_BUILTIN == trueId {
1127
1397
  result = BundleInfo(id: trueId, version: "", status: BundleStatus.SUCCESS, checksum: "")
@@ -1131,11 +1401,10 @@ import UIKit
1131
1401
  do {
1132
1402
  result = try UserDefaults.standard.getObj(forKey: "\(trueId)\(self.INFO_SUFFIX)", castTo: BundleInfo.self)
1133
1403
  } catch {
1134
- print("\(CapacitorUpdater.TAG) Failed to parse info for bundle [\(trueId)]", error.localizedDescription)
1404
+ logger.error("Failed to parse info for bundle [\(trueId)] \(error.localizedDescription)")
1135
1405
  result = BundleInfo(id: trueId, version: "", status: BundleStatus.PENDING, checksum: "")
1136
1406
  }
1137
1407
  }
1138
- // print("\(CapacitorUpdater.TAG) Returning info bundle [\(result.toString())]")
1139
1408
  return result
1140
1409
  }
1141
1410
 
@@ -1155,26 +1424,26 @@ import UIKit
1155
1424
 
1156
1425
  public func saveBundleInfo(id: String, bundle: BundleInfo?) {
1157
1426
  if bundle != nil && (bundle!.isBuiltin() || bundle!.isUnknown()) {
1158
- print("\(CapacitorUpdater.TAG) Not saving info for bundle [\(id)]", bundle?.toString() ?? "")
1427
+ logger.info("Not saving info for bundle [\(id)] \(bundle?.toString() ?? "")")
1159
1428
  return
1160
1429
  }
1161
1430
  if bundle == nil {
1162
- print("\(CapacitorUpdater.TAG) Removing info for bundle [\(id)]")
1431
+ logger.info("Removing info for bundle [\(id)]")
1163
1432
  UserDefaults.standard.removeObject(forKey: "\(id)\(self.INFO_SUFFIX)")
1164
1433
  } else {
1165
1434
  let update = bundle!.setId(id: id)
1166
- print("\(CapacitorUpdater.TAG) Storing info for bundle [\(id)]", update.toString())
1435
+ logger.info("Storing info for bundle [\(id)] \(update.toString())")
1167
1436
  do {
1168
1437
  try UserDefaults.standard.setObj(update, forKey: "\(id)\(self.INFO_SUFFIX)")
1169
1438
  } catch {
1170
- print("\(CapacitorUpdater.TAG) Failed to save info for bundle [\(id)]", error.localizedDescription)
1439
+ logger.error("Failed to save info for bundle [\(id)] \(error.localizedDescription)")
1171
1440
  }
1172
1441
  }
1173
1442
  UserDefaults.standard.synchronize()
1174
1443
  }
1175
1444
 
1176
1445
  private func setBundleStatus(id: String, status: BundleStatus) {
1177
- print("\(CapacitorUpdater.TAG) Setting status for bundle [\(id)] to \(status)")
1446
+ logger.info("Setting status for bundle [\(id)] to \(status)")
1178
1447
  let info = self.getBundleInfo(id: id)
1179
1448
  self.saveBundleInfo(id: id, bundle: info.setStatus(status: status.localizedString))
1180
1449
  }