@capgo/capacitor-updater 6.14.25 → 6.14.29

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 (54) hide show
  1. package/CapgoCapacitorUpdater.podspec +3 -2
  2. package/Package.swift +2 -2
  3. package/README.md +341 -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 +1196 -514
  9. package/android/src/main/java/ee/forgr/capacitor_updater/{CapacitorUpdater.java → CapgoUpdater.java} +522 -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/DownloadService.java +300 -119
  15. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +63 -25
  16. package/android/src/main/java/ee/forgr/capacitor_updater/Logger.java +338 -0
  17. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +72 -0
  18. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +169 -0
  19. package/dist/docs.json +652 -63
  20. package/dist/esm/definitions.d.ts +265 -15
  21. package/dist/esm/definitions.js.map +1 -1
  22. package/dist/esm/history.d.ts +1 -0
  23. package/dist/esm/history.js +283 -0
  24. package/dist/esm/history.js.map +1 -0
  25. package/dist/esm/index.d.ts +1 -0
  26. package/dist/esm/index.js +1 -0
  27. package/dist/esm/index.js.map +1 -1
  28. package/dist/esm/web.d.ts +12 -1
  29. package/dist/esm/web.js +29 -2
  30. package/dist/esm/web.js.map +1 -1
  31. package/dist/plugin.cjs.js +311 -2
  32. package/dist/plugin.cjs.js.map +1 -1
  33. package/dist/plugin.js +311 -2
  34. package/dist/plugin.js.map +1 -1
  35. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/AES.swift +6 -3
  36. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1575 -0
  37. package/ios/{Plugin/CapacitorUpdater.swift → Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift} +365 -139
  38. package/ios/{Plugin/CryptoCipher.swift → Sources/CapacitorUpdaterPlugin/CryptoCipherV1.swift} +13 -6
  39. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/CryptoCipherV2.swift +33 -27
  40. package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +220 -0
  41. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/InternalUtils.swift +47 -0
  42. package/ios/Sources/CapacitorUpdaterPlugin/Logger.swift +310 -0
  43. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/RSA.swift +1 -0
  44. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +112 -0
  45. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/UserDefaultsExtension.swift +0 -2
  46. package/package.json +20 -16
  47. package/ios/Plugin/CapacitorUpdaterPlugin.swift +0 -1031
  48. /package/{LICENCE → LICENSE} +0 -0
  49. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BigInt.swift +0 -0
  50. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleInfo.swift +0 -0
  51. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleStatus.swift +0 -0
  52. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayCondition.swift +0 -0
  53. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayUntilNext.swift +0 -0
  54. /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,31 @@ 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
+ private var userAgent: String {
51
+ let safePluginVersion = PLUGIN_VERSION.isEmpty ? "unknown" : PLUGIN_VERSION
52
+ let safeAppId = appId.isEmpty ? "unknown" : appId
53
+ return "CapacitorUpdater/\(safePluginVersion) (\(safeAppId)) ios/\(versionOs)"
54
+ }
55
+
56
+ private lazy var alamofireSession: Session = {
57
+ let configuration = URLSessionConfiguration.default
58
+ configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
59
+ return Session(configuration: configuration)
60
+ }()
61
+
47
62
  public var notifyDownloadRaw: (String, Int, Bool) -> Void = { _, _, _ in }
48
63
  public func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false) {
49
64
  notifyDownloadRaw(id, percent, ignoreMultipleOfTen)
50
65
  }
51
66
  public var notifyDownload: (String, Int) -> Void = { _, _ in }
52
67
 
68
+ public func setLogger(_ logger: Logger) {
69
+ self.logger = logger
70
+ }
71
+
53
72
  private func calcTotalPercent(percent: Int, min: Int, max: Int) -> Int {
54
73
  return (percent * (max - min)) / 100 + min
55
74
  }
@@ -71,6 +90,18 @@ import UIKit
71
90
  return !self.isDevEnvironment && !self.isAppStoreReceiptSandbox() && !self.hasEmbeddedMobileProvision()
72
91
  }
73
92
 
93
+ /**
94
+ * Check if a 429 (Too Many Requests) response was received and set the flag
95
+ */
96
+ private func checkAndHandleRateLimitResponse(statusCode: Int?) -> Bool {
97
+ if statusCode == 429 {
98
+ CapgoUpdater.rateLimitExceeded = true
99
+ logger.warn("Rate limit exceeded (429). Stopping all stats and channel requests until app restart.")
100
+ return true
101
+ }
102
+ return false
103
+ }
104
+
74
105
  // MARK: Private
75
106
  private func hasEmbeddedMobileProvision() -> Bool {
76
107
  guard Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") == nil else {
@@ -110,7 +141,7 @@ import UIKit
110
141
  do {
111
142
  try FileManager.default.createDirectory(atPath: source.path, withIntermediateDirectories: true, attributes: nil)
112
143
  } catch {
113
- print("\(CapacitorUpdater.TAG) Cannot createDirectory \(source.path)")
144
+ logger.error("Cannot createDirectory \(source.path)")
114
145
  throw CustomError.cannotCreateDirectory
115
146
  }
116
147
  }
@@ -120,7 +151,7 @@ import UIKit
120
151
  do {
121
152
  try FileManager.default.removeItem(atPath: source.path)
122
153
  } catch {
123
- print("\(CapacitorUpdater.TAG) File not removed. \(source.path)")
154
+ logger.error("File not removed. \(source.path)")
124
155
  throw CustomError.cannotDeleteDirectory
125
156
  }
126
157
  }
@@ -137,14 +168,14 @@ import UIKit
137
168
  return false
138
169
  }
139
170
  } catch {
140
- print("\(CapacitorUpdater.TAG) File not moved. source: \(source.path) dest: \(dest.path)")
171
+ logger.error("File not moved. source: \(source.path) dest: \(dest.path)")
141
172
  throw CustomError.cannotUnflat
142
173
  }
143
174
  }
144
175
 
145
176
  private func unzipProgressHandler(entry: String, zipInfo: unz_file_info, entryNumber: Int, total: Int, destUnZip: URL, id: String, unzipError: inout NSError?) {
146
177
  if entry.contains("\\") {
147
- print("\(CapacitorUpdater.TAG) unzip: Windows path is not supported, please use unix path as required by zip RFC: \(entry)")
178
+ logger.error("unzip: Windows path is not supported, please use unix path as required by zip RFC: \(entry)")
148
179
  self.sendStats(action: "windows_path_fail")
149
180
  }
150
181
 
@@ -227,7 +258,7 @@ import UIKit
227
258
  try FileManager.default.removeItem(at: sourceZip)
228
259
  }
229
260
  } catch {
230
- print("\(CapacitorUpdater.TAG) Could not delete source zip at \(sourceZip.path): \(error)")
261
+ logger.error("Could not delete source zip at \(sourceZip.path): \(error)")
231
262
  }
232
263
  }
233
264
 
@@ -257,8 +288,8 @@ import UIKit
257
288
  if let channel = channel {
258
289
  parameters.defaultChannel = channel
259
290
  }
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 })
291
+ logger.info("Auto-update parameters: \(parameters)")
292
+ let request = alamofireSession.request(url, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
262
293
 
263
294
  request.validate().responseDecodable(of: AppVersionDec.self) { response in
264
295
  switch response.result {
@@ -275,6 +306,9 @@ import UIKit
275
306
  if let major = response.value?.major {
276
307
  latest.major = major
277
308
  }
309
+ if let breaking = response.value?.breaking {
310
+ latest.breaking = breaking
311
+ }
278
312
  if let error = response.value?.error {
279
313
  latest.error = error
280
314
  }
@@ -291,7 +325,7 @@ import UIKit
291
325
  latest.manifest = manifest
292
326
  }
293
327
  case let .failure(error):
294
- print("\(CapacitorUpdater.TAG) Error getting Latest", response.value ?? "", error )
328
+ self.logger.error("Error getting Latest \(response.value.debugDescription) \(error)")
295
329
  latest.message = "Error getting Latest \(String(describing: response.value))"
296
330
  latest.error = "response_error"
297
331
  }
@@ -304,7 +338,7 @@ import UIKit
304
338
  private func setCurrentBundle(bundle: String) {
305
339
  UserDefaults.standard.set(bundle, forKey: self.CAP_SERVER_PATH)
306
340
  UserDefaults.standard.synchronize()
307
- print("\(CapacitorUpdater.TAG) Current bundle set to: \((bundle ).isEmpty ? BundleInfo.ID_BUILTIN : bundle)")
341
+ logger.info("Current bundle set to: \((bundle ).isEmpty ? BundleInfo.ID_BUILTIN : bundle)")
308
342
  }
309
343
 
310
344
  private var tempDataPath: URL {
@@ -323,7 +357,7 @@ import UIKit
323
357
 
324
358
  public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String) throws -> BundleInfo {
325
359
  let id = self.randomString(length: 10)
326
- print("\(CapacitorUpdater.TAG) downloadManifest start \(id)")
360
+ logger.info("downloadManifest start \(id)")
327
361
  let destFolder = self.getBundleDirectory(id: id)
328
362
  let builtinFolder = Bundle.main.bundleURL.appendingPathComponent("public")
329
363
 
@@ -351,12 +385,16 @@ import UIKit
351
385
  }
352
386
 
353
387
  if !self.hasOldPrivateKeyPropertyInConfig && !self.publicKey.isEmpty && !sessionKey.isEmpty {
388
+ // V2 Encryption (publicKey)
354
389
  do {
355
- fileHash = try CryptoCipherV2.decryptChecksum(checksum: fileHash, publicKey: self.publicKey, version: version)
390
+ fileHash = try CryptoCipherV2.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
356
391
  } catch {
357
392
  downloadError = error
358
- print("\(CapacitorUpdater.TAG) CryptoCipherV2.decryptChecksum error \(id) \(fileName) error: \(error)")
393
+ logger.error("CryptoCipherV2.decryptChecksum error \(id) \(fileName) error: \(error)")
359
394
  }
395
+ } else if self.hasOldPrivateKeyPropertyInConfig {
396
+ // V1 Encryption (privateKey) - deprecated but supported
397
+ // V1 doesn't decrypt checksum, uses different method
360
398
  }
361
399
 
362
400
  let fileNameWithoutPath = (fileName as NSString).lastPathComponent
@@ -372,19 +410,19 @@ import UIKit
372
410
 
373
411
  if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
374
412
  try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
375
- print("\(CapacitorUpdater.TAG) downloadManifest \(fileName) using builtin file \(id)")
413
+ logger.info("downloadManifest \(fileName) using builtin file \(id)")
376
414
  completedFiles += 1
377
415
  self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
378
416
  dispatchGroup.leave()
379
417
  } else if FileManager.default.fileExists(atPath: cacheFilePath.path) && verifyChecksum(file: cacheFilePath, expectedHash: fileHash) {
380
418
  try FileManager.default.copyItem(at: cacheFilePath, to: destFilePath)
381
- print("\(CapacitorUpdater.TAG) downloadManifest \(fileName) copy from cache \(id)")
419
+ logger.info("downloadManifest \(fileName) copy from cache \(id)")
382
420
  completedFiles += 1
383
421
  self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
384
422
  dispatchGroup.leave()
385
423
  } else {
386
424
  // File not in cache, download, decompress, and save to both cache and destination
387
- AF.download(downloadUrl).responseData { response in
425
+ self.alamofireSession.download(downloadUrl).responseData { response in
388
426
  defer { dispatchGroup.leave() }
389
427
 
390
428
  switch response.result {
@@ -399,9 +437,10 @@ import UIKit
399
437
  }
400
438
  }
401
439
 
402
- // Add decryption step if public key is set and sessionKey is provided
440
+ // Add decryption step if encryption keys are set
403
441
  var finalData = data
404
- if !self.publicKey.isEmpty && !sessionKey.isEmpty {
442
+ if !self.hasOldPrivateKeyPropertyInConfig && !self.publicKey.isEmpty && !sessionKey.isEmpty {
443
+ // V2 Encryption (publicKey)
405
444
  let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
406
445
  try finalData.write(to: tempFile)
407
446
  do {
@@ -410,19 +449,36 @@ import UIKit
410
449
  self.sendStats(action: "decrypt_fail", versionName: version)
411
450
  throw error
412
451
  }
413
- // TODO: try and do self.sendStats(action: "decrypt_fail", versionName: version) if fail
452
+ finalData = try Data(contentsOf: tempFile)
453
+ } else if self.hasOldPrivateKeyPropertyInConfig && !sessionKey.isEmpty {
454
+ // V1 Encryption (privateKey) - deprecated but supported
455
+ let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
456
+ try finalData.write(to: tempFile)
457
+ do {
458
+ try CryptoCipherV1.decryptFile(filePath: tempFile, privateKey: self.privateKey, sessionKey: sessionKey, version: version)
459
+ } catch {
460
+ self.sendStats(action: "decrypt_fail", versionName: version)
461
+ throw error
462
+ }
414
463
  finalData = try Data(contentsOf: tempFile)
415
464
  try FileManager.default.removeItem(at: tempFile)
416
465
  }
417
466
 
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)"])
467
+ // Check if file has .br extension for Brotli decompression
468
+ let isBrotli = fileName.hasSuffix(".br")
469
+ let finalFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
470
+ let destFilePath = destFolder.appendingPathComponent(finalFileName)
471
+
472
+ if isBrotli {
473
+ // Decompress the Brotli data
474
+ guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
475
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
476
+ }
477
+ finalData = decompressedData
421
478
  }
422
- finalData = decompressedData
423
479
 
424
480
  try finalData.write(to: destFilePath)
425
- if !self.hasOldPrivateKeyPropertyInConfig && !self.publicKey.isEmpty && !sessionKey.isEmpty {
481
+ if !self.publicKey.isEmpty && !sessionKey.isEmpty {
426
482
  // assume that calcChecksum != null
427
483
  let calculatedChecksum = CryptoCipherV2.calcChecksum(filePath: destFilePath)
428
484
  if calculatedChecksum != fileHash {
@@ -435,13 +491,13 @@ import UIKit
435
491
 
436
492
  completedFiles += 1
437
493
  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")
494
+ self.logger.info("downloadManifest \(id) \(fileName) downloaded\(isBrotli ? ", decompressed" : "")\(!self.publicKey.isEmpty && !sessionKey.isEmpty ? ", decrypted" : ""), and cached")
439
495
  } catch {
440
496
  downloadError = error
441
- NSLog("\(CapacitorUpdater.TAG) downloadManifest \(id) \(fileName) error: \(error.localizedDescription)")
497
+ self.logger.error("downloadManifest \(id) \(fileName) error: \(error.localizedDescription)")
442
498
  }
443
499
  case .failure(let error):
444
- NSLog("\(CapacitorUpdater.TAG) downloadManifest \(id) \(fileName) download error: \(error.localizedDescription). Debug response: \(response.debugDescription).")
500
+ self.logger.error("downloadManifest \(id) \(fileName) download error: \(error.localizedDescription). Debug response: \(response.debugDescription).")
445
501
  }
446
502
  }
447
503
  }
@@ -460,7 +516,7 @@ import UIKit
460
516
  let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.localizedString)
461
517
  self.saveBundleInfo(id: id, bundle: updatedBundle)
462
518
 
463
- print("\(CapacitorUpdater.TAG) downloadManifest done \(id)")
519
+ logger.info("downloadManifest done \(id)")
464
520
  return updatedBundle
465
521
  }
466
522
 
@@ -501,7 +557,7 @@ import UIKit
501
557
  var status = compression_stream_init(streamPointer, COMPRESSION_STREAM_DECODE, COMPRESSION_BROTLI)
502
558
 
503
559
  guard status != COMPRESSION_STATUS_ERROR else {
504
- print("\(CapacitorUpdater.TAG) Error: Failed to initialize Brotli stream for \(fileName). Status: \(status)")
560
+ logger.error("Error: Failed to initialize Brotli stream for \(fileName). Status: \(status)")
505
561
  return nil
506
562
  }
507
563
 
@@ -523,7 +579,7 @@ import UIKit
523
579
  if let baseAddress = rawBufferPointer.baseAddress {
524
580
  streamPointer.pointee.src_ptr = baseAddress.assumingMemoryBound(to: UInt8.self)
525
581
  } else {
526
- print("\(CapacitorUpdater.TAG) Error: Failed to get base address for \(fileName)")
582
+ logger.error("Error: Failed to get base address for \(fileName)")
527
583
  status = COMPRESSION_STATUS_ERROR
528
584
  return
529
585
  }
@@ -533,7 +589,7 @@ import UIKit
533
589
  if status == COMPRESSION_STATUS_ERROR {
534
590
  let maxBytes = min(32, data.count)
535
591
  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)")
592
+ logger.error("Error: Brotli decompression failed for \(fileName). First \(maxBytes) bytes: \(hexDump)")
537
593
  break
538
594
  }
539
595
 
@@ -547,18 +603,18 @@ import UIKit
547
603
  if status == COMPRESSION_STATUS_END {
548
604
  break
549
605
  } else if status == COMPRESSION_STATUS_ERROR {
550
- print("\(CapacitorUpdater.TAG) Error: Brotli process failed for \(fileName). Status: \(status)")
606
+ logger.error("Error: Brotli process failed for \(fileName). Status: \(status)")
551
607
  if let text = String(data: data, encoding: .utf8) {
552
608
  let asciiCount = text.unicodeScalars.filter { $0.isASCII }.count
553
609
  let totalCount = text.unicodeScalars.count
554
610
  if totalCount > 0 && Double(asciiCount) / Double(totalCount) >= 0.8 {
555
- print("\(CapacitorUpdater.TAG) Error: Input appears to be plain text: \(text)")
611
+ logger.error("Error: Input appears to be plain text: \(text)")
556
612
  }
557
613
  }
558
614
 
559
615
  let maxBytes = min(32, data.count)
560
616
  let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
561
- print("\(CapacitorUpdater.TAG) Error: Raw data (\(fileName)): \(hexDump)")
617
+ logger.error("Error: Raw data (\(fileName)): \(hexDump)")
562
618
 
563
619
  return nil
564
620
  }
@@ -569,7 +625,7 @@ import UIKit
569
625
  }
570
626
 
571
627
  if input.count == 0 {
572
- print("\(CapacitorUpdater.TAG) Error: Zero input size for \(fileName)")
628
+ logger.error("Error: Zero input size for \(fileName)")
573
629
  break
574
630
  }
575
631
  }
@@ -598,11 +654,13 @@ import UIKit
598
654
  let monitor = ClosureEventMonitor()
599
655
  monitor.requestDidCompleteTaskWithError = { (_, _, error) in
600
656
  if error != nil {
601
- print("\(CapacitorUpdater.TAG) Downloading failed - ClosureEventMonitor activated")
657
+ self.logger.error("Downloading failed - ClosureEventMonitor activated")
602
658
  mainError = error as NSError?
603
659
  }
604
660
  }
605
- let session = Session(eventMonitors: [monitor])
661
+ let configuration = URLSessionConfiguration.default
662
+ configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
663
+ let session = Session(configuration: configuration, eventMonitors: [monitor])
606
664
 
607
665
  let request = session.streamRequest(url, headers: requestHeaders).validate().onHTTPResponse(perform: { response in
608
666
  if let contentLength = response.headers.value(for: "Content-Length") {
@@ -629,11 +687,11 @@ import UIKit
629
687
  }
630
688
 
631
689
  } else {
632
- print("\(CapacitorUpdater.TAG) Download failed")
690
+ self.logger.error("Download failed")
633
691
  }
634
692
 
635
693
  case .complete:
636
- print("\(CapacitorUpdater.TAG) Download complete, total received bytes: \(totalReceivedBytes)")
694
+ self.logger.info("Download complete, total received bytes: \(totalReceivedBytes)")
637
695
  self.notifyDownload(id: id, percent: 70, ignoreMultipleOfTen: true)
638
696
  semaphore.signal()
639
697
  }
@@ -655,69 +713,68 @@ import UIKit
655
713
  reachabilityManager?.stopListening()
656
714
 
657
715
  if mainError != nil {
658
- print("\(CapacitorUpdater.TAG) Failed to download: \(String(describing: mainError))")
716
+ logger.error("Failed to download: \(String(describing: mainError))")
659
717
  self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
660
718
  throw mainError!
661
719
  }
662
720
 
663
721
  let finalPath = tempDataPath.deletingLastPathComponent().appendingPathComponent("\(id)")
664
722
  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
723
+ if !self.hasOldPrivateKeyPropertyInConfig {
724
+ // V2 Encryption (publicKey)
725
+ try CryptoCipherV2.decryptFile(filePath: tempDataPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
726
+ } else {
727
+ // V1 Encryption (privateKey) - deprecated but supported
728
+ try CryptoCipherV1.decryptFile(filePath: tempDataPath, privateKey: self.privateKey, sessionKey: sessionKey, version: version)
675
729
  }
676
730
  try FileManager.default.moveItem(at: tempDataPath, to: finalPath)
677
731
  } catch {
678
- print("\(CapacitorUpdater.TAG) Failed decrypt file : \(error)")
732
+ logger.error("Failed decrypt file : \(error)")
679
733
  self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
680
734
  cleanDownloadData()
681
735
  throw error
682
736
  }
683
737
 
684
738
  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)")
739
+ checksum = CryptoCipherV2.calcChecksum(filePath: finalPath)
740
+ logger.info("Downloading: 80% (unzipping)")
691
741
  try self.saveDownloaded(sourceZip: finalPath, id: id, base: self.libraryDir.appendingPathComponent(self.bundleDirectory), notify: true)
692
742
 
693
743
  } catch {
694
- print("\(CapacitorUpdater.TAG) Failed to unzip file: \(error)")
744
+ logger.error("Failed to unzip file: \(error)")
695
745
  self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
746
+ // Best-effort cleanup of the decrypted zip file when unzip fails
747
+ do {
748
+ if FileManager.default.fileExists(atPath: finalPath.path) {
749
+ try FileManager.default.removeItem(at: finalPath)
750
+ }
751
+ } catch {
752
+ logger.error("Could not delete failed zip at \(finalPath.path): \(error)")
753
+ }
696
754
  cleanDownloadData()
697
- // todo: cleanup zip attempts
698
755
  throw error
699
756
  }
700
757
 
701
758
  self.notifyDownload(id: id, percent: 90)
702
- print("\(CapacitorUpdater.TAG) Downloading: 90% (wrapping up)")
759
+ logger.info("Downloading: 90% (wrapping up)")
703
760
  let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum)
704
761
  self.saveBundleInfo(id: id, bundle: info)
705
762
  self.cleanDownloadData()
706
763
  self.notifyDownload(id: id, percent: 100)
707
- print("\(CapacitorUpdater.TAG) Downloading: 100% (complete)")
764
+ logger.info("Downloading: 100% (complete)")
708
765
  return info
709
766
  }
710
767
  private func ensureResumableFilesExist() {
711
768
  let fileManager = FileManager.default
712
769
  if !fileManager.fileExists(atPath: tempDataPath.path) {
713
770
  if !fileManager.createFile(atPath: tempDataPath.path, contents: Data()) {
714
- print("\(CapacitorUpdater.TAG) Cannot ensure that a file at \(tempDataPath.path) exists")
771
+ logger.error("Cannot ensure that a file at \(tempDataPath.path) exists")
715
772
  }
716
773
  }
717
774
 
718
775
  if !fileManager.fileExists(atPath: updateInfo.path) {
719
776
  if !fileManager.createFile(atPath: updateInfo.path, contents: Data()) {
720
- print("\(CapacitorUpdater.TAG) Cannot ensure that a file at \(updateInfo.path) exists")
777
+ logger.error("Cannot ensure that a file at \(updateInfo.path) exists")
721
778
  }
722
779
  }
723
780
  }
@@ -729,7 +786,7 @@ import UIKit
729
786
  do {
730
787
  try fileManager.removeItem(at: tempDataPath)
731
788
  } catch {
732
- print("\(CapacitorUpdater.TAG) Could not delete file at \(tempDataPath): \(error)")
789
+ logger.error("Could not delete file at \(tempDataPath): \(error)")
733
790
  }
734
791
  }
735
792
  // Deleting update.dat
@@ -737,7 +794,7 @@ import UIKit
737
794
  do {
738
795
  try fileManager.removeItem(at: updateInfo)
739
796
  } catch {
740
- print("\(CapacitorUpdater.TAG) Could not delete file at \(updateInfo): \(error)")
797
+ logger.error("Could not delete file at \(updateInfo): \(error)")
741
798
  }
742
799
  }
743
800
  }
@@ -756,7 +813,7 @@ import UIKit
756
813
  fileHandle.closeFile()
757
814
  }
758
815
  } catch {
759
- print("Failed to write data starting at byte \(byteOffset): \(error)")
816
+ logger.error("Failed to write data starting at byte \(byteOffset): \(error)")
760
817
  }
761
818
  self.tempData.removeAll() // Clearing tempData to avoid writing the same data multiple times
762
819
  }
@@ -765,7 +822,7 @@ import UIKit
765
822
  do {
766
823
  try "\(version)".write(to: updateInfo, atomically: true, encoding: .utf8)
767
824
  } catch {
768
- print("\(CapacitorUpdater.TAG) Failed to save progress: \(error)")
825
+ logger.error("Failed to save progress: \(error)")
769
826
  }
770
827
  }
771
828
  private func getLocalUpdateVersion() -> String { // Return the version that was tried to be downloaded on last download attempt
@@ -787,7 +844,7 @@ import UIKit
787
844
  return fileSize.int64Value
788
845
  }
789
846
  } catch {
790
- print("\(CapacitorUpdater.TAG) Could not retrieve already downloaded data size : \(error)")
847
+ logger.error("Could not retrieve already downloaded data size : \(error)")
791
848
  }
792
849
  return 0
793
850
  }
@@ -799,7 +856,7 @@ import UIKit
799
856
  do {
800
857
  let files: [String] = try FileManager.default.contentsOfDirectory(atPath: dest.path)
801
858
  var res: [BundleInfo] = []
802
- print("\(CapacitorUpdater.TAG) list File : \(dest.path)")
859
+ logger.info("list File : \(dest.path)")
803
860
  if dest.exist {
804
861
  for id: String in files {
805
862
  res.append(self.getBundleInfo(id: id))
@@ -807,12 +864,12 @@ import UIKit
807
864
  }
808
865
  return res
809
866
  } catch {
810
- print("\(CapacitorUpdater.TAG) No version available \(dest.path)")
867
+ logger.info("No version available \(dest.path)")
811
868
  return []
812
869
  }
813
870
  } else {
814
871
  guard let regex = try? NSRegularExpression(pattern: "^[0-9A-Za-z]{10}_info$") else {
815
- print("\(CapacitorUpdater.TAG) Invald regex ?????")
872
+ logger.error("Invalid regex ?????")
816
873
  return []
817
874
  }
818
875
  return UserDefaults.standard.dictionaryRepresentation().keys.filter {
@@ -831,7 +888,7 @@ import UIKit
831
888
  public func delete(id: String, removeInfo: Bool) -> Bool {
832
889
  let deleted: BundleInfo = self.getBundleInfo(id: id)
833
890
  if deleted.isBuiltin() || self.getCurrentBundleId() == id {
834
- print("\(CapacitorUpdater.TAG) Cannot delete \(id)")
891
+ logger.info("Cannot delete \(id)")
835
892
  return false
836
893
  }
837
894
 
@@ -840,7 +897,7 @@ import UIKit
840
897
  !next.isDeleted() &&
841
898
  !next.isErrorStatus() &&
842
899
  next.getId() == id {
843
- print("\(CapacitorUpdater.TAG) Cannot delete the next bundle \(id)")
900
+ logger.info("Cannot delete the next bundle \(id)")
844
901
  return false
845
902
  }
846
903
 
@@ -848,7 +905,7 @@ import UIKit
848
905
  do {
849
906
  try FileManager.default.removeItem(atPath: destPersist.path)
850
907
  } catch {
851
- print("\(CapacitorUpdater.TAG) Folder \(destPersist.path), not removed.")
908
+ logger.error("Folder \(destPersist.path), not removed.")
852
909
  // even if, we don;t care. Android doesn't care
853
910
  if removeInfo {
854
911
  self.removeBundleInfo(id: id)
@@ -861,7 +918,7 @@ import UIKit
861
918
  } else {
862
919
  self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.localizedString))
863
920
  }
864
- print("\(CapacitorUpdater.TAG) bundle delete \(deleted.getVersionName())")
921
+ logger.info("bundle delete \(deleted.getVersionName())")
865
922
  self.sendStats(action: "delete", versionName: deleted.getVersionName())
866
923
  return true
867
924
  }
@@ -870,6 +927,42 @@ import UIKit
870
927
  return self.delete(id: id, removeInfo: true)
871
928
  }
872
929
 
930
+ public func cleanupDownloadDirectories(allowedIds: Set<String>) {
931
+ let bundleRoot = libraryDir.appendingPathComponent(bundleDirectory)
932
+ let fileManager = FileManager.default
933
+
934
+ guard fileManager.fileExists(atPath: bundleRoot.path) else {
935
+ return
936
+ }
937
+
938
+ do {
939
+ let contents = try fileManager.contentsOfDirectory(at: bundleRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
940
+
941
+ for url in contents {
942
+ let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
943
+ if resourceValues.isDirectory != true {
944
+ continue
945
+ }
946
+
947
+ let id = url.lastPathComponent
948
+
949
+ if allowedIds.contains(id) {
950
+ continue
951
+ }
952
+
953
+ do {
954
+ try fileManager.removeItem(at: url)
955
+ self.removeBundleInfo(id: id)
956
+ logger.info("Deleted orphan bundle directory: \(id)")
957
+ } catch {
958
+ logger.error("Failed to delete orphan bundle directory: \(id) \(error.localizedDescription)")
959
+ }
960
+ }
961
+ } catch {
962
+ logger.error("Failed to enumerate bundle directory for cleanup: \(error.localizedDescription)")
963
+ }
964
+ }
965
+
873
966
  public func getBundleDirectory(id: String) -> URL {
874
967
  return libraryDir.appendingPathComponent(self.bundleDirectory).appendingPathComponent(id)
875
968
  }
@@ -914,7 +1007,7 @@ import UIKit
914
1007
  public func autoReset() {
915
1008
  let currentBundle: BundleInfo = self.getCurrentBundle()
916
1009
  if !currentBundle.isBuiltin() && !self.bundleExists(id: currentBundle.getId()) {
917
- print("\(CapacitorUpdater.TAG) Folder at bundle path does not exist. Triggering reset.")
1010
+ logger.info("Folder at bundle path does not exist. Triggering reset.")
918
1011
  self.reset()
919
1012
  }
920
1013
  }
@@ -924,7 +1017,7 @@ import UIKit
924
1017
  }
925
1018
 
926
1019
  public func reset(isInternal: Bool) {
927
- print("\(CapacitorUpdater.TAG) reset: \(isInternal)")
1020
+ logger.info("reset: \(isInternal)")
928
1021
  let currentBundleName = self.getCurrentBundle().getVersionName()
929
1022
  self.setCurrentBundle(bundle: "")
930
1023
  self.setFallbackBundle(fallback: Optional<BundleInfo>.none)
@@ -937,14 +1030,14 @@ import UIKit
937
1030
  public func setSuccess(bundle: BundleInfo, autoDeletePrevious: Bool) {
938
1031
  self.setBundleStatus(id: bundle.getId(), status: BundleStatus.SUCCESS)
939
1032
  let fallback: BundleInfo = self.getFallbackBundle()
940
- print("\(CapacitorUpdater.TAG) Fallback bundle is: \(fallback.toString())")
941
- print("\(CapacitorUpdater.TAG) Version successfully loaded: \(bundle.toString())")
1033
+ logger.info("Fallback bundle is: \(fallback.toString())")
1034
+ logger.info("Version successfully loaded: \(bundle.toString())")
942
1035
  if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() {
943
1036
  let res = self.delete(id: fallback.getId())
944
1037
  if res {
945
- print("\(CapacitorUpdater.TAG) Deleted previous bundle: \(fallback.toString())")
1038
+ logger.info("Deleted previous bundle: \(fallback.toString())")
946
1039
  } else {
947
- print("\(CapacitorUpdater.TAG) Failed to delete previous bundle: \(fallback.toString())")
1040
+ logger.error("Failed to delete previous bundle: \(fallback.toString())")
948
1041
  }
949
1042
  }
950
1043
  self.setFallbackBundle(fallback: bundle)
@@ -956,8 +1049,17 @@ import UIKit
956
1049
 
957
1050
  func unsetChannel() -> SetChannel {
958
1051
  let setChannel: SetChannel = SetChannel()
1052
+
1053
+ // Check if rate limit was exceeded
1054
+ if CapgoUpdater.rateLimitExceeded {
1055
+ logger.debug("Skipping unsetChannel due to rate limit (429). Requests will resume after app restart.")
1056
+ setChannel.message = "Rate limit exceeded"
1057
+ setChannel.error = "rate_limit_exceeded"
1058
+ return setChannel
1059
+ }
1060
+
959
1061
  if (self.channelUrl ).isEmpty {
960
- print("\(CapacitorUpdater.TAG) Channel URL is not set")
1062
+ logger.error("Channel URL is not set")
961
1063
  setChannel.message = "Channel URL is not set"
962
1064
  setChannel.error = "missing_config"
963
1065
  return setChannel
@@ -965,24 +1067,30 @@ import UIKit
965
1067
  let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
966
1068
  let parameters: InfoObject = self.createInfoObject()
967
1069
 
968
- let request = AF.request(self.channelUrl, method: .delete, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1070
+ let request = alamofireSession.request(self.channelUrl, method: .delete, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
969
1071
 
970
1072
  request.validate().responseDecodable(of: SetChannelDec.self) { response in
1073
+ // Check for 429 rate limit
1074
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1075
+ setChannel.message = "Rate limit exceeded"
1076
+ setChannel.error = "rate_limit_exceeded"
1077
+ semaphore.signal()
1078
+ return
1079
+ }
1080
+
971
1081
  switch response.result {
972
1082
  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
1083
+ if let responseValue = response.value {
1084
+ if let error = responseValue.error {
1085
+ setChannel.error = error
1086
+ } else {
1087
+ setChannel.status = responseValue.status ?? ""
1088
+ setChannel.message = responseValue.message ?? ""
1089
+ }
981
1090
  }
982
1091
  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"
1092
+ self.logger.error("Error unset Channel \(error)")
1093
+ setChannel.error = "Request failed: \(error.localizedDescription)"
986
1094
  }
987
1095
  semaphore.signal()
988
1096
  }
@@ -992,8 +1100,17 @@ import UIKit
992
1100
 
993
1101
  func setChannel(channel: String) -> SetChannel {
994
1102
  let setChannel: SetChannel = SetChannel()
1103
+
1104
+ // Check if rate limit was exceeded
1105
+ if CapgoUpdater.rateLimitExceeded {
1106
+ logger.debug("Skipping setChannel due to rate limit (429). Requests will resume after app restart.")
1107
+ setChannel.message = "Rate limit exceeded"
1108
+ setChannel.error = "rate_limit_exceeded"
1109
+ return setChannel
1110
+ }
1111
+
995
1112
  if (self.channelUrl ).isEmpty {
996
- print("\(CapacitorUpdater.TAG) Channel URL is not set")
1113
+ logger.error("Channel URL is not set")
997
1114
  setChannel.message = "Channel URL is not set"
998
1115
  setChannel.error = "missing_config"
999
1116
  return setChannel
@@ -1002,24 +1119,30 @@ import UIKit
1002
1119
  var parameters: InfoObject = self.createInfoObject()
1003
1120
  parameters.channel = channel
1004
1121
 
1005
- let request = AF.request(self.channelUrl, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1122
+ let request = alamofireSession.request(self.channelUrl, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1006
1123
 
1007
1124
  request.validate().responseDecodable(of: SetChannelDec.self) { response in
1125
+ // Check for 429 rate limit
1126
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1127
+ setChannel.message = "Rate limit exceeded"
1128
+ setChannel.error = "rate_limit_exceeded"
1129
+ semaphore.signal()
1130
+ return
1131
+ }
1132
+
1008
1133
  switch response.result {
1009
1134
  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
1135
+ if let responseValue = response.value {
1136
+ if let error = responseValue.error {
1137
+ setChannel.error = error
1138
+ } else {
1139
+ setChannel.status = responseValue.status ?? ""
1140
+ setChannel.message = responseValue.message ?? ""
1141
+ }
1018
1142
  }
1019
1143
  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"
1144
+ self.logger.error("Error set Channel \(error)")
1145
+ setChannel.error = "Request failed: \(error.localizedDescription)"
1023
1146
  }
1024
1147
  semaphore.signal()
1025
1148
  }
@@ -1029,36 +1152,48 @@ import UIKit
1029
1152
 
1030
1153
  func getChannel() -> GetChannel {
1031
1154
  let getChannel: GetChannel = GetChannel()
1155
+
1156
+ // Check if rate limit was exceeded
1157
+ if CapgoUpdater.rateLimitExceeded {
1158
+ logger.debug("Skipping getChannel due to rate limit (429). Requests will resume after app restart.")
1159
+ getChannel.message = "Rate limit exceeded"
1160
+ getChannel.error = "rate_limit_exceeded"
1161
+ return getChannel
1162
+ }
1163
+
1032
1164
  if (self.channelUrl ).isEmpty {
1033
- print("\(CapacitorUpdater.TAG) Channel URL is not set")
1165
+ logger.error("Channel URL is not set")
1034
1166
  getChannel.message = "Channel URL is not set"
1035
1167
  getChannel.error = "missing_config"
1036
1168
  return getChannel
1037
1169
  }
1038
1170
  let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
1039
1171
  let parameters: InfoObject = self.createInfoObject()
1040
- let request = AF.request(self.channelUrl, method: .put, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1172
+ let request = alamofireSession.request(self.channelUrl, method: .put, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1041
1173
 
1042
1174
  request.validate().responseDecodable(of: GetChannelDec.self) { response in
1043
1175
  defer {
1044
1176
  semaphore.signal()
1045
1177
  }
1178
+
1179
+ // Check for 429 rate limit
1180
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1181
+ getChannel.message = "Rate limit exceeded"
1182
+ getChannel.error = "rate_limit_exceeded"
1183
+ return
1184
+ }
1185
+
1046
1186
  switch response.result {
1047
1187
  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
1188
+ if let responseValue = response.value {
1189
+ if let error = responseValue.error {
1190
+ getChannel.error = error
1191
+ } else {
1192
+ getChannel.status = responseValue.status ?? ""
1193
+ getChannel.message = responseValue.message ?? ""
1194
+ getChannel.channel = responseValue.channel ?? ""
1195
+ getChannel.allowSet = responseValue.allowSet ?? true
1196
+ }
1062
1197
  }
1063
1198
  case let .failure(error):
1064
1199
  if let data = response.data, let bodyString = String(data: data, encoding: .utf8) {
@@ -1069,18 +1204,105 @@ import UIKit
1069
1204
  }
1070
1205
  }
1071
1206
 
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"
1207
+ self.logger.error("Error get Channel \(error)")
1208
+ getChannel.error = "Request failed: \(error.localizedDescription)"
1075
1209
  }
1076
1210
  }
1077
1211
  semaphore.wait()
1078
1212
  return getChannel
1079
1213
  }
1080
1214
 
1215
+ func listChannels() -> ListChannels {
1216
+ let listChannels: ListChannels = ListChannels()
1217
+
1218
+ // Check if rate limit was exceeded
1219
+ if CapgoUpdater.rateLimitExceeded {
1220
+ logger.debug("Skipping listChannels due to rate limit (429). Requests will resume after app restart.")
1221
+ listChannels.error = "rate_limit_exceeded"
1222
+ return listChannels
1223
+ }
1224
+
1225
+ if (self.channelUrl).isEmpty {
1226
+ logger.error("Channel URL is not set")
1227
+ listChannels.error = "Channel URL is not set"
1228
+ return listChannels
1229
+ }
1230
+
1231
+ let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
1232
+
1233
+ // Auto-detect values
1234
+ let appId = self.appId
1235
+ let platform = "ios"
1236
+ let isEmulator = self.isEmulator()
1237
+ let isProd = self.isProd()
1238
+
1239
+ // Create query parameters
1240
+ var urlComponents = URLComponents(string: self.channelUrl)
1241
+ urlComponents?.queryItems = [
1242
+ URLQueryItem(name: "app_id", value: appId),
1243
+ URLQueryItem(name: "platform", value: platform),
1244
+ URLQueryItem(name: "is_emulator", value: String(isEmulator)),
1245
+ URLQueryItem(name: "is_prod", value: String(isProd))
1246
+ ]
1247
+
1248
+ guard let url = urlComponents?.url else {
1249
+ logger.error("Invalid channel URL")
1250
+ listChannels.error = "Invalid channel URL"
1251
+ return listChannels
1252
+ }
1253
+
1254
+ let request = alamofireSession.request(url, method: .get, requestModifier: { $0.timeoutInterval = self.timeout })
1255
+
1256
+ request.validate().responseDecodable(of: ListChannelsDec.self) { response in
1257
+ defer {
1258
+ semaphore.signal()
1259
+ }
1260
+
1261
+ // Check for 429 rate limit
1262
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1263
+ listChannels.error = "rate_limit_exceeded"
1264
+ return
1265
+ }
1266
+
1267
+ switch response.result {
1268
+ case .success:
1269
+ if let responseValue = response.value {
1270
+ // Check for server-side errors
1271
+ if let error = responseValue.error {
1272
+ listChannels.error = error
1273
+ return
1274
+ }
1275
+
1276
+ // Backend returns direct array, so channels should be populated by our custom decoder
1277
+ if let channels = responseValue.channels {
1278
+ listChannels.channels = channels.map { channel in
1279
+ var channelDict: [String: Any] = [:]
1280
+ channelDict["id"] = channel.id ?? ""
1281
+ channelDict["name"] = channel.name ?? ""
1282
+ channelDict["public"] = channel.public ?? false
1283
+ channelDict["allow_self_set"] = channel.allow_self_set ?? false
1284
+ return channelDict
1285
+ }
1286
+ }
1287
+ }
1288
+ case let .failure(error):
1289
+ self.logger.error("Error list channels \(error)")
1290
+ listChannels.error = "Request failed: \(error.localizedDescription)"
1291
+ }
1292
+ }
1293
+ semaphore.wait()
1294
+ return listChannels
1295
+ }
1296
+
1081
1297
  private let operationQueue = OperationQueue()
1082
1298
 
1083
1299
  func sendStats(action: String, versionName: String? = nil, oldVersionName: String? = "") {
1300
+ // Check if rate limit was exceeded
1301
+ if CapgoUpdater.rateLimitExceeded {
1302
+ logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.")
1303
+ return
1304
+ }
1305
+
1084
1306
  guard !statsUrl.isEmpty else {
1085
1307
  return
1086
1308
  }
@@ -1095,22 +1317,28 @@ import UIKit
1095
1317
 
1096
1318
  let operation = BlockOperation {
1097
1319
  let semaphore = DispatchSemaphore(value: 0)
1098
- AF.request(
1320
+ self.alamofireSession.request(
1099
1321
  self.statsUrl,
1100
1322
  method: .post,
1101
1323
  parameters: parameters,
1102
1324
  encoder: JSONParameterEncoder.default,
1103
1325
  requestModifier: { $0.timeoutInterval = self.timeout }
1104
1326
  ).responseData { response in
1327
+ // Check for 429 rate limit
1328
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1329
+ semaphore.signal()
1330
+ return
1331
+ }
1332
+
1105
1333
  switch response.result {
1106
1334
  case .success:
1107
- print("\(CapacitorUpdater.TAG) Stats sent for \(action), version \(versionName)")
1335
+ self.logger.info("Stats sent for \(action), version \(versionName)")
1108
1336
  case let .failure(error):
1109
- print("\(CapacitorUpdater.TAG) Error sending stats: ", response.value ?? "", error.localizedDescription)
1337
+ self.logger.error("Error sending stats: \(response.value?.debugDescription ?? "") \(error.localizedDescription)")
1110
1338
  }
1111
1339
  semaphore.signal()
1112
1340
  }
1113
- semaphore.signal()
1341
+ semaphore.wait()
1114
1342
  }
1115
1343
  operationQueue.addOperation(operation)
1116
1344
 
@@ -1121,7 +1349,6 @@ import UIKit
1121
1349
  if id != nil {
1122
1350
  trueId = id!
1123
1351
  }
1124
- // print("\(CapacitorUpdater.TAG) Getting info for bundle [\(trueId)]")
1125
1352
  let result: BundleInfo
1126
1353
  if BundleInfo.ID_BUILTIN == trueId {
1127
1354
  result = BundleInfo(id: trueId, version: "", status: BundleStatus.SUCCESS, checksum: "")
@@ -1131,11 +1358,10 @@ import UIKit
1131
1358
  do {
1132
1359
  result = try UserDefaults.standard.getObj(forKey: "\(trueId)\(self.INFO_SUFFIX)", castTo: BundleInfo.self)
1133
1360
  } catch {
1134
- print("\(CapacitorUpdater.TAG) Failed to parse info for bundle [\(trueId)]", error.localizedDescription)
1361
+ logger.error("Failed to parse info for bundle [\(trueId)] \(error.localizedDescription)")
1135
1362
  result = BundleInfo(id: trueId, version: "", status: BundleStatus.PENDING, checksum: "")
1136
1363
  }
1137
1364
  }
1138
- // print("\(CapacitorUpdater.TAG) Returning info bundle [\(result.toString())]")
1139
1365
  return result
1140
1366
  }
1141
1367
 
@@ -1155,26 +1381,26 @@ import UIKit
1155
1381
 
1156
1382
  public func saveBundleInfo(id: String, bundle: BundleInfo?) {
1157
1383
  if bundle != nil && (bundle!.isBuiltin() || bundle!.isUnknown()) {
1158
- print("\(CapacitorUpdater.TAG) Not saving info for bundle [\(id)]", bundle?.toString() ?? "")
1384
+ logger.info("Not saving info for bundle [\(id)] \(bundle?.toString() ?? "")")
1159
1385
  return
1160
1386
  }
1161
1387
  if bundle == nil {
1162
- print("\(CapacitorUpdater.TAG) Removing info for bundle [\(id)]")
1388
+ logger.info("Removing info for bundle [\(id)]")
1163
1389
  UserDefaults.standard.removeObject(forKey: "\(id)\(self.INFO_SUFFIX)")
1164
1390
  } else {
1165
1391
  let update = bundle!.setId(id: id)
1166
- print("\(CapacitorUpdater.TAG) Storing info for bundle [\(id)]", update.toString())
1392
+ logger.info("Storing info for bundle [\(id)] \(update.toString())")
1167
1393
  do {
1168
1394
  try UserDefaults.standard.setObj(update, forKey: "\(id)\(self.INFO_SUFFIX)")
1169
1395
  } catch {
1170
- print("\(CapacitorUpdater.TAG) Failed to save info for bundle [\(id)]", error.localizedDescription)
1396
+ logger.error("Failed to save info for bundle [\(id)] \(error.localizedDescription)")
1171
1397
  }
1172
1398
  }
1173
1399
  UserDefaults.standard.synchronize()
1174
1400
  }
1175
1401
 
1176
1402
  private func setBundleStatus(id: String, status: BundleStatus) {
1177
- print("\(CapacitorUpdater.TAG) Setting status for bundle [\(id)] to \(status)")
1403
+ logger.info("Setting status for bundle [\(id)] to \(status)")
1178
1404
  let info = self.getBundleInfo(id: id)
1179
1405
  self.saveBundleInfo(id: id, bundle: info.setStatus(status: status.localizedString))
1180
1406
  }