@capgo/capacitor-updater 6.30.0 → 6.35.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.
@@ -16,21 +16,25 @@ import Foundation
16
16
  private let version: String
17
17
  private let checksum: String
18
18
  private let status: BundleStatus
19
+ private let link: String?
20
+ private let comment: String?
19
21
 
20
- convenience init(id: String, version: String, status: BundleStatus, downloaded: Date, checksum: String) {
21
- self.init(id: id, version: version, status: status, downloaded: downloaded.iso8601withFractionalSeconds, checksum: checksum)
22
+ convenience init(id: String, version: String, status: BundleStatus, downloaded: Date, checksum: String, link: String? = nil, comment: String? = nil) {
23
+ self.init(id: id, version: version, status: status, downloaded: downloaded.iso8601withFractionalSeconds, checksum: checksum, link: link, comment: comment)
22
24
  }
23
25
 
24
- init(id: String, version: String, status: BundleStatus, downloaded: String = BundleInfo.DOWNLOADED_BUILTIN, checksum: String) {
26
+ init(id: String, version: String, status: BundleStatus, downloaded: String = BundleInfo.DOWNLOADED_BUILTIN, checksum: String, link: String? = nil, comment: String? = nil) {
25
27
  self.downloaded = downloaded.trim()
26
28
  self.id = id
27
29
  self.version = version
28
30
  self.checksum = checksum
29
31
  self.status = status
32
+ self.link = link
33
+ self.comment = comment
30
34
  }
31
35
 
32
36
  enum CodingKeys: String, CodingKey {
33
- case downloaded, id, version, status, checksum
37
+ case downloaded, id, version, status, checksum, link, comment
34
38
  }
35
39
 
36
40
  public func isBuiltin() -> Bool {
@@ -62,11 +66,11 @@ import Foundation
62
66
  }
63
67
 
64
68
  public func setChecksum(checksum: String) -> BundleInfo {
65
- return BundleInfo(id: self.id, version: self.version, status: self.status, downloaded: self.downloaded, checksum: checksum)
69
+ return BundleInfo(id: self.id, version: self.version, status: self.status, downloaded: self.downloaded, checksum: checksum, link: self.link, comment: self.comment)
66
70
  }
67
71
 
68
72
  public func setDownloaded(downloaded: Date) -> BundleInfo {
69
- return BundleInfo(id: self.id, version: self.version, status: self.status, downloaded: downloaded, checksum: self.checksum)
73
+ return BundleInfo(id: self.id, version: self.version, status: self.status, downloaded: downloaded, checksum: self.checksum, link: self.link, comment: self.comment)
70
74
  }
71
75
 
72
76
  public func getId() -> String {
@@ -74,7 +78,7 @@ import Foundation
74
78
  }
75
79
 
76
80
  public func setId(id: String) -> BundleInfo {
77
- return BundleInfo(id: id, version: self.version, status: self.status, downloaded: self.downloaded, checksum: self.checksum)
81
+ return BundleInfo(id: id, version: self.version, status: self.status, downloaded: self.downloaded, checksum: self.checksum, link: self.link, comment: self.comment)
78
82
  }
79
83
 
80
84
  public func getVersionName() -> String {
@@ -82,7 +86,7 @@ import Foundation
82
86
  }
83
87
 
84
88
  public func setVersionName(version: String) -> BundleInfo {
85
- return BundleInfo(id: self.id, version: version, status: self.status, downloaded: self.downloaded, checksum: self.checksum)
89
+ return BundleInfo(id: self.id, version: version, status: self.status, downloaded: self.downloaded, checksum: self.checksum, link: self.link, comment: self.comment)
86
90
  }
87
91
 
88
92
  public func getStatus() -> String {
@@ -90,17 +94,40 @@ import Foundation
90
94
  }
91
95
 
92
96
  public func setStatus(status: String) -> BundleInfo {
93
- return BundleInfo(id: self.id, version: self.version, status: BundleStatus(localizedString: status)!, downloaded: self.downloaded, checksum: self.checksum)
97
+ return BundleInfo(id: self.id, version: self.version, status: BundleStatus(localizedString: status)!, downloaded: self.downloaded, checksum: self.checksum, link: self.link, comment: self.comment)
98
+ }
99
+
100
+ public func getLink() -> String? {
101
+ return self.link
102
+ }
103
+
104
+ public func setLink(link: String?) -> BundleInfo {
105
+ return BundleInfo(id: self.id, version: self.version, status: self.status, downloaded: self.downloaded, checksum: self.checksum, link: link, comment: self.comment)
106
+ }
107
+
108
+ public func getComment() -> String? {
109
+ return self.comment
110
+ }
111
+
112
+ public func setComment(comment: String?) -> BundleInfo {
113
+ return BundleInfo(id: self.id, version: self.version, status: self.status, downloaded: self.downloaded, checksum: self.checksum, link: self.link, comment: comment)
94
114
  }
95
115
 
96
116
  public func toJSON() -> [String: String] {
97
- return [
117
+ var result: [String: String] = [
98
118
  "id": self.getId(),
99
119
  "version": self.getVersionName(),
100
120
  "downloaded": self.getDownloaded(),
101
121
  "checksum": self.getChecksum(),
102
122
  "status": self.getStatus()
103
123
  ]
124
+ if let link = self.link {
125
+ result["link"] = link
126
+ }
127
+ if let comment = self.comment {
128
+ result["comment"] = comment
129
+ }
130
+ return result
104
131
  }
105
132
 
106
133
  public static func == (lhs: BundleInfo, rhs: BundleInfo) -> Bool {
@@ -54,7 +54,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
54
54
  CAPPluginMethod(name: "isShakeMenuEnabled", returnType: CAPPluginReturnPromise)
55
55
  ]
56
56
  public var implementation = CapgoUpdater()
57
- private let pluginVersion: String = "6.27.11"
57
+ private let pluginVersion: String = "6.35.0"
58
58
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
59
59
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
60
60
  static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
@@ -63,6 +63,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
63
63
  private let updateUrlDefaultsKey = "CapacitorUpdater.updateUrl"
64
64
  private let statsUrlDefaultsKey = "CapacitorUpdater.statsUrl"
65
65
  private let channelUrlDefaultsKey = "CapacitorUpdater.channelUrl"
66
+ private let defaultChannelDefaultsKey = "CapacitorUpdater.defaultChannel"
66
67
  private let lastFailedBundleDefaultsKey = "CapacitorUpdater.lastFailedBundle"
67
68
  // Note: DELAY_CONDITION_PREFERENCES is now defined in DelayUpdateUtils.DELAY_CONDITION_PREFERENCES
68
69
  private var updateUrl = ""
@@ -86,6 +87,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
86
87
  private var autoSplashscreenTimedOut = false
87
88
  private var autoDeleteFailed = false
88
89
  private var autoDeletePrevious = false
90
+ private var allowSetDefaultChannel = true
89
91
  private var keepUrlPathAfterReload = false
90
92
  private var backgroundWork: DispatchWorkItem?
91
93
  private var taskRunning = false
@@ -117,6 +119,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
117
119
  // Use DeviceIdHelper to get or create device ID that persists across reinstalls
118
120
  self.implementation.deviceID = DeviceIdHelper.getOrCreateDeviceId()
119
121
  persistCustomId = getConfig().getBoolean("persistCustomId", false)
122
+ allowSetDefaultChannel = getConfig().getBoolean("allowSetDefaultChannel", true)
120
123
  if persistCustomId {
121
124
  let storedCustomId = UserDefaults.standard.string(forKey: customIdDefaultsKey) ?? ""
122
125
  if !storedCustomId.isEmpty {
@@ -203,9 +206,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
203
206
 
204
207
  // Set logger for shared classes
205
208
  implementation.setLogger(logger)
206
- CryptoCipherV2.setLogger(logger)
207
- CryptoCipherV1.setLogger(logger)
208
- CryptoCipherV2.setLogger(logger)
209
+ CryptoCipher.setLogger(logger)
209
210
 
210
211
  // Initialize DelayUpdateUtils
211
212
  self.delayUpdateUtils = DelayUpdateUtils(currentVersionNative: currentVersionNative, logger: logger)
@@ -230,7 +231,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
230
231
  logger.info("Loaded persisted channelUrl")
231
232
  }
232
233
  }
233
- implementation.defaultChannel = getConfig().getString("defaultChannel", "")!
234
+
235
+ // Load defaultChannel: first try from persistent storage (set via setChannel), then fall back to config
236
+ if let storedDefaultChannel = UserDefaults.standard.object(forKey: defaultChannelDefaultsKey) as? String {
237
+ implementation.defaultChannel = storedDefaultChannel
238
+ logger.info("Loaded persisted defaultChannel from setChannel()")
239
+ } else {
240
+ implementation.defaultChannel = getConfig().getString("defaultChannel", "")!
241
+ }
234
242
  self.implementation.autoReset()
235
243
 
236
244
  // Check if app was recently installed/updated BEFORE cleanupObsoleteVersions updates LatestVersionNative
@@ -354,7 +362,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
354
362
  }
355
363
 
356
364
  private func cleanupObsoleteVersions() {
357
- let previous = UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? "0"
365
+ let previous = UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
358
366
  if previous != "0" && self.currentBuildVersion != previous {
359
367
  _ = self._reset(toLastSuccessful: false)
360
368
  let res = implementation.list()
@@ -507,7 +515,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
507
515
  throw ObjectSavableError.checksum
508
516
  }
509
517
 
510
- checksum = try CryptoCipherV2.decryptChecksum(checksum: checksum, publicKey: self.implementation.publicKey)
518
+ checksum = try CryptoCipher.decryptChecksum(checksum: checksum, publicKey: self.implementation.publicKey)
519
+ CryptoCipher.logChecksumInfo(label: "Bundle checksum", hexChecksum: next.getChecksum())
520
+ CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: checksum)
511
521
  if (checksum != "" || self.implementation.publicKey != "") && next.getChecksum() != checksum {
512
522
  self.logger.error("Error checksum \(next.getChecksum()) \(checksum)")
513
523
  self.implementation.sendStats(action: "checksum_fail", versionName: next.getVersionName())
@@ -702,7 +712,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
702
712
  @objc func unsetChannel(_ call: CAPPluginCall) {
703
713
  let triggerAutoUpdate = call.getBool("triggerAutoUpdate", false)
704
714
  DispatchQueue.global(qos: .background).async {
705
- let res = self.implementation.unsetChannel()
715
+ let configDefaultChannel = self.getConfig().getString("defaultChannel", "")!
716
+ let res = self.implementation.unsetChannel(defaultChannelKey: self.defaultChannelDefaultsKey, configDefaultChannel: configDefaultChannel)
706
717
  if res.error != "" {
707
718
  call.reject(res.error, "UNSETCHANNEL_FAILED", nil, [
708
719
  "message": res.error,
@@ -729,11 +740,18 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
729
740
  }
730
741
  let triggerAutoUpdate = call.getBool("triggerAutoUpdate") ?? false
731
742
  DispatchQueue.global(qos: .background).async {
732
- let res = self.implementation.setChannel(channel: channel)
743
+ let res = self.implementation.setChannel(channel: channel, defaultChannelKey: self.defaultChannelDefaultsKey, allowSetDefaultChannel: self.allowSetDefaultChannel)
733
744
  if res.error != "" {
745
+ // Fire channelPrivate event if channel doesn't allow self-assignment
746
+ if res.error.contains("cannot_update_via_private_channel") || res.error.contains("channel_self_set_not_allowed") {
747
+ self.notifyListeners("channelPrivate", data: [
748
+ "channel": channel,
749
+ "message": res.error
750
+ ])
751
+ }
734
752
  call.reject(res.error, "SETCHANNEL_FAILED", nil, [
735
753
  "message": res.error,
736
- "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
754
+ "error": res.error.contains("Channel URL") ? "missing_config" : (res.error.contains("cannot_update_via_private_channel") || res.error.contains("channel_self_set_not_allowed")) ? "channel_private" : "request_failed"
737
755
  ])
738
756
  } else {
739
757
  if self._isAutoUpdateEnabled() && triggerAutoUpdate {
@@ -1323,9 +1341,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1323
1341
  }
1324
1342
  }
1325
1343
  if res.manifest != nil {
1326
- nextImpl = try self.implementation.downloadManifest(manifest: res.manifest!, version: latestVersionName, sessionKey: sessionKey)
1344
+ nextImpl = try self.implementation.downloadManifest(manifest: res.manifest!, version: latestVersionName, sessionKey: sessionKey, link: res.link, comment: res.comment)
1327
1345
  } else {
1328
- nextImpl = try self.implementation.download(url: downloadUrl, version: latestVersionName, sessionKey: sessionKey)
1346
+ nextImpl = try self.implementation.download(url: downloadUrl, version: latestVersionName, sessionKey: sessionKey, link: res.link, comment: res.comment)
1329
1347
  }
1330
1348
  }
1331
1349
  guard let next = nextImpl else {
@@ -1338,7 +1356,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1338
1356
  self.endBackGroundTaskWithNotif(msg: "Latest version is in error state. Aborting update.", latestVersionName: latestVersionName, current: current)
1339
1357
  return
1340
1358
  }
1341
- res.checksum = try CryptoCipherV2.decryptChecksum(checksum: res.checksum, publicKey: self.implementation.publicKey)
1359
+ res.checksum = try CryptoCipher.decryptChecksum(checksum: res.checksum, publicKey: self.implementation.publicKey)
1360
+ CryptoCipher.logChecksumInfo(label: "Bundle checksum", hexChecksum: next.getChecksum())
1361
+ CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: res.checksum)
1342
1362
  if res.checksum != "" && next.getChecksum() != res.checksum && res.manifest == nil {
1343
1363
  self.logger.error("Error checksum \(next.getChecksum()) \(res.checksum)")
1344
1364
  self.implementation.sendStats(action: "checksum_fail", versionName: next.getVersionName())
@@ -374,6 +374,12 @@ import UIKit
374
374
  if let manifest = response.value?.manifest {
375
375
  latest.manifest = manifest
376
376
  }
377
+ if let link = response.value?.link {
378
+ latest.link = link
379
+ }
380
+ if let comment = response.value?.comment {
381
+ latest.comment = comment
382
+ }
377
383
  case let .failure(error):
378
384
  self.logger.error("Error getting Latest \(response.value.debugDescription) \(error)")
379
385
  latest.message = "Error getting Latest \(String(describing: response.value))"
@@ -402,11 +408,11 @@ import UIKit
402
408
  private var tempData = Data()
403
409
 
404
410
  private func verifyChecksum(file: URL, expectedHash: String) -> Bool {
405
- let actualHash = CryptoCipherV2.calcChecksum(filePath: file)
411
+ let actualHash = CryptoCipher.calcChecksum(filePath: file)
406
412
  return actualHash == expectedHash
407
413
  }
408
414
 
409
- public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String) throws -> BundleInfo {
415
+ public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
410
416
  let id = self.randomString(length: 10)
411
417
  logger.info("downloadManifest start \(id)")
412
418
  let destFolder = self.getBundleDirectory(id: id)
@@ -416,7 +422,7 @@ import UIKit
416
422
  try FileManager.default.createDirectory(at: destFolder, withIntermediateDirectories: true, attributes: nil)
417
423
 
418
424
  // Create and save BundleInfo before starting the download process
419
- let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: "")
425
+ let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: "", link: link, comment: comment)
420
426
  self.saveBundleInfo(id: id, bundle: bundleInfo)
421
427
 
422
428
  // Send stats for manifest download start
@@ -441,20 +447,26 @@ import UIKit
441
447
  if !self.hasOldPrivateKeyPropertyInConfig && !self.publicKey.isEmpty && !sessionKey.isEmpty {
442
448
  // V2 Encryption (publicKey)
443
449
  do {
444
- fileHash = try CryptoCipherV2.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
450
+ fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
445
451
  } catch {
446
452
  downloadError = error
447
- logger.error("CryptoCipherV2.decryptChecksum error \(id) \(fileName) error: \(error)")
453
+ logger.error("CryptoCipher.decryptChecksum error \(id) \(fileName) error: \(error)")
448
454
  }
449
455
  } else if self.hasOldPrivateKeyPropertyInConfig {
450
456
  // V1 Encryption (privateKey) - deprecated but supported
451
457
  // V1 doesn't decrypt checksum, uses different method
452
458
  }
453
459
 
460
+ // Check if file has .br extension for Brotli decompression
454
461
  let fileNameWithoutPath = (fileName as NSString).lastPathComponent
455
462
  let cacheFileName = "\(fileHash)_\(fileNameWithoutPath)"
456
463
  let cacheFilePath = cacheFolder.appendingPathComponent(cacheFileName)
457
- let destFilePath = destFolder.appendingPathComponent(fileName)
464
+
465
+ // Check if file is Brotli compressed and remove .br extension from destination
466
+ let isBrotli = fileName.hasSuffix(".br")
467
+ let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
468
+
469
+ let destFilePath = destFolder.appendingPathComponent(destFileName)
458
470
  let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
459
471
 
460
472
  // Create necessary subdirectories in the destination folder
@@ -463,16 +475,26 @@ import UIKit
463
475
  dispatchGroup.enter()
464
476
 
465
477
  if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
466
- try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
467
- logger.info("downloadManifest \(fileName) using builtin file \(id)")
468
- completedFiles += 1
469
- self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
478
+ do {
479
+ try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
480
+ logger.info("downloadManifest \(fileName) using builtin file \(id)")
481
+ completedFiles += 1
482
+ self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
483
+ } catch {
484
+ downloadError = error
485
+ logger.error("Failed to copy builtin file \(fileName): \(error.localizedDescription)")
486
+ }
470
487
  dispatchGroup.leave()
471
488
  } else if FileManager.default.fileExists(atPath: cacheFilePath.path) && verifyChecksum(file: cacheFilePath, expectedHash: fileHash) {
472
- try FileManager.default.copyItem(at: cacheFilePath, to: destFilePath)
473
- logger.info("downloadManifest \(fileName) copy from cache \(id)")
474
- completedFiles += 1
475
- self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
489
+ do {
490
+ try FileManager.default.copyItem(at: cacheFilePath, to: destFilePath)
491
+ logger.info("downloadManifest \(fileName) copy from cache \(id)")
492
+ completedFiles += 1
493
+ self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
494
+ } catch {
495
+ downloadError = error
496
+ logger.error("Failed to copy cached file \(fileName): \(error.localizedDescription)")
497
+ }
476
498
  dispatchGroup.leave()
477
499
  } else {
478
500
  // File not in cache, download, decompress, and save to both cache and destination
@@ -499,35 +521,22 @@ import UIKit
499
521
  let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
500
522
  try finalData.write(to: tempFile)
501
523
  do {
502
- try CryptoCipherV2.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
524
+ try CryptoCipher.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
503
525
  } catch {
504
526
  self.sendStats(action: "decrypt_fail", versionName: version)
505
527
  throw error
506
528
  }
507
529
  finalData = try Data(contentsOf: tempFile)
508
530
  } else if self.hasOldPrivateKeyPropertyInConfig && !sessionKey.isEmpty {
509
- // V1 Encryption (privateKey) - deprecated but supported
510
- let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
511
- try finalData.write(to: tempFile)
512
- do {
513
- try CryptoCipherV1.decryptFile(filePath: tempFile, privateKey: self.privateKey, sessionKey: sessionKey, version: version)
514
- } catch {
515
- self.sendStats(action: "decrypt_fail", versionName: version)
516
- throw error
517
- }
518
- finalData = try Data(contentsOf: tempFile)
519
- try FileManager.default.removeItem(at: tempFile)
531
+ // V1 Encryption (privateKey) - deprecated not supported
532
+ throw NSError(domain: "DeprecatedEncryptionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "V1 Encryption with privateKey is deprecated and not supported in this download method for file \(fileName) at url \(downloadUrl)"])
520
533
  }
521
534
 
522
- // Check if file has .br extension for Brotli decompression
523
- let isBrotli = fileName.hasSuffix(".br")
524
- let finalFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
525
- let destFilePath = destFolder.appendingPathComponent(finalFileName)
526
-
535
+ // Use the isBrotli and destFilePath already computed above
527
536
  if isBrotli {
528
537
  // Decompress the Brotli data
529
538
  guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
530
- self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(finalFileName)")
539
+ self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
531
540
  throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
532
541
  }
533
542
  finalData = decompressedData
@@ -536,9 +545,13 @@ import UIKit
536
545
  try finalData.write(to: destFilePath)
537
546
  if !self.publicKey.isEmpty && !sessionKey.isEmpty {
538
547
  // assume that calcChecksum != null
539
- let calculatedChecksum = CryptoCipherV2.calcChecksum(filePath: destFilePath)
548
+ let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
549
+ CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
550
+ CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
540
551
  if calculatedChecksum != fileHash {
541
- self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(finalFileName)")
552
+ // Delete the corrupt file before throwing error
553
+ try? FileManager.default.removeItem(at: destFilePath)
554
+ self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
542
555
  throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
543
556
  }
544
557
  }
@@ -696,7 +709,7 @@ import UIKit
696
709
  return status == COMPRESSION_STATUS_END ? decompressedData : nil
697
710
  }
698
711
 
699
- public func download(url: URL, version: String, sessionKey: String) throws -> BundleInfo {
712
+ public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
700
713
  let id: String = self.randomString(length: 10)
701
714
  let semaphore = DispatchSemaphore(value: 0)
702
715
  if version != getLocalUpdateVersion() {
@@ -763,7 +776,7 @@ import UIKit
763
776
  semaphore.signal()
764
777
  }
765
778
  }
766
- self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum))
779
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum, link: link, comment: comment))
767
780
  let reachabilityManager = NetworkReachabilityManager()
768
781
  reachabilityManager?.startListening { status in
769
782
  switch status {
@@ -781,7 +794,7 @@ import UIKit
781
794
 
782
795
  if mainError != nil {
783
796
  logger.error("Failed to download: \(String(describing: mainError))")
784
- self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
797
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
785
798
  throw mainError!
786
799
  }
787
800
 
@@ -789,27 +802,28 @@ import UIKit
789
802
  do {
790
803
  if !self.hasOldPrivateKeyPropertyInConfig {
791
804
  // V2 Encryption (publicKey)
792
- try CryptoCipherV2.decryptFile(filePath: tempDataPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
805
+ try CryptoCipher.decryptFile(filePath: tempDataPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
793
806
  } else {
794
807
  // V1 Encryption (privateKey) - deprecated but supported
795
- try CryptoCipherV1.decryptFile(filePath: tempDataPath, privateKey: self.privateKey, sessionKey: sessionKey, version: version)
808
+ try CryptoCipher.decryptFile(filePath: tempDataPath, privateKey: self.privateKey, sessionKey: sessionKey, version: version)
796
809
  }
797
810
  try FileManager.default.moveItem(at: tempDataPath, to: finalPath)
798
811
  } catch {
799
812
  logger.error("Failed decrypt file : \(error)")
800
- self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
813
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
801
814
  cleanDownloadData()
802
815
  throw error
803
816
  }
804
817
 
805
818
  do {
806
- checksum = CryptoCipherV2.calcChecksum(filePath: finalPath)
819
+ checksum = CryptoCipher.calcChecksum(filePath: finalPath)
820
+ CryptoCipher.logChecksumInfo(label: "Calculated bundle checksum", hexChecksum: checksum)
807
821
  logger.info("Downloading: 80% (unzipping)")
808
822
  try self.saveDownloaded(sourceZip: finalPath, id: id, base: self.libraryDir.appendingPathComponent(self.bundleDirectory), notify: true)
809
823
 
810
824
  } catch {
811
825
  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))
826
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
813
827
  // Best-effort cleanup of the decrypted zip file when unzip fails
814
828
  do {
815
829
  if FileManager.default.fileExists(atPath: finalPath.path) {
@@ -824,7 +838,7 @@ import UIKit
824
838
 
825
839
  self.notifyDownload(id: id, percent: 90)
826
840
  logger.info("Downloading: 90% (wrapping up)")
827
- let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum)
841
+ let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum, link: link, comment: comment)
828
842
  self.saveBundleInfo(id: id, bundle: info)
829
843
  self.cleanDownloadData()
830
844
 
@@ -1131,60 +1145,31 @@ import UIKit
1131
1145
  self.setBundleStatus(id: bundle.getId(), status: BundleStatus.ERROR)
1132
1146
  }
1133
1147
 
1134
- func unsetChannel() -> SetChannel {
1148
+ func unsetChannel(defaultChannelKey: String, configDefaultChannel: String) -> SetChannel {
1135
1149
  let setChannel: SetChannel = SetChannel()
1136
1150
 
1137
- // Check if rate limit was exceeded
1138
- if CapgoUpdater.rateLimitExceeded {
1139
- logger.debug("Skipping unsetChannel due to rate limit (429). Requests will resume after app restart.")
1140
- setChannel.message = "Rate limit exceeded"
1141
- setChannel.error = "rate_limit_exceeded"
1142
- return setChannel
1143
- }
1144
-
1145
- if (self.channelUrl ).isEmpty {
1146
- logger.error("Channel URL is not set")
1147
- setChannel.message = "Channel URL is not set"
1148
- setChannel.error = "missing_config"
1149
- return setChannel
1150
- }
1151
- let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
1152
- let parameters: InfoObject = self.createInfoObject()
1153
-
1154
- let request = alamofireSession.request(self.channelUrl, method: .delete, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1155
-
1156
- request.validate().responseDecodable(of: SetChannelDec.self) { response in
1157
- // Check for 429 rate limit
1158
- if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1159
- setChannel.message = "Rate limit exceeded"
1160
- setChannel.error = "rate_limit_exceeded"
1161
- semaphore.signal()
1162
- return
1163
- }
1151
+ // Clear persisted defaultChannel and revert to config value
1152
+ UserDefaults.standard.removeObject(forKey: defaultChannelKey)
1153
+ UserDefaults.standard.synchronize()
1154
+ self.defaultChannel = configDefaultChannel
1155
+ self.logger.info("Persisted defaultChannel cleared, reverted to config value: \(configDefaultChannel)")
1164
1156
 
1165
- switch response.result {
1166
- case .success:
1167
- if let responseValue = response.value {
1168
- if let error = responseValue.error {
1169
- setChannel.error = error
1170
- } else {
1171
- setChannel.status = responseValue.status ?? ""
1172
- setChannel.message = responseValue.message ?? ""
1173
- }
1174
- }
1175
- case let .failure(error):
1176
- self.logger.error("Error unset Channel \(error)")
1177
- setChannel.error = "Request failed: \(error.localizedDescription)"
1178
- }
1179
- semaphore.signal()
1180
- }
1181
- semaphore.wait()
1157
+ setChannel.status = "ok"
1158
+ setChannel.message = "Channel override removed"
1182
1159
  return setChannel
1183
1160
  }
1184
1161
 
1185
- func setChannel(channel: String) -> SetChannel {
1162
+ func setChannel(channel: String, defaultChannelKey: String, allowSetDefaultChannel: Bool) -> SetChannel {
1186
1163
  let setChannel: SetChannel = SetChannel()
1187
1164
 
1165
+ // Check if setting defaultChannel is allowed
1166
+ if !allowSetDefaultChannel {
1167
+ logger.error("setChannel is disabled by allowSetDefaultChannel config")
1168
+ setChannel.message = "setChannel is disabled by configuration"
1169
+ setChannel.error = "disabled_by_config"
1170
+ return setChannel
1171
+ }
1172
+
1188
1173
  // Check if rate limit was exceeded
1189
1174
  if CapgoUpdater.rateLimitExceeded {
1190
1175
  logger.debug("Skipping setChannel due to rate limit (429). Requests will resume after app restart.")
@@ -1220,6 +1205,12 @@ import UIKit
1220
1205
  if let error = responseValue.error {
1221
1206
  setChannel.error = error
1222
1207
  } else {
1208
+ // Success - persist defaultChannel
1209
+ self.defaultChannel = channel
1210
+ UserDefaults.standard.set(channel, forKey: defaultChannelKey)
1211
+ UserDefaults.standard.synchronize()
1212
+ self.logger.info("defaultChannel persisted locally: \(channel)")
1213
+
1223
1214
  setChannel.status = responseValue.status ?? ""
1224
1215
  setChannel.message = responseValue.message ?? ""
1225
1216
  }
@@ -8,8 +8,7 @@ import Foundation
8
8
  import CryptoKit
9
9
  import BigInt
10
10
 
11
- // V2 Encryption - uses publicKey (modern encryption from main branch)
12
- public struct CryptoCipherV2 {
11
+ public struct CryptoCipher {
13
12
  private static var logger: Logger!
14
13
 
15
14
  public static func setLogger(_ logger: Logger) {
@@ -42,6 +41,7 @@ public struct CryptoCipherV2 {
42
41
  // Determine if input is hex or base64 encoded
43
42
  // Hex strings only contain 0-9 and a-f, while base64 contains other characters
44
43
  let checksumBytes: Data
44
+ let detectedFormat: String
45
45
  if isHexString(checksum) {
46
46
  // Hex encoded (new format from CLI for plugin versions >= 5.30.0, 6.30.0, 7.30.0)
47
47
  guard let hexData = hexStringToData(checksum) else {
@@ -49,6 +49,7 @@ public struct CryptoCipherV2 {
49
49
  throw CustomError.cannotDecode
50
50
  }
51
51
  checksumBytes = hexData
52
+ detectedFormat = "hex"
52
53
  } else {
53
54
  // TODO: remove backwards compatibility
54
55
  // Base64 encoded (old format for backwards compatibility)
@@ -57,7 +58,9 @@ public struct CryptoCipherV2 {
57
58
  throw CustomError.cannotDecode
58
59
  }
59
60
  checksumBytes = base64Data
61
+ detectedFormat = "base64"
60
62
  }
63
+ logger.debug("Received encrypted checksum format: \(detectedFormat) (length: \(checksum.count) chars, \(checksumBytes.count) bytes)")
61
64
 
62
65
  if checksumBytes.isEmpty {
63
66
  logger.error("Decoded checksum is empty")
@@ -75,12 +78,55 @@ public struct CryptoCipherV2 {
75
78
  }
76
79
 
77
80
  // Return as hex string to match calcChecksum output format
78
- return decryptedChecksum.map { String(format: "%02x", $0) }.joined()
81
+ let result = decryptedChecksum.map { String(format: "%02x", $0) }.joined()
82
+
83
+ // Detect checksum algorithm based on length
84
+ let detectedAlgorithm: String
85
+ if decryptedChecksum.count == 32 {
86
+ detectedAlgorithm = "SHA-256"
87
+ } else if decryptedChecksum.count == 4 {
88
+ detectedAlgorithm = "CRC32 (deprecated)"
89
+ logger.error("CRC32 checksum detected. This algorithm is deprecated and no longer supported. Please update your CLI to use SHA-256 checksums.")
90
+ } else {
91
+ detectedAlgorithm = "unknown (\(decryptedChecksum.count) bytes)"
92
+ logger.error("Unknown checksum algorithm detected with \(decryptedChecksum.count) bytes. Expected SHA-256 (32 bytes).")
93
+ }
94
+ logger.debug("Decrypted checksum: \(detectedAlgorithm) hex format (length: \(result.count) chars, \(decryptedChecksum.count) bytes)")
95
+ return result
79
96
  } catch {
80
97
  logger.error("decryptChecksum fail: \(error.localizedDescription)")
81
98
  throw CustomError.cannotDecode
82
99
  }
83
100
  }
101
+
102
+ /// Detect checksum algorithm based on hex string length.
103
+ /// SHA-256 = 64 hex chars (32 bytes)
104
+ /// CRC32 = 8 hex chars (4 bytes)
105
+ public static func detectChecksumAlgorithm(_ hexChecksum: String) -> String {
106
+ if hexChecksum.isEmpty {
107
+ return "empty"
108
+ }
109
+ let len = hexChecksum.count
110
+ if len == 64 {
111
+ return "SHA-256"
112
+ } else if len == 8 {
113
+ return "CRC32 (deprecated)"
114
+ } else {
115
+ return "unknown (\(len) hex chars)"
116
+ }
117
+ }
118
+
119
+ /// Log checksum info and warn if deprecated algorithm detected.
120
+ public static func logChecksumInfo(label: String, hexChecksum: String) {
121
+ let algorithm = detectChecksumAlgorithm(hexChecksum)
122
+ logger.debug("\(label): \(algorithm) hex format (length: \(hexChecksum.count) chars)")
123
+ if algorithm.contains("CRC32") {
124
+ logger.error("CRC32 checksum detected. This algorithm is deprecated and no longer supported. Please update your CLI to use SHA-256 checksums.")
125
+ } else if algorithm.contains("unknown") {
126
+ logger.error("Unknown checksum algorithm detected. Expected SHA-256 (64 hex chars) but got \(hexChecksum.count) chars.")
127
+ }
128
+ }
129
+
84
130
  public static func calcChecksum(filePath: URL) -> String {
85
131
  let bufferSize = 1024 * 1024 * 5 // 5 MB
86
132
  var sha256 = SHA256()