@capgo/capacitor-updater 8.0.1 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CapgoCapacitorUpdater.podspec +7 -5
  2. package/Package.swift +9 -7
  3. package/README.md +984 -215
  4. package/android/build.gradle +24 -12
  5. package/android/proguard-rules.pro +22 -5
  6. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +110 -22
  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 +1310 -488
  9. package/android/src/main/java/ee/forgr/capacitor_updater/{CapacitorUpdater.java → CapgoUpdater.java} +640 -203
  10. package/android/src/main/java/ee/forgr/capacitor_updater/{CryptoCipherV2.java → CryptoCipher.java} +119 -33
  11. package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +0 -3
  12. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +260 -0
  13. package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +221 -0
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +497 -133
  15. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +80 -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 +873 -154
  20. package/dist/esm/definitions.d.ts +881 -114
  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/Sources/CapacitorUpdaterPlugin/AES.swift +69 -0
  36. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +55 -0
  37. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleInfo.swift +37 -10
  38. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleStatus.swift +1 -1
  39. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1605 -0
  40. package/ios/{Plugin/CapacitorUpdater.swift → Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift} +523 -230
  41. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +267 -0
  42. package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +220 -0
  43. package/ios/Sources/CapacitorUpdaterPlugin/DeviceIdHelper.swift +120 -0
  44. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/InternalUtils.swift +53 -0
  45. package/ios/Sources/CapacitorUpdaterPlugin/Logger.swift +310 -0
  46. package/ios/Sources/CapacitorUpdaterPlugin/RSA.swift +274 -0
  47. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +112 -0
  48. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/UserDefaultsExtension.swift +0 -2
  49. package/package.json +21 -19
  50. package/ios/Plugin/CapacitorUpdaterPlugin.swift +0 -975
  51. package/ios/Plugin/CryptoCipherV2.swift +0 -310
  52. /package/{LICENCE → LICENSE} +0 -0
  53. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayCondition.swift +0 -0
  54. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayUntilNext.swift +0 -0
  55. /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,11 +30,10 @@ 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 = ""
36
- public var PLUGIN_VERSION: String = ""
36
+ public var pluginVersion: String = ""
37
37
  public var timeout: Double = 20
38
38
  public var statsUrl: String = ""
39
39
  public var channelUrl: String = ""
@@ -42,12 +42,34 @@ import UIKit
42
42
  public var deviceID = ""
43
43
  public var publicKey: String = ""
44
44
 
45
- public var notifyDownloadRaw: (String, Int, Bool) -> Void = { _, _, _ in }
46
- public func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false) {
47
- notifyDownloadRaw(id, percent, ignoreMultipleOfTen)
45
+ // Flag to track if we received a 429 response - stops requests until app restart
46
+ private static var rateLimitExceeded = false
47
+
48
+ // Flag to track if we've already sent the rate limit statistic - prevents infinite loop
49
+ private static var rateLimitStatisticSent = false
50
+
51
+ private var userAgent: String {
52
+ let safePluginVersion = pluginVersion.isEmpty ? "unknown" : pluginVersion
53
+ let safeAppId = appId.isEmpty ? "unknown" : appId
54
+ return "CapacitorUpdater/\(safePluginVersion) (\(safeAppId)) ios/\(versionOs)"
55
+ }
56
+
57
+ private lazy var alamofireSession: Session = {
58
+ let configuration = URLSessionConfiguration.default
59
+ configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
60
+ return Session(configuration: configuration)
61
+ }()
62
+
63
+ public var notifyDownloadRaw: (String, Int, Bool, BundleInfo?) -> Void = { _, _, _, _ in }
64
+ public func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
65
+ notifyDownloadRaw(id, percent, ignoreMultipleOfTen, bundle)
48
66
  }
49
67
  public var notifyDownload: (String, Int) -> Void = { _, _ in }
50
68
 
69
+ public func setLogger(_ logger: Logger) {
70
+ self.logger = logger
71
+ }
72
+
51
73
  private func calcTotalPercent(percent: Int, min: Int, max: Int) -> Int {
52
74
  return (percent * (max - min)) / 100 + min
53
75
  }
@@ -69,6 +91,64 @@ import UIKit
69
91
  return !self.isDevEnvironment && !self.isAppStoreReceiptSandbox() && !self.hasEmbeddedMobileProvision()
70
92
  }
71
93
 
94
+ /**
95
+ * Check if a 429 (Too Many Requests) response was received and set the flag
96
+ */
97
+ private func checkAndHandleRateLimitResponse(statusCode: Int?) -> Bool {
98
+ if statusCode == 429 {
99
+ // Send a statistic about the rate limit BEFORE setting the flag
100
+ // Only send once to prevent infinite loop if the stat request itself gets rate limited
101
+ if !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
102
+ CapgoUpdater.rateLimitStatisticSent = true
103
+
104
+ // Dispatch to background queue to avoid blocking the main thread
105
+ DispatchQueue.global(qos: .utility).async {
106
+ self.sendRateLimitStatistic()
107
+ }
108
+ }
109
+ CapgoUpdater.rateLimitExceeded = true
110
+ logger.warn("Rate limit exceeded (429). Stopping all stats and channel requests until app restart.")
111
+ return true
112
+ }
113
+ return false
114
+ }
115
+
116
+ /**
117
+ * Send a synchronous statistic about rate limiting
118
+ * Note: This method uses a semaphore to block until the request completes.
119
+ * It MUST be called from a background queue to avoid blocking the main thread.
120
+ */
121
+ private func sendRateLimitStatistic() {
122
+ guard !statsUrl.isEmpty else {
123
+ return
124
+ }
125
+
126
+ let current = getCurrentBundle()
127
+ var parameters = createInfoObject()
128
+ parameters.action = "rate_limit_reached"
129
+ parameters.version_name = current.getVersionName()
130
+ parameters.old_version_name = ""
131
+
132
+ // Send synchronously using semaphore (safe because we're on a background queue)
133
+ let semaphore = DispatchSemaphore(value: 0)
134
+ self.alamofireSession.request(
135
+ self.statsUrl,
136
+ method: .post,
137
+ parameters: parameters,
138
+ encoder: JSONParameterEncoder.default,
139
+ requestModifier: { $0.timeoutInterval = self.timeout }
140
+ ).responseData { response in
141
+ switch response.result {
142
+ case .success:
143
+ self.logger.info("Rate limit statistic sent")
144
+ case let .failure(error):
145
+ self.logger.error("Error sending rate limit statistic: \(error.localizedDescription)")
146
+ }
147
+ semaphore.signal()
148
+ }
149
+ semaphore.wait()
150
+ }
151
+
72
152
  // MARK: Private
73
153
  private func hasEmbeddedMobileProvision() -> Bool {
74
154
  guard Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") == nil else {
@@ -108,7 +188,7 @@ import UIKit
108
188
  do {
109
189
  try FileManager.default.createDirectory(atPath: source.path, withIntermediateDirectories: true, attributes: nil)
110
190
  } catch {
111
- print("\(CapacitorUpdater.TAG) Cannot createDirectory \(source.path)")
191
+ logger.error("Cannot createDirectory \(source.path)")
112
192
  throw CustomError.cannotCreateDirectory
113
193
  }
114
194
  }
@@ -118,7 +198,7 @@ import UIKit
118
198
  do {
119
199
  try FileManager.default.removeItem(atPath: source.path)
120
200
  } catch {
121
- print("\(CapacitorUpdater.TAG) File not removed. \(source.path)")
201
+ logger.error("File not removed. \(source.path)")
122
202
  throw CustomError.cannotDeleteDirectory
123
203
  }
124
204
  }
@@ -135,58 +215,14 @@ import UIKit
135
215
  return false
136
216
  }
137
217
  } catch {
138
- print("\(CapacitorUpdater.TAG) File not moved. source: \(source.path) dest: \(dest.path)")
218
+ logger.error("File not moved. source: \(source.path) dest: \(dest.path)")
139
219
  throw CustomError.cannotUnflat
140
220
  }
141
221
  }
142
222
 
143
- private func decryptFileV2(filePath: URL, sessionKey: String, version: String) throws {
144
- if self.publicKey.isEmpty || sessionKey.isEmpty || sessionKey.components(separatedBy: ":").count != 2 {
145
- print("\(CapacitorUpdater.TAG) Cannot find public key or sessionKey")
146
- return
147
- }
148
- do {
149
- guard let rsaPublicKey: RSAPublicKey = .load(rsaPublicKey: self.publicKey) else {
150
- print("cannot decode publicKey", self.publicKey)
151
- throw CustomError.cannotDecode
152
- }
153
-
154
- let sessionKeyArray: [String] = sessionKey.components(separatedBy: ":")
155
- guard let ivData: Data = Data(base64Encoded: sessionKeyArray[0]) else {
156
- print("cannot decode sessionKey", sessionKey)
157
- throw CustomError.cannotDecode
158
- }
159
-
160
- guard let sessionKeyDataEncrypted = Data(base64Encoded: sessionKeyArray[1]) else {
161
- throw NSError(domain: "Invalid session key data", code: 1, userInfo: nil)
162
- }
163
-
164
- guard let sessionKeyDataDecrypted = rsaPublicKey.decrypt(data: sessionKeyDataEncrypted) else {
165
- throw NSError(domain: "Failed to decrypt session key data", code: 2, userInfo: nil)
166
- }
167
-
168
- let aesPrivateKey = AES128Key(iv: ivData, aes128Key: sessionKeyDataDecrypted)
169
-
170
- guard let encryptedData = try? Data(contentsOf: filePath) else {
171
- throw NSError(domain: "Failed to read encrypted data", code: 3, userInfo: nil)
172
- }
173
-
174
- guard let decryptedData = aesPrivateKey.decrypt(data: encryptedData) else {
175
- throw NSError(domain: "Failed to decrypt data", code: 4, userInfo: nil)
176
- }
177
-
178
- try decryptedData.write(to: filePath)
179
-
180
- } catch {
181
- print("\(CapacitorUpdater.TAG) Cannot decode: \(filePath.path)", error)
182
- self.sendStats(action: "decrypt_fail", versionName: version)
183
- throw CustomError.cannotDecode
184
- }
185
- }
186
-
187
223
  private func unzipProgressHandler(entry: String, zipInfo: unz_file_info, entryNumber: Int, total: Int, destUnZip: URL, id: String, unzipError: inout NSError?) {
188
224
  if entry.contains("\\") {
189
- print("\(CapacitorUpdater.TAG) unzip: Windows path is not supported, please use unix path as required by zip RFC: \(entry)")
225
+ logger.error("unzip: Windows path is not supported, please use unix path as required by zip RFC: \(entry)")
190
226
  self.sendStats(action: "windows_path_fail")
191
227
  }
192
228
 
@@ -255,12 +291,22 @@ import UIKit
255
291
 
256
292
  if !success || unzipError != nil {
257
293
  self.sendStats(action: "unzip_fail")
294
+ try? FileManager.default.removeItem(at: destUnZip)
258
295
  throw unzipError ?? CustomError.cannotUnzip
259
296
  }
260
297
 
261
298
  if try unflatFolder(source: destUnZip, dest: destPersist) {
262
299
  try deleteFolder(source: destUnZip)
263
300
  }
301
+
302
+ // Cleanup: remove the downloaded/decrypted zip after successful extraction
303
+ do {
304
+ if FileManager.default.fileExists(atPath: sourceZip.path) {
305
+ try FileManager.default.removeItem(at: sourceZip)
306
+ }
307
+ } catch {
308
+ logger.error("Could not delete source zip at \(sourceZip.path): \(error)")
309
+ }
264
310
  }
265
311
 
266
312
  private func createInfoObject() -> InfoObject {
@@ -273,7 +319,7 @@ import UIKit
273
319
  version_code: self.versionCode,
274
320
  version_os: self.versionOs,
275
321
  version_name: self.getCurrentBundle().getVersionName(),
276
- plugin_version: self.PLUGIN_VERSION,
322
+ plugin_version: self.pluginVersion,
277
323
  is_emulator: self.isEmulator(),
278
324
  is_prod: self.isProd(),
279
325
  action: nil,
@@ -289,12 +335,13 @@ import UIKit
289
335
  if let channel = channel {
290
336
  parameters.defaultChannel = channel
291
337
  }
292
- print("\(CapacitorUpdater.TAG) Auto-update parameters: \(parameters)")
293
- let request = AF.request(url, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
338
+ logger.info("Auto-update parameters: \(parameters)")
339
+ let request = alamofireSession.request(url, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
294
340
 
295
341
  request.validate().responseDecodable(of: AppVersionDec.self) { response in
296
342
  switch response.result {
297
343
  case .success:
344
+ latest.statusCode = response.response?.statusCode ?? 0
298
345
  if let url = response.value?.url {
299
346
  latest.url = url
300
347
  }
@@ -307,6 +354,9 @@ import UIKit
307
354
  if let major = response.value?.major {
308
355
  latest.major = major
309
356
  }
357
+ if let breaking = response.value?.breaking {
358
+ latest.breaking = breaking
359
+ }
310
360
  if let error = response.value?.error {
311
361
  latest.error = error
312
362
  }
@@ -322,10 +372,17 @@ import UIKit
322
372
  if let manifest = response.value?.manifest {
323
373
  latest.manifest = manifest
324
374
  }
375
+ if let link = response.value?.link {
376
+ latest.link = link
377
+ }
378
+ if let comment = response.value?.comment {
379
+ latest.comment = comment
380
+ }
325
381
  case let .failure(error):
326
- print("\(CapacitorUpdater.TAG) Error getting Latest", response.value ?? "", error )
382
+ self.logger.error("Error getting Latest \(response.value.debugDescription) \(error)")
327
383
  latest.message = "Error getting Latest \(String(describing: response.value))"
328
384
  latest.error = "response_error"
385
+ latest.statusCode = response.response?.statusCode ?? 0
329
386
  }
330
387
  semaphore.signal()
331
388
  }
@@ -336,7 +393,7 @@ import UIKit
336
393
  private func setCurrentBundle(bundle: String) {
337
394
  UserDefaults.standard.set(bundle, forKey: self.CAP_SERVER_PATH)
338
395
  UserDefaults.standard.synchronize()
339
- print("\(CapacitorUpdater.TAG) Current bundle set to: \((bundle ).isEmpty ? BundleInfo.ID_BUILTIN : bundle)")
396
+ logger.info("Current bundle set to: \((bundle ).isEmpty ? BundleInfo.ID_BUILTIN : bundle)")
340
397
  }
341
398
 
342
399
  private var tempDataPath: URL {
@@ -347,36 +404,15 @@ import UIKit
347
404
  return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("update.dat")
348
405
  }
349
406
  private var tempData = Data()
350
-
407
+
351
408
  private func verifyChecksum(file: URL, expectedHash: String) -> Bool {
352
- let actualHash = CryptoCipherV2.calcChecksum(filePath: file)
409
+ let actualHash = CryptoCipher.calcChecksum(filePath: file)
353
410
  return actualHash == expectedHash
354
411
  }
355
412
 
356
- public func decryptChecksum(checksum: String, version: String) throws -> String {
357
- if self.publicKey.isEmpty {
358
- return checksum
359
- }
360
- do {
361
- let checksumBytes: Data = Data(base64Encoded: checksum)!
362
- guard let rsaPublicKey: RSAPublicKey = .load(rsaPublicKey: self.publicKey) else {
363
- print("cannot decode publicKey", self.publicKey)
364
- throw CustomError.cannotDecode
365
- }
366
- guard let decryptedChecksum = rsaPublicKey.decrypt(data: checksumBytes) else {
367
- throw NSError(domain: "Failed to decrypt session key data", code: 2, userInfo: nil)
368
- }
369
- return decryptedChecksum.base64EncodedString()
370
- } catch {
371
- print("\(CapacitorUpdater.TAG) Cannot decrypt checksum: \(checksum)", error)
372
- self.sendStats(action: "decrypt_fail", versionName: version)
373
- throw CustomError.cannotDecode
374
- }
375
- }
376
-
377
- public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String) throws -> BundleInfo {
413
+ public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
378
414
  let id = self.randomString(length: 10)
379
- print("\(CapacitorUpdater.TAG) downloadManifest start \(id)")
415
+ logger.info("downloadManifest start \(id)")
380
416
  let destFolder = self.getBundleDirectory(id: id)
381
417
  let builtinFolder = Bundle.main.bundleURL.appendingPathComponent("public")
382
418
 
@@ -384,9 +420,12 @@ import UIKit
384
420
  try FileManager.default.createDirectory(at: destFolder, withIntermediateDirectories: true, attributes: nil)
385
421
 
386
422
  // Create and save BundleInfo before starting the download process
387
- let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: "")
423
+ let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: "", link: link, comment: comment)
388
424
  self.saveBundleInfo(id: id, bundle: bundleInfo)
389
425
 
426
+ // Send stats for manifest download start
427
+ self.sendStats(action: "download_manifest_start", versionName: version)
428
+
390
429
  // Notify the start of the download process
391
430
  self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
392
431
 
@@ -405,17 +444,23 @@ import UIKit
405
444
 
406
445
  if !self.publicKey.isEmpty && !sessionKey.isEmpty {
407
446
  do {
408
- fileHash = try CryptoCipherV2.decryptChecksum(checksum: fileHash, publicKey: self.publicKey, version: version)
447
+ fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
409
448
  } catch {
410
449
  downloadError = error
411
- print("\(CapacitorUpdater.TAG) CryptoCipherV2.decryptChecksum error \(id) \(fileName) error: \(error)")
450
+ logger.error("CryptoCipher.decryptChecksum error \(id) \(fileName) error: \(error)")
412
451
  }
413
452
  }
414
453
 
454
+ // Check if file has .br extension for Brotli decompression
415
455
  let fileNameWithoutPath = (fileName as NSString).lastPathComponent
416
456
  let cacheFileName = "\(fileHash)_\(fileNameWithoutPath)"
417
457
  let cacheFilePath = cacheFolder.appendingPathComponent(cacheFileName)
418
- let destFilePath = destFolder.appendingPathComponent(fileName)
458
+
459
+ // Check if file is Brotli compressed and remove .br extension from destination
460
+ let isBrotli = fileName.hasSuffix(".br")
461
+ let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
462
+
463
+ let destFilePath = destFolder.appendingPathComponent(destFileName)
419
464
  let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
420
465
 
421
466
  // Create necessary subdirectories in the destination folder
@@ -424,20 +469,30 @@ import UIKit
424
469
  dispatchGroup.enter()
425
470
 
426
471
  if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
427
- try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
428
- print("\(CapacitorUpdater.TAG) downloadManifest \(fileName) using builtin file \(id)")
429
- completedFiles += 1
430
- self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
472
+ do {
473
+ try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
474
+ logger.info("downloadManifest \(fileName) using builtin file \(id)")
475
+ completedFiles += 1
476
+ self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
477
+ } catch {
478
+ downloadError = error
479
+ logger.error("Failed to copy builtin file \(fileName): \(error.localizedDescription)")
480
+ }
431
481
  dispatchGroup.leave()
432
482
  } else if FileManager.default.fileExists(atPath: cacheFilePath.path) && verifyChecksum(file: cacheFilePath, expectedHash: fileHash) {
433
- try FileManager.default.copyItem(at: cacheFilePath, to: destFilePath)
434
- print("\(CapacitorUpdater.TAG) downloadManifest \(fileName) copy from cache \(id)")
435
- completedFiles += 1
436
- self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
483
+ do {
484
+ try FileManager.default.copyItem(at: cacheFilePath, to: destFilePath)
485
+ logger.info("downloadManifest \(fileName) copy from cache \(id)")
486
+ completedFiles += 1
487
+ self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
488
+ } catch {
489
+ downloadError = error
490
+ logger.error("Failed to copy cached file \(fileName): \(error.localizedDescription)")
491
+ }
437
492
  dispatchGroup.leave()
438
493
  } else {
439
494
  // File not in cache, download, decompress, and save to both cache and destination
440
- AF.download(downloadUrl).responseData { response in
495
+ self.alamofireSession.download(downloadUrl).responseData { response in
441
496
  defer { dispatchGroup.leave() }
442
497
 
443
498
  switch response.result {
@@ -445,10 +500,11 @@ import UIKit
445
500
  do {
446
501
  let statusCode = response.response?.statusCode ?? 200
447
502
  if statusCode < 200 || statusCode >= 300 {
503
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
448
504
  if let stringData = String(data: data, encoding: .utf8) {
449
- throw NSError(domain: "StatusCodeError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid. Data: \(stringData)"])
505
+ throw NSError(domain: "StatusCodeError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid. Data: \(stringData) for file \(fileName) at url \(downloadUrl)"])
450
506
  } else {
451
- throw NSError(domain: "StatusCodeError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid"])
507
+ throw NSError(domain: "StatusCodeError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
452
508
  }
453
509
  }
454
510
 
@@ -458,7 +514,7 @@ import UIKit
458
514
  let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
459
515
  try finalData.write(to: tempFile)
460
516
  do {
461
- try CryptoCipherV2.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
517
+ try CryptoCipher.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
462
518
  } catch {
463
519
  self.sendStats(action: "decrypt_fail", versionName: version)
464
520
  throw error
@@ -468,18 +524,27 @@ import UIKit
468
524
  try FileManager.default.removeItem(at: tempFile)
469
525
  }
470
526
 
471
- // Decompress the Brotli data
472
- guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
473
- throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data"])
527
+ // Use the isBrotli and destFilePath already computed above
528
+ if isBrotli {
529
+ // Decompress the Brotli data
530
+ guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
531
+ self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
532
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
533
+ }
534
+ finalData = decompressedData
474
535
  }
475
- finalData = decompressedData
476
536
 
477
537
  try finalData.write(to: destFilePath)
478
538
  if !self.publicKey.isEmpty && !sessionKey.isEmpty {
479
539
  // assume that calcChecksum != null
480
- let calculatedChecksum = CryptoCipherV2.calcChecksum(filePath: destFilePath)
540
+ let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
541
+ CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
542
+ CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
481
543
  if calculatedChecksum != fileHash {
482
- throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash))"])
544
+ // Delete the corrupt file before throwing error
545
+ try? FileManager.default.removeItem(at: destFilePath)
546
+ self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
547
+ throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
483
548
  }
484
549
  }
485
550
 
@@ -488,13 +553,15 @@ import UIKit
488
553
 
489
554
  completedFiles += 1
490
555
  self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
491
- print("\(CapacitorUpdater.TAG) downloadManifest \(id) \(fileName) downloaded, decompressed\(!self.publicKey.isEmpty && !sessionKey.isEmpty ? ", decrypted" : ""), and cached")
556
+ self.logger.info("downloadManifest \(id) \(fileName) downloaded\(isBrotli ? ", decompressed" : "")\(!self.publicKey.isEmpty && !sessionKey.isEmpty ? ", decrypted" : ""), and cached")
492
557
  } catch {
493
558
  downloadError = error
494
- print("\(CapacitorUpdater.TAG) downloadManifest \(id) \(fileName) error: \(error)")
559
+ self.logger.error("downloadManifest \(id) \(fileName) error: \(error.localizedDescription)")
495
560
  }
496
561
  case .failure(let error):
497
- print("\(CapacitorUpdater.TAG) downloadManifest \(id) \(fileName) download error: \(error). Debug response: \(response.debugDescription).")
562
+ downloadError = error
563
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
564
+ self.logger.error("downloadManifest \(id) \(fileName) download error: \(error.localizedDescription). Debug response: \(response.debugDescription).")
498
565
  }
499
566
  }
500
567
  }
@@ -513,19 +580,52 @@ import UIKit
513
580
  let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.localizedString)
514
581
  self.saveBundleInfo(id: id, bundle: updatedBundle)
515
582
 
516
- print("\(CapacitorUpdater.TAG) downloadManifest done \(id)")
583
+ // Send stats for manifest download complete
584
+ self.sendStats(action: "download_manifest_complete", versionName: version)
585
+
586
+ self.notifyDownload(id: id, percent: 100, bundle: updatedBundle)
587
+ logger.info("downloadManifest done \(id)")
517
588
  return updatedBundle
518
589
  }
519
590
 
520
591
  private func decompressBrotli(data: Data, fileName: String) -> Data? {
592
+ // Handle empty files
593
+ if data.count == 0 {
594
+ return data
595
+ }
596
+
597
+ // Handle the special EMPTY_BROTLI_STREAM case
598
+ if data.count == 3 && data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 {
599
+ return Data()
600
+ }
601
+
602
+ // For small files, check if it's a minimal Brotli wrapper
603
+ if data.count > 3 {
604
+ let maxBytes = min(32, data.count)
605
+ let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
606
+ // Handle our minimal wrapper pattern
607
+ if data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 && data.last == 0x03 {
608
+ let range = data.index(data.startIndex, offsetBy: 3)..<data.index(data.endIndex, offsetBy: -1)
609
+ return data[range]
610
+ }
611
+
612
+ // Handle brotli.compress minimal wrapper (quality 0)
613
+ if data[0] == 0x0b && data[1] == 0x02 && data[2] == 0x80 && data.last == 0x03 {
614
+ let range = data.index(data.startIndex, offsetBy: 3)..<data.index(data.endIndex, offsetBy: -1)
615
+ return data[range]
616
+ }
617
+ }
618
+
619
+ // For all other cases, try standard decompression
521
620
  let outputBufferSize = 65536
522
621
  var outputBuffer = [UInt8](repeating: 0, count: outputBufferSize)
523
622
  var decompressedData = Data()
524
623
 
525
624
  let streamPointer = UnsafeMutablePointer<compression_stream>.allocate(capacity: 1)
526
625
  var status = compression_stream_init(streamPointer, COMPRESSION_STREAM_DECODE, COMPRESSION_BROTLI)
626
+
527
627
  guard status != COMPRESSION_STATUS_ERROR else {
528
- print("\(CapacitorUpdater.TAG) Unable to initialize the decompression stream. \(fileName)")
628
+ logger.error("Error: Failed to initialize Brotli stream for \(fileName). Status: \(status)")
529
629
  return nil
530
630
  }
531
631
 
@@ -547,7 +647,7 @@ import UIKit
547
647
  if let baseAddress = rawBufferPointer.baseAddress {
548
648
  streamPointer.pointee.src_ptr = baseAddress.assumingMemoryBound(to: UInt8.self)
549
649
  } else {
550
- print("\(CapacitorUpdater.TAG) Error: Unable to get base address of input data. \(fileName)")
650
+ logger.error("Error: Failed to get base address for \(fileName)")
551
651
  status = COMPRESSION_STATUS_ERROR
552
652
  return
553
653
  }
@@ -555,6 +655,9 @@ import UIKit
555
655
  }
556
656
 
557
657
  if status == COMPRESSION_STATUS_ERROR {
658
+ let maxBytes = min(32, data.count)
659
+ let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
660
+ logger.error("Error: Brotli decompression failed for \(fileName). First \(maxBytes) bytes: \(hexDump)")
558
661
  break
559
662
  }
560
663
 
@@ -568,15 +671,19 @@ import UIKit
568
671
  if status == COMPRESSION_STATUS_END {
569
672
  break
570
673
  } else if status == COMPRESSION_STATUS_ERROR {
571
- print("\(CapacitorUpdater.TAG) Error during Brotli decompression. \(fileName)")
572
- // Try to decode as text if mostly ASCII
674
+ logger.error("Error: Brotli process failed for \(fileName). Status: \(status)")
573
675
  if let text = String(data: data, encoding: .utf8) {
574
676
  let asciiCount = text.unicodeScalars.filter { $0.isASCII }.count
575
677
  let totalCount = text.unicodeScalars.count
576
678
  if totalCount > 0 && Double(asciiCount) / Double(totalCount) >= 0.8 {
577
- print("\(CapacitorUpdater.TAG) Compressed data as text: \(text)")
679
+ logger.error("Error: Input appears to be plain text: \(text)")
578
680
  }
579
681
  }
682
+
683
+ let maxBytes = min(32, data.count)
684
+ let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
685
+ logger.error("Error: Raw data (\(fileName)): \(hexDump)")
686
+
580
687
  return nil
581
688
  }
582
689
 
@@ -586,6 +693,7 @@ import UIKit
586
693
  }
587
694
 
588
695
  if input.count == 0 {
696
+ logger.error("Error: Zero input size for \(fileName)")
589
697
  break
590
698
  }
591
699
  }
@@ -593,7 +701,7 @@ import UIKit
593
701
  return status == COMPRESSION_STATUS_END ? decompressedData : nil
594
702
  }
595
703
 
596
- public func download(url: URL, version: String, sessionKey: String) throws -> BundleInfo {
704
+ public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
597
705
  let id: String = self.randomString(length: 10)
598
706
  let semaphore = DispatchSemaphore(value: 0)
599
707
  if version != getLocalUpdateVersion() {
@@ -606,6 +714,10 @@ import UIKit
606
714
  var lastSentProgress = 0
607
715
  var totalReceivedBytes: Int64 = loadDownloadProgress() // Retrieving the amount of already downloaded data if exist, defined at 0 otherwise
608
716
  let requestHeaders: HTTPHeaders = ["Range": "bytes=\(totalReceivedBytes)-"]
717
+
718
+ // Send stats for zip download start
719
+ self.sendStats(action: "download_zip_start", versionName: version)
720
+
609
721
  // Opening connection for streaming the bytes
610
722
  if totalReceivedBytes == 0 {
611
723
  self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
@@ -614,11 +726,13 @@ import UIKit
614
726
  let monitor = ClosureEventMonitor()
615
727
  monitor.requestDidCompleteTaskWithError = { (_, _, error) in
616
728
  if error != nil {
617
- print("\(CapacitorUpdater.TAG) Downloading failed - ClosureEventMonitor activated")
729
+ self.logger.error("Downloading failed - ClosureEventMonitor activated")
618
730
  mainError = error as NSError?
619
731
  }
620
732
  }
621
- let session = Session(eventMonitors: [monitor])
733
+ let configuration = URLSessionConfiguration.default
734
+ configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
735
+ let session = Session(configuration: configuration, eventMonitors: [monitor])
622
736
 
623
737
  let request = session.streamRequest(url, headers: requestHeaders).validate().onHTTPResponse(perform: { response in
624
738
  if let contentLength = response.headers.value(for: "Content-Length") {
@@ -645,16 +759,16 @@ import UIKit
645
759
  }
646
760
 
647
761
  } else {
648
- print("\(CapacitorUpdater.TAG) Download failed")
762
+ self.logger.error("Download failed")
649
763
  }
650
764
 
651
765
  case .complete:
652
- print("\(CapacitorUpdater.TAG) Download complete, total received bytes: \(totalReceivedBytes)")
766
+ self.logger.info("Download complete, total received bytes: \(totalReceivedBytes)")
653
767
  self.notifyDownload(id: id, percent: 70, ignoreMultipleOfTen: true)
654
768
  semaphore.signal()
655
769
  }
656
770
  }
657
- self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum))
771
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum, link: link, comment: comment))
658
772
  let reachabilityManager = NetworkReachabilityManager()
659
773
  reachabilityManager?.startListening { status in
660
774
  switch status {
@@ -671,55 +785,67 @@ import UIKit
671
785
  reachabilityManager?.stopListening()
672
786
 
673
787
  if mainError != nil {
674
- print("\(CapacitorUpdater.TAG) Failed to download: \(String(describing: mainError))")
675
- self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
788
+ logger.error("Failed to download: \(String(describing: mainError))")
789
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
676
790
  throw mainError!
677
791
  }
678
792
 
679
793
  let finalPath = tempDataPath.deletingLastPathComponent().appendingPathComponent("\(id)")
680
794
  do {
681
- try self.decryptFileV2(filePath: tempDataPath, sessionKey: sessionKey, version: version)
795
+ try CryptoCipher.decryptFile(filePath: tempDataPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
682
796
  try FileManager.default.moveItem(at: tempDataPath, to: finalPath)
683
797
  } catch {
684
- print("\(CapacitorUpdater.TAG) Failed decrypt file : \(error)")
685
- self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
798
+ logger.error("Failed decrypt file : \(error)")
799
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
686
800
  cleanDownloadData()
687
801
  throw error
688
802
  }
689
803
 
690
804
  do {
691
- checksum = CryptoCipherV2.calcChecksum(filePath: finalPath)
692
- print("\(CapacitorUpdater.TAG) Downloading: 80% (unzipping)")
805
+ checksum = CryptoCipher.calcChecksum(filePath: finalPath)
806
+ CryptoCipher.logChecksumInfo(label: "Calculated bundle checksum", hexChecksum: checksum)
807
+ logger.info("Downloading: 80% (unzipping)")
693
808
  try self.saveDownloaded(sourceZip: finalPath, id: id, base: self.libraryDir.appendingPathComponent(self.bundleDirectory), notify: true)
694
809
 
695
810
  } catch {
696
- print("\(CapacitorUpdater.TAG) Failed to unzip file: \(error)")
697
- self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
811
+ logger.error("Failed to unzip file: \(error)")
812
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
813
+ // Best-effort cleanup of the decrypted zip file when unzip fails
814
+ do {
815
+ if FileManager.default.fileExists(atPath: finalPath.path) {
816
+ try FileManager.default.removeItem(at: finalPath)
817
+ }
818
+ } catch {
819
+ logger.error("Could not delete failed zip at \(finalPath.path): \(error)")
820
+ }
698
821
  cleanDownloadData()
699
- // todo: cleanup zip attempts
700
822
  throw error
701
823
  }
702
824
 
703
825
  self.notifyDownload(id: id, percent: 90)
704
- print("\(CapacitorUpdater.TAG) Downloading: 90% (wrapping up)")
705
- let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum)
826
+ logger.info("Downloading: 90% (wrapping up)")
827
+ let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum, link: link, comment: comment)
706
828
  self.saveBundleInfo(id: id, bundle: info)
707
829
  self.cleanDownloadData()
708
- self.notifyDownload(id: id, percent: 100)
709
- print("\(CapacitorUpdater.TAG) Downloading: 100% (complete)")
830
+
831
+ // Send stats for zip download complete
832
+ self.sendStats(action: "download_zip_complete", versionName: version)
833
+
834
+ self.notifyDownload(id: id, percent: 100, bundle: info)
835
+ logger.info("Downloading: 100% (complete)")
710
836
  return info
711
837
  }
712
838
  private func ensureResumableFilesExist() {
713
839
  let fileManager = FileManager.default
714
840
  if !fileManager.fileExists(atPath: tempDataPath.path) {
715
841
  if !fileManager.createFile(atPath: tempDataPath.path, contents: Data()) {
716
- print("\(CapacitorUpdater.TAG) Cannot ensure that a file at \(tempDataPath.path) exists")
842
+ logger.error("Cannot ensure that a file at \(tempDataPath.path) exists")
717
843
  }
718
844
  }
719
845
 
720
846
  if !fileManager.fileExists(atPath: updateInfo.path) {
721
847
  if !fileManager.createFile(atPath: updateInfo.path, contents: Data()) {
722
- print("\(CapacitorUpdater.TAG) Cannot ensure that a file at \(updateInfo.path) exists")
848
+ logger.error("Cannot ensure that a file at \(updateInfo.path) exists")
723
849
  }
724
850
  }
725
851
  }
@@ -731,7 +857,7 @@ import UIKit
731
857
  do {
732
858
  try fileManager.removeItem(at: tempDataPath)
733
859
  } catch {
734
- print("\(CapacitorUpdater.TAG) Could not delete file at \(tempDataPath): \(error)")
860
+ logger.error("Could not delete file at \(tempDataPath): \(error)")
735
861
  }
736
862
  }
737
863
  // Deleting update.dat
@@ -739,7 +865,7 @@ import UIKit
739
865
  do {
740
866
  try fileManager.removeItem(at: updateInfo)
741
867
  } catch {
742
- print("\(CapacitorUpdater.TAG) Could not delete file at \(updateInfo): \(error)")
868
+ logger.error("Could not delete file at \(updateInfo): \(error)")
743
869
  }
744
870
  }
745
871
  }
@@ -758,7 +884,7 @@ import UIKit
758
884
  fileHandle.closeFile()
759
885
  }
760
886
  } catch {
761
- print("Failed to write data starting at byte \(byteOffset): \(error)")
887
+ logger.error("Failed to write data starting at byte \(byteOffset): \(error)")
762
888
  }
763
889
  self.tempData.removeAll() // Clearing tempData to avoid writing the same data multiple times
764
890
  }
@@ -767,7 +893,7 @@ import UIKit
767
893
  do {
768
894
  try "\(version)".write(to: updateInfo, atomically: true, encoding: .utf8)
769
895
  } catch {
770
- print("\(CapacitorUpdater.TAG) Failed to save progress: \(error)")
896
+ logger.error("Failed to save progress: \(error)")
771
897
  }
772
898
  }
773
899
  private func getLocalUpdateVersion() -> String { // Return the version that was tried to be downloaded on last download attempt
@@ -789,7 +915,7 @@ import UIKit
789
915
  return fileSize.int64Value
790
916
  }
791
917
  } catch {
792
- print("\(CapacitorUpdater.TAG) Could not retrieve already downloaded data size : \(error)")
918
+ logger.error("Could not retrieve already downloaded data size : \(error)")
793
919
  }
794
920
  return 0
795
921
  }
@@ -801,7 +927,7 @@ import UIKit
801
927
  do {
802
928
  let files: [String] = try FileManager.default.contentsOfDirectory(atPath: dest.path)
803
929
  var res: [BundleInfo] = []
804
- print("\(CapacitorUpdater.TAG) list File : \(dest.path)")
930
+ logger.info("list File : \(dest.path)")
805
931
  if dest.exist {
806
932
  for id: String in files {
807
933
  res.append(self.getBundleInfo(id: id))
@@ -809,12 +935,12 @@ import UIKit
809
935
  }
810
936
  return res
811
937
  } catch {
812
- print("\(CapacitorUpdater.TAG) No version available \(dest.path)")
938
+ logger.info("No version available \(dest.path)")
813
939
  return []
814
940
  }
815
941
  } else {
816
942
  guard let regex = try? NSRegularExpression(pattern: "^[0-9A-Za-z]{10}_info$") else {
817
- print("\(CapacitorUpdater.TAG) Invald regex ?????")
943
+ logger.error("Invalid regex ?????")
818
944
  return []
819
945
  }
820
946
  return UserDefaults.standard.dictionaryRepresentation().keys.filter {
@@ -833,7 +959,7 @@ import UIKit
833
959
  public func delete(id: String, removeInfo: Bool) -> Bool {
834
960
  let deleted: BundleInfo = self.getBundleInfo(id: id)
835
961
  if deleted.isBuiltin() || self.getCurrentBundleId() == id {
836
- print("\(CapacitorUpdater.TAG) Cannot delete \(id)")
962
+ logger.info("Cannot delete \(id)")
837
963
  return false
838
964
  }
839
965
 
@@ -842,7 +968,7 @@ import UIKit
842
968
  !next.isDeleted() &&
843
969
  !next.isErrorStatus() &&
844
970
  next.getId() == id {
845
- print("\(CapacitorUpdater.TAG) Cannot delete the next bundle \(id)")
971
+ logger.info("Cannot delete the next bundle \(id)")
846
972
  return false
847
973
  }
848
974
 
@@ -850,7 +976,7 @@ import UIKit
850
976
  do {
851
977
  try FileManager.default.removeItem(atPath: destPersist.path)
852
978
  } catch {
853
- print("\(CapacitorUpdater.TAG) Folder \(destPersist.path), not removed.")
979
+ logger.error("Folder \(destPersist.path), not removed.")
854
980
  // even if, we don;t care. Android doesn't care
855
981
  if removeInfo {
856
982
  self.removeBundleInfo(id: id)
@@ -863,7 +989,7 @@ import UIKit
863
989
  } else {
864
990
  self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.localizedString))
865
991
  }
866
- print("\(CapacitorUpdater.TAG) bundle delete \(deleted.getVersionName())")
992
+ logger.info("bundle delete \(deleted.getVersionName())")
867
993
  self.sendStats(action: "delete", versionName: deleted.getVersionName())
868
994
  return true
869
995
  }
@@ -872,6 +998,55 @@ import UIKit
872
998
  return self.delete(id: id, removeInfo: true)
873
999
  }
874
1000
 
1001
+ public func cleanupDeltaCache() {
1002
+ let fileManager = FileManager.default
1003
+ guard fileManager.fileExists(atPath: cacheFolder.path) else {
1004
+ return
1005
+ }
1006
+ do {
1007
+ try fileManager.removeItem(at: cacheFolder)
1008
+ logger.info("Cleaned up delta cache folder")
1009
+ } catch {
1010
+ logger.error("Failed to cleanup delta cache: \(error.localizedDescription)")
1011
+ }
1012
+ }
1013
+
1014
+ public func cleanupDownloadDirectories(allowedIds: Set<String>) {
1015
+ let bundleRoot = libraryDir.appendingPathComponent(bundleDirectory)
1016
+ let fileManager = FileManager.default
1017
+
1018
+ guard fileManager.fileExists(atPath: bundleRoot.path) else {
1019
+ return
1020
+ }
1021
+
1022
+ do {
1023
+ let contents = try fileManager.contentsOfDirectory(at: bundleRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
1024
+
1025
+ for url in contents {
1026
+ let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
1027
+ if resourceValues.isDirectory != true {
1028
+ continue
1029
+ }
1030
+
1031
+ let id = url.lastPathComponent
1032
+
1033
+ if allowedIds.contains(id) {
1034
+ continue
1035
+ }
1036
+
1037
+ do {
1038
+ try fileManager.removeItem(at: url)
1039
+ self.removeBundleInfo(id: id)
1040
+ logger.info("Deleted orphan bundle directory: \(id)")
1041
+ } catch {
1042
+ logger.error("Failed to delete orphan bundle directory: \(id) \(error.localizedDescription)")
1043
+ }
1044
+ }
1045
+ } catch {
1046
+ logger.error("Failed to enumerate bundle directory for cleanup: \(error.localizedDescription)")
1047
+ }
1048
+ }
1049
+
875
1050
  public func getBundleDirectory(id: String) -> URL {
876
1051
  return libraryDir.appendingPathComponent(self.bundleDirectory).appendingPathComponent(id)
877
1052
  }
@@ -916,7 +1091,7 @@ import UIKit
916
1091
  public func autoReset() {
917
1092
  let currentBundle: BundleInfo = self.getCurrentBundle()
918
1093
  if !currentBundle.isBuiltin() && !self.bundleExists(id: currentBundle.getId()) {
919
- print("\(CapacitorUpdater.TAG) Folder at bundle path does not exist. Triggering reset.")
1094
+ logger.info("Folder at bundle path does not exist. Triggering reset.")
920
1095
  self.reset()
921
1096
  }
922
1097
  }
@@ -926,7 +1101,7 @@ import UIKit
926
1101
  }
927
1102
 
928
1103
  public func reset(isInternal: Bool) {
929
- print("\(CapacitorUpdater.TAG) reset: \(isInternal)")
1104
+ logger.info("reset: \(isInternal)")
930
1105
  let currentBundleName = self.getCurrentBundle().getVersionName()
931
1106
  self.setCurrentBundle(bundle: "")
932
1107
  self.setFallbackBundle(fallback: Optional<BundleInfo>.none)
@@ -939,14 +1114,14 @@ import UIKit
939
1114
  public func setSuccess(bundle: BundleInfo, autoDeletePrevious: Bool) {
940
1115
  self.setBundleStatus(id: bundle.getId(), status: BundleStatus.SUCCESS)
941
1116
  let fallback: BundleInfo = self.getFallbackBundle()
942
- print("\(CapacitorUpdater.TAG) Fallback bundle is: \(fallback.toString())")
943
- print("\(CapacitorUpdater.TAG) Version successfully loaded: \(bundle.toString())")
1117
+ logger.info("Fallback bundle is: \(fallback.toString())")
1118
+ logger.info("Version successfully loaded: \(bundle.toString())")
944
1119
  if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() {
945
1120
  let res = self.delete(id: fallback.getId())
946
1121
  if res {
947
- print("\(CapacitorUpdater.TAG) Deleted previous bundle: \(fallback.toString())")
1122
+ logger.info("Deleted previous bundle: \(fallback.toString())")
948
1123
  } else {
949
- print("\(CapacitorUpdater.TAG) Failed to delete previous bundle: \(fallback.toString())")
1124
+ logger.error("Failed to delete previous bundle: \(fallback.toString())")
950
1125
  }
951
1126
  }
952
1127
  self.setFallbackBundle(fallback: bundle)
@@ -956,46 +1131,41 @@ import UIKit
956
1131
  self.setBundleStatus(id: bundle.getId(), status: BundleStatus.ERROR)
957
1132
  }
958
1133
 
959
- func unsetChannel() -> SetChannel {
1134
+ func unsetChannel(defaultChannelKey: String, configDefaultChannel: String) -> SetChannel {
960
1135
  let setChannel: SetChannel = SetChannel()
961
- if (self.channelUrl ).isEmpty {
962
- print("\(CapacitorUpdater.TAG) Channel URL is not set")
963
- setChannel.message = "Channel URL is not set"
964
- setChannel.error = "missing_config"
965
- return setChannel
966
- }
967
- let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
968
- let parameters: InfoObject = self.createInfoObject()
969
1136
 
970
- let request = AF.request(self.channelUrl, method: .delete, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1137
+ // Clear persisted defaultChannel and revert to config value
1138
+ UserDefaults.standard.removeObject(forKey: defaultChannelKey)
1139
+ UserDefaults.standard.synchronize()
1140
+ self.defaultChannel = configDefaultChannel
1141
+ self.logger.info("Persisted defaultChannel cleared, reverted to config value: \(configDefaultChannel)")
971
1142
 
972
- request.validate().responseDecodable(of: SetChannelDec.self) { response in
973
- switch response.result {
974
- case .success:
975
- if let status = response.value?.status {
976
- setChannel.status = status
977
- }
978
- if let error = response.value?.error {
979
- setChannel.error = error
980
- }
981
- if let message = response.value?.message {
982
- setChannel.message = message
983
- }
984
- case let .failure(error):
985
- print("\(CapacitorUpdater.TAG) Error unset Channel", response.value ?? "", error)
986
- setChannel.message = "Error unset Channel \(String(describing: response.value))"
987
- setChannel.error = "response_error"
988
- }
989
- semaphore.signal()
990
- }
991
- semaphore.wait()
1143
+ setChannel.status = "ok"
1144
+ setChannel.message = "Channel override removed"
992
1145
  return setChannel
993
1146
  }
994
1147
 
995
- func setChannel(channel: String) -> SetChannel {
1148
+ func setChannel(channel: String, defaultChannelKey: String, allowSetDefaultChannel: Bool) -> SetChannel {
996
1149
  let setChannel: SetChannel = SetChannel()
1150
+
1151
+ // Check if setting defaultChannel is allowed
1152
+ if !allowSetDefaultChannel {
1153
+ logger.error("setChannel is disabled by allowSetDefaultChannel config")
1154
+ setChannel.message = "setChannel is disabled by configuration"
1155
+ setChannel.error = "disabled_by_config"
1156
+ return setChannel
1157
+ }
1158
+
1159
+ // Check if rate limit was exceeded
1160
+ if CapgoUpdater.rateLimitExceeded {
1161
+ logger.debug("Skipping setChannel due to rate limit (429). Requests will resume after app restart.")
1162
+ setChannel.message = "Rate limit exceeded"
1163
+ setChannel.error = "rate_limit_exceeded"
1164
+ return setChannel
1165
+ }
1166
+
997
1167
  if (self.channelUrl ).isEmpty {
998
- print("\(CapacitorUpdater.TAG) Channel URL is not set")
1168
+ logger.error("Channel URL is not set")
999
1169
  setChannel.message = "Channel URL is not set"
1000
1170
  setChannel.error = "missing_config"
1001
1171
  return setChannel
@@ -1004,24 +1174,36 @@ import UIKit
1004
1174
  var parameters: InfoObject = self.createInfoObject()
1005
1175
  parameters.channel = channel
1006
1176
 
1007
- let request = AF.request(self.channelUrl, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1177
+ let request = alamofireSession.request(self.channelUrl, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1008
1178
 
1009
1179
  request.validate().responseDecodable(of: SetChannelDec.self) { response in
1180
+ // Check for 429 rate limit
1181
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1182
+ setChannel.message = "Rate limit exceeded"
1183
+ setChannel.error = "rate_limit_exceeded"
1184
+ semaphore.signal()
1185
+ return
1186
+ }
1187
+
1010
1188
  switch response.result {
1011
1189
  case .success:
1012
- if let status = response.value?.status {
1013
- setChannel.status = status
1014
- }
1015
- if let error = response.value?.error {
1016
- setChannel.error = error
1017
- }
1018
- if let message = response.value?.message {
1019
- setChannel.message = message
1190
+ if let responseValue = response.value {
1191
+ if let error = responseValue.error {
1192
+ setChannel.error = error
1193
+ } else {
1194
+ // Success - persist defaultChannel
1195
+ self.defaultChannel = channel
1196
+ UserDefaults.standard.set(channel, forKey: defaultChannelKey)
1197
+ UserDefaults.standard.synchronize()
1198
+ self.logger.info("defaultChannel persisted locally: \(channel)")
1199
+
1200
+ setChannel.status = responseValue.status ?? ""
1201
+ setChannel.message = responseValue.message ?? ""
1202
+ }
1020
1203
  }
1021
1204
  case let .failure(error):
1022
- print("\(CapacitorUpdater.TAG) Error set Channel", response.value ?? "", error)
1023
- setChannel.message = "Error set Channel \(String(describing: response.value))"
1024
- setChannel.error = "response_error"
1205
+ self.logger.error("Error set Channel \(error)")
1206
+ setChannel.error = "Request failed: \(error.localizedDescription)"
1025
1207
  }
1026
1208
  semaphore.signal()
1027
1209
  }
@@ -1031,36 +1213,48 @@ import UIKit
1031
1213
 
1032
1214
  func getChannel() -> GetChannel {
1033
1215
  let getChannel: GetChannel = GetChannel()
1216
+
1217
+ // Check if rate limit was exceeded
1218
+ if CapgoUpdater.rateLimitExceeded {
1219
+ logger.debug("Skipping getChannel due to rate limit (429). Requests will resume after app restart.")
1220
+ getChannel.message = "Rate limit exceeded"
1221
+ getChannel.error = "rate_limit_exceeded"
1222
+ return getChannel
1223
+ }
1224
+
1034
1225
  if (self.channelUrl ).isEmpty {
1035
- print("\(CapacitorUpdater.TAG) Channel URL is not set")
1226
+ logger.error("Channel URL is not set")
1036
1227
  getChannel.message = "Channel URL is not set"
1037
1228
  getChannel.error = "missing_config"
1038
1229
  return getChannel
1039
1230
  }
1040
1231
  let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
1041
1232
  let parameters: InfoObject = self.createInfoObject()
1042
- let request = AF.request(self.channelUrl, method: .put, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1233
+ let request = alamofireSession.request(self.channelUrl, method: .put, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1043
1234
 
1044
1235
  request.validate().responseDecodable(of: GetChannelDec.self) { response in
1045
1236
  defer {
1046
1237
  semaphore.signal()
1047
1238
  }
1239
+
1240
+ // Check for 429 rate limit
1241
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1242
+ getChannel.message = "Rate limit exceeded"
1243
+ getChannel.error = "rate_limit_exceeded"
1244
+ return
1245
+ }
1246
+
1048
1247
  switch response.result {
1049
1248
  case .success:
1050
- if let status = response.value?.status {
1051
- getChannel.status = status
1052
- }
1053
- if let error = response.value?.error {
1054
- getChannel.error = error
1055
- }
1056
- if let message = response.value?.message {
1057
- getChannel.message = message
1058
- }
1059
- if let channel = response.value?.channel {
1060
- getChannel.channel = channel
1061
- }
1062
- if let allowSet = response.value?.allowSet {
1063
- getChannel.allowSet = allowSet
1249
+ if let responseValue = response.value {
1250
+ if let error = responseValue.error {
1251
+ getChannel.error = error
1252
+ } else {
1253
+ getChannel.status = responseValue.status ?? ""
1254
+ getChannel.message = responseValue.message ?? ""
1255
+ getChannel.channel = responseValue.channel ?? ""
1256
+ getChannel.allowSet = responseValue.allowSet ?? true
1257
+ }
1064
1258
  }
1065
1259
  case let .failure(error):
1066
1260
  if let data = response.data, let bodyString = String(data: data, encoding: .utf8) {
@@ -1071,18 +1265,113 @@ import UIKit
1071
1265
  }
1072
1266
  }
1073
1267
 
1074
- print("\(CapacitorUpdater.TAG) Error get Channel", response.value ?? "", error)
1075
- getChannel.message = "Error get Channel \(String(describing: response.value)))"
1076
- getChannel.error = "response_error"
1268
+ self.logger.error("Error get Channel \(error)")
1269
+ getChannel.error = "Request failed: \(error.localizedDescription)"
1077
1270
  }
1078
1271
  }
1079
1272
  semaphore.wait()
1080
1273
  return getChannel
1081
1274
  }
1082
1275
 
1276
+ func listChannels() -> ListChannels {
1277
+ let listChannels: ListChannels = ListChannels()
1278
+
1279
+ // Check if rate limit was exceeded
1280
+ if CapgoUpdater.rateLimitExceeded {
1281
+ logger.debug("Skipping listChannels due to rate limit (429). Requests will resume after app restart.")
1282
+ listChannels.error = "rate_limit_exceeded"
1283
+ return listChannels
1284
+ }
1285
+
1286
+ if (self.channelUrl).isEmpty {
1287
+ logger.error("Channel URL is not set")
1288
+ listChannels.error = "Channel URL is not set"
1289
+ return listChannels
1290
+ }
1291
+
1292
+ let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
1293
+
1294
+ // Create info object and convert to query parameters
1295
+ let infoObject = self.createInfoObject()
1296
+
1297
+ // Create query parameters from InfoObject
1298
+ var urlComponents = URLComponents(string: self.channelUrl)
1299
+ var queryItems: [URLQueryItem] = []
1300
+
1301
+ // Convert InfoObject to dictionary using Mirror
1302
+ let mirror = Mirror(reflecting: infoObject)
1303
+ for child in mirror.children {
1304
+ if let key = child.label, let value = child.value as? CustomStringConvertible {
1305
+ queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
1306
+ } else if let key = child.label {
1307
+ // Handle optional values
1308
+ let mirror = Mirror(reflecting: child.value)
1309
+ if let value = mirror.children.first?.value {
1310
+ queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
1311
+ }
1312
+ }
1313
+ }
1314
+
1315
+ urlComponents?.queryItems = queryItems
1316
+
1317
+ guard let url = urlComponents?.url else {
1318
+ logger.error("Invalid channel URL")
1319
+ listChannels.error = "Invalid channel URL"
1320
+ return listChannels
1321
+ }
1322
+
1323
+ let request = alamofireSession.request(url, method: .get, requestModifier: { $0.timeoutInterval = self.timeout })
1324
+
1325
+ request.validate().responseDecodable(of: ListChannelsDec.self) { response in
1326
+ defer {
1327
+ semaphore.signal()
1328
+ }
1329
+
1330
+ // Check for 429 rate limit
1331
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1332
+ listChannels.error = "rate_limit_exceeded"
1333
+ return
1334
+ }
1335
+
1336
+ switch response.result {
1337
+ case .success:
1338
+ if let responseValue = response.value {
1339
+ // Check for server-side errors
1340
+ if let error = responseValue.error {
1341
+ listChannels.error = error
1342
+ return
1343
+ }
1344
+
1345
+ // Backend returns direct array, so channels should be populated by our custom decoder
1346
+ if let channels = responseValue.channels {
1347
+ listChannels.channels = channels.map { channel in
1348
+ var channelDict: [String: Any] = [:]
1349
+ channelDict["id"] = channel.id ?? ""
1350
+ channelDict["name"] = channel.name ?? ""
1351
+ channelDict["public"] = channel.public ?? false
1352
+ channelDict["allow_self_set"] = channel.allow_self_set ?? false
1353
+ return channelDict
1354
+ }
1355
+ }
1356
+ }
1357
+ case let .failure(error):
1358
+ self.logger.error("Error list channels \(error)")
1359
+ listChannels.error = "Request failed: \(error.localizedDescription)"
1360
+ }
1361
+ }
1362
+ semaphore.wait()
1363
+ return listChannels
1364
+ }
1365
+
1083
1366
  private let operationQueue = OperationQueue()
1084
1367
 
1085
1368
  func sendStats(action: String, versionName: String? = nil, oldVersionName: String? = "") {
1369
+ // Check if rate limit was exceeded
1370
+ if CapgoUpdater.rateLimitExceeded {
1371
+ logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.")
1372
+ return
1373
+ }
1374
+
1086
1375
  guard !statsUrl.isEmpty else {
1087
1376
  return
1088
1377
  }
@@ -1097,22 +1386,28 @@ import UIKit
1097
1386
 
1098
1387
  let operation = BlockOperation {
1099
1388
  let semaphore = DispatchSemaphore(value: 0)
1100
- AF.request(
1389
+ self.alamofireSession.request(
1101
1390
  self.statsUrl,
1102
1391
  method: .post,
1103
1392
  parameters: parameters,
1104
1393
  encoder: JSONParameterEncoder.default,
1105
1394
  requestModifier: { $0.timeoutInterval = self.timeout }
1106
1395
  ).responseData { response in
1396
+ // Check for 429 rate limit
1397
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1398
+ semaphore.signal()
1399
+ return
1400
+ }
1401
+
1107
1402
  switch response.result {
1108
1403
  case .success:
1109
- print("\(CapacitorUpdater.TAG) Stats sent for \(action), version \(versionName)")
1404
+ self.logger.info("Stats sent for \(action), version \(versionName)")
1110
1405
  case let .failure(error):
1111
- print("\(CapacitorUpdater.TAG) Error sending stats: ", response.value ?? "", error.localizedDescription)
1406
+ self.logger.error("Error sending stats: \(response.value?.debugDescription ?? "") \(error.localizedDescription)")
1112
1407
  }
1113
1408
  semaphore.signal()
1114
1409
  }
1115
- semaphore.signal()
1410
+ semaphore.wait()
1116
1411
  }
1117
1412
  operationQueue.addOperation(operation)
1118
1413
 
@@ -1123,7 +1418,6 @@ import UIKit
1123
1418
  if id != nil {
1124
1419
  trueId = id!
1125
1420
  }
1126
- // print("\(CapacitorUpdater.TAG) Getting info for bundle [\(trueId)]")
1127
1421
  let result: BundleInfo
1128
1422
  if BundleInfo.ID_BUILTIN == trueId {
1129
1423
  result = BundleInfo(id: trueId, version: "", status: BundleStatus.SUCCESS, checksum: "")
@@ -1133,11 +1427,10 @@ import UIKit
1133
1427
  do {
1134
1428
  result = try UserDefaults.standard.getObj(forKey: "\(trueId)\(self.INFO_SUFFIX)", castTo: BundleInfo.self)
1135
1429
  } catch {
1136
- print("\(CapacitorUpdater.TAG) Failed to parse info for bundle [\(trueId)]", error.localizedDescription)
1430
+ logger.error("Failed to parse info for bundle [\(trueId)] \(error.localizedDescription)")
1137
1431
  result = BundleInfo(id: trueId, version: "", status: BundleStatus.PENDING, checksum: "")
1138
1432
  }
1139
1433
  }
1140
- // print("\(CapacitorUpdater.TAG) Returning info bundle [\(result.toString())]")
1141
1434
  return result
1142
1435
  }
1143
1436
 
@@ -1157,26 +1450,26 @@ import UIKit
1157
1450
 
1158
1451
  public func saveBundleInfo(id: String, bundle: BundleInfo?) {
1159
1452
  if bundle != nil && (bundle!.isBuiltin() || bundle!.isUnknown()) {
1160
- print("\(CapacitorUpdater.TAG) Not saving info for bundle [\(id)]", bundle?.toString() ?? "")
1453
+ logger.info("Not saving info for bundle [\(id)] \(bundle?.toString() ?? "")")
1161
1454
  return
1162
1455
  }
1163
1456
  if bundle == nil {
1164
- print("\(CapacitorUpdater.TAG) Removing info for bundle [\(id)]")
1457
+ logger.info("Removing info for bundle [\(id)]")
1165
1458
  UserDefaults.standard.removeObject(forKey: "\(id)\(self.INFO_SUFFIX)")
1166
1459
  } else {
1167
1460
  let update = bundle!.setId(id: id)
1168
- print("\(CapacitorUpdater.TAG) Storing info for bundle [\(id)]", update.toString())
1461
+ logger.info("Storing info for bundle [\(id)] \(update.toString())")
1169
1462
  do {
1170
1463
  try UserDefaults.standard.setObj(update, forKey: "\(id)\(self.INFO_SUFFIX)")
1171
1464
  } catch {
1172
- print("\(CapacitorUpdater.TAG) Failed to save info for bundle [\(id)]", error.localizedDescription)
1465
+ logger.error("Failed to save info for bundle [\(id)] \(error.localizedDescription)")
1173
1466
  }
1174
1467
  }
1175
1468
  UserDefaults.standard.synchronize()
1176
1469
  }
1177
1470
 
1178
1471
  private func setBundleStatus(id: String, status: BundleStatus) {
1179
- print("\(CapacitorUpdater.TAG) Setting status for bundle [\(id)] to \(status)")
1472
+ logger.info("Setting status for bundle [\(id)] to \(status)")
1180
1473
  let info = self.getBundleInfo(id: id)
1181
1474
  self.saveBundleInfo(id: id, bundle: info.setStatus(status: status.localizedString))
1182
1475
  }