@capgo/capacitor-updater 8.45.9 → 8.45.11

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.
@@ -81,15 +81,51 @@ import UIKit
81
81
  CapgoUpdater.buildUserAgent(appId: appId, pluginVersion: pluginVersion, versionOs: versionOs)
82
82
  }
83
83
 
84
+ struct RequestResult {
85
+ let data: Data?
86
+ let response: HTTPURLResponse?
87
+ let error: Error?
88
+ let timedOut: Bool
89
+ }
90
+
91
+ private struct DownloadRequestResult {
92
+ let fileURL: URL?
93
+ let response: HTTPURLResponse?
94
+ let error: Error?
95
+ let timedOut: Bool
96
+ }
97
+
98
+ private func isTimedOutError(_ error: Error?) -> Bool {
99
+ guard let nsError = error as NSError? else {
100
+ return false
101
+ }
102
+
103
+ return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut
104
+ }
105
+
84
106
  private lazy var alamofireSession: Session = {
85
- let configuration = URLSessionConfiguration.default
107
+ let configuration = URLSessionConfiguration.ephemeral
86
108
  configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
109
+ configuration.httpCookieStorage = nil
110
+ configuration.httpShouldSetCookies = false
111
+ configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
112
+ configuration.urlCache = nil
87
113
  return Session(configuration: configuration)
88
114
  }()
115
+ private let networkResponseQueue = DispatchQueue(label: "ee.forgr.capacitor-updater.network-response", qos: .utility)
89
116
 
90
117
  public var notifyDownloadRaw: (String, Int, Bool, BundleInfo?) -> Void = { _, _, _, _ in }
91
118
  public func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
92
- notifyDownloadRaw(id, percent, ignoreMultipleOfTen, bundle)
119
+ let emit = {
120
+ self.notifyDownloadRaw(id, percent, ignoreMultipleOfTen, bundle)
121
+ }
122
+ if Thread.isMainThread {
123
+ emit()
124
+ } else {
125
+ DispatchQueue.main.async {
126
+ emit()
127
+ }
128
+ }
93
129
  }
94
130
  public var notifyDownload: (String, Int) -> Void = { _, _ in }
95
131
  public var notifyListeners: (String, [String: Any]) -> Void = { _, _ in }
@@ -98,6 +134,145 @@ import UIKit
98
134
  self.logger = logger
99
135
  }
100
136
 
137
+ private func createRequest(url: URL, method: String, parameters: [String: Any]? = nil, expectsJSONResponse: Bool = false) -> URLRequest? {
138
+ var request = URLRequest(url: url)
139
+ request.httpMethod = method
140
+ request.timeoutInterval = self.timeout
141
+ request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
142
+ if expectsJSONResponse || parameters != nil {
143
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
144
+ }
145
+
146
+ guard let parameters else {
147
+ return request
148
+ }
149
+
150
+ guard JSONSerialization.isValidJSONObject(parameters) else {
151
+ logger.error("Invalid JSON body for \(method) \(url.absoluteString)")
152
+ return nil
153
+ }
154
+
155
+ do {
156
+ request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
157
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
158
+ return request
159
+ } catch {
160
+ logger.error("Error encoding request body for \(method) \(url.absoluteString)")
161
+ logger.debug("Error: \(error.localizedDescription)")
162
+ return nil
163
+ }
164
+ }
165
+
166
+ func performRequest(_ request: URLRequest, label: String) -> RequestResult {
167
+ let waitTimeout = max(self.timeout + 5, 10)
168
+ let semaphore = DispatchSemaphore(value: 0)
169
+ var responseData: Data?
170
+ var httpResponse: HTTPURLResponse?
171
+ var requestError: Error?
172
+ let dataRequest = self.alamofireSession.request(request).responseData(queue: self.networkResponseQueue) { response in
173
+ responseData = response.data
174
+ httpResponse = response.response
175
+ requestError = response.error
176
+ semaphore.signal()
177
+ }
178
+ dataRequest.resume()
179
+
180
+ if semaphore.wait(timeout: .now() + waitTimeout) == .timedOut {
181
+ dataRequest.cancel()
182
+ logger.error("\(label) timed out after \(Int(waitTimeout))s")
183
+ return RequestResult(data: responseData, response: httpResponse, error: requestError, timedOut: true)
184
+ }
185
+
186
+ return RequestResult(data: responseData, response: httpResponse, error: requestError, timedOut: false)
187
+ }
188
+
189
+ private func performDownloadRequest(_ request: URLRequest, label: String) -> DownloadRequestResult {
190
+ let waitTimeout = max(self.timeout + 5, 10)
191
+ let semaphore = DispatchSemaphore(value: 0)
192
+ var tempFileURL: URL?
193
+ var httpResponse: HTTPURLResponse?
194
+ var requestError: Error?
195
+ let temporaryDownloadURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
196
+ let destination: DownloadRequest.Destination = { _, _ in
197
+ (temporaryDownloadURL, [.removePreviousFile, .createIntermediateDirectories])
198
+ }
199
+ let downloadRequest = self.alamofireSession.download(request, to: destination).response(queue: self.networkResponseQueue) { response in
200
+ tempFileURL = response.fileURL
201
+ httpResponse = response.response
202
+ requestError = response.error
203
+ semaphore.signal()
204
+ }
205
+ downloadRequest.resume()
206
+
207
+ if semaphore.wait(timeout: .now() + waitTimeout) == .timedOut {
208
+ downloadRequest.cancel()
209
+ logger.error("\(label) timed out after \(Int(waitTimeout))s")
210
+ return DownloadRequestResult(
211
+ fileURL: existingDownloadFileURL(tempFileURL, fallback: temporaryDownloadURL),
212
+ response: httpResponse,
213
+ error: requestError,
214
+ timedOut: true
215
+ )
216
+ }
217
+
218
+ if isTimedOutError(requestError) {
219
+ logger.error("\(label) timed out after \(Int(waitTimeout))s")
220
+ }
221
+
222
+ return DownloadRequestResult(
223
+ fileURL: existingDownloadFileURL(tempFileURL, fallback: temporaryDownloadURL),
224
+ response: httpResponse,
225
+ error: requestError,
226
+ timedOut: isTimedOutError(requestError)
227
+ )
228
+ }
229
+
230
+ private func existingDownloadFileURL(_ fileURL: URL?, fallback: URL) -> URL? {
231
+ let fileManager = FileManager.default
232
+ if let fileURL, fileManager.fileExists(atPath: fileURL.path) {
233
+ return fileURL
234
+ }
235
+ return fileManager.fileExists(atPath: fallback.path) ? fallback : nil
236
+ }
237
+
238
+ private func storeDownloadedFile(_ downloadedFileURL: URL, at tempPath: URL, existingBytes: Int64, response: HTTPURLResponse?) throws {
239
+ let fileManager = FileManager.default
240
+ if existingBytes > 0 && (response?.statusCode == 206 || response == nil) {
241
+ let resumedData = try Data(contentsOf: downloadedFileURL)
242
+ let fileHandle = try FileHandle(forWritingTo: tempPath)
243
+ fileHandle.seek(toFileOffset: UInt64(existingBytes))
244
+ fileHandle.write(resumedData)
245
+ try fileHandle.close()
246
+ try? fileManager.removeItem(at: downloadedFileURL)
247
+ return
248
+ }
249
+
250
+ if fileManager.fileExists(atPath: tempPath.path) {
251
+ try fileManager.removeItem(at: tempPath)
252
+ }
253
+ try fileManager.moveItem(at: downloadedFileURL, to: tempPath)
254
+ }
255
+
256
+ private func persistPartialDownload(_ downloadResult: DownloadRequestResult, id: String, tempPath: URL, existingBytes: Int64) {
257
+ guard let downloadedFileURL = downloadResult.fileURL else {
258
+ return
259
+ }
260
+ guard FileManager.default.fileExists(atPath: downloadedFileURL.path) else {
261
+ return
262
+ }
263
+ if let statusCode = downloadResult.response?.statusCode, statusCode < 200 || statusCode >= 300 {
264
+ return
265
+ }
266
+
267
+ do {
268
+ try storeDownloadedFile(downloadedFileURL, at: tempPath, existingBytes: existingBytes, response: downloadResult.response)
269
+ logger.info("Stored partial download for retry")
270
+ } catch {
271
+ logger.error("Failed to store partial download")
272
+ logger.debug("Path: \(downloadedFileURL.path), Error: \(error)")
273
+ }
274
+ }
275
+
101
276
  deinit {
102
277
  // Invalidate the stats timer to prevent memory leaks
103
278
  statsFlushTimer?.invalidate()
@@ -224,8 +399,8 @@ import UIKit
224
399
  self.alamofireSession.request(
225
400
  self.statsUrl,
226
401
  method: .post,
227
- parameters: parameters,
228
- encoder: JSONParameterEncoder.default,
402
+ parameters: parameters.toParameters(),
403
+ encoding: JSONEncoding.default,
229
404
  requestModifier: { $0.timeoutInterval = self.timeout }
230
405
  ).responseData { response in
231
406
  switch response.result {
@@ -334,6 +509,65 @@ import UIKit
334
509
  }
335
510
  }
336
511
 
512
+ private func extractZipEntry(_ archive: Archive, entry: Entry, to destPath: URL) throws {
513
+ let fileManager = FileManager.default
514
+
515
+ switch entry.type {
516
+ case .directory:
517
+ try fileManager.createDirectory(at: destPath, withIntermediateDirectories: true, attributes: nil)
518
+ case .file:
519
+ let parentDir = destPath.deletingLastPathComponent()
520
+ try fileManager.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)
521
+
522
+ if fileManager.fileExists(atPath: destPath.path) {
523
+ try fileManager.removeItem(at: destPath)
524
+ }
525
+
526
+ guard fileManager.createFile(atPath: destPath.path, contents: nil) else {
527
+ throw CustomError.cannotUnzip
528
+ }
529
+
530
+ let fileHandle = try FileHandle(forWritingTo: destPath)
531
+ defer {
532
+ fileHandle.closeFile()
533
+ }
534
+
535
+ _ = try archive.extract(entry, bufferSize: 16 * 1024, skipCRC32: true) { data in
536
+ if !data.isEmpty {
537
+ fileHandle.write(data)
538
+ }
539
+ }
540
+ case .symlink:
541
+ var linkData = Data()
542
+ _ = try archive.extract(entry, bufferSize: 16 * 1024, skipCRC32: true) { data in
543
+ linkData.append(data)
544
+ }
545
+
546
+ guard let linkPath = String(data: linkData, encoding: .utf8) else {
547
+ throw CustomError.cannotUnzip
548
+ }
549
+
550
+ let parentDir = destPath.deletingLastPathComponent()
551
+ try fileManager.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)
552
+
553
+ let isAbsolutePath = (linkPath as NSString).isAbsolutePath
554
+ let linkURL = URL(fileURLWithPath: linkPath, relativeTo: isAbsolutePath ? nil : parentDir)
555
+ let canonicalPath = linkURL.standardizedFileURL.path
556
+ let canonicalDir = parentDir.standardizedFileURL.path
557
+ let normalizedDir = canonicalDir.hasSuffix("/") ? canonicalDir : "\(canonicalDir)/"
558
+
559
+ if canonicalPath != canonicalDir && !canonicalPath.hasPrefix(normalizedDir) {
560
+ throw CustomError.cannotUnzip
561
+ }
562
+
563
+ if fileManager.fileExists(atPath: destPath.path) {
564
+ try fileManager.removeItem(at: destPath)
565
+ }
566
+
567
+ try fileManager.createSymbolicLink(atPath: destPath.path, withDestinationPath: linkPath)
568
+ }
569
+ }
570
+
337
571
  private func saveDownloaded(sourceZip: URL, id: String, base: URL, notify: Bool) throws {
338
572
  try prepareFolder(source: base)
339
573
  let destPersist: URL = base.appendingPathComponent(id)
@@ -365,14 +599,26 @@ import UIKit
365
599
 
366
600
  let destPath = destUnZip.appendingPathComponent(entry.path)
367
601
 
602
+ if entry.type == .directory {
603
+ try FileManager.default.createDirectory(at: destPath, withIntermediateDirectories: true, attributes: nil)
604
+ processedEntries += 1
605
+ if notify && totalEntries > 0 {
606
+ let newPercent = self.calcTotalPercent(percent: Int(Double(processedEntries) / Double(totalEntries) * 100), min: 75, max: 81)
607
+ if newPercent != self.unzipPercent {
608
+ self.unzipPercent = newPercent
609
+ self.notifyDownload(id: id, percent: newPercent)
610
+ }
611
+ }
612
+ continue
613
+ }
614
+
368
615
  // Create parent directories if needed
369
616
  let parentDir = destPath.deletingLastPathComponent()
370
617
  if !FileManager.default.fileExists(atPath: parentDir.path) {
371
618
  try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)
372
619
  }
373
620
 
374
- // Extract the entry
375
- _ = try archive.extract(entry, to: destPath, skipCRC32: true)
621
+ try self.extractZipEntry(archive, entry: entry, to: destPath)
376
622
 
377
623
  // Update progress
378
624
  processedEntries += 1
@@ -476,65 +722,117 @@ import UIKit
476
722
  }
477
723
 
478
724
  public func getLatest(url: URL, channel: String?) -> AppVersion {
479
- let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
480
725
  let latest: AppVersion = AppVersion()
726
+ func applyLatestResponse(_ value: AppVersionDec?) {
727
+ if let url = value?.url {
728
+ latest.url = url
729
+ }
730
+ if let checksum = value?.checksum {
731
+ latest.checksum = checksum
732
+ }
733
+ if let version = value?.version {
734
+ latest.version = version
735
+ }
736
+ if let major = value?.major {
737
+ latest.major = major
738
+ }
739
+ if let breaking = value?.breaking {
740
+ latest.breaking = breaking
741
+ }
742
+ if let error = value?.error {
743
+ latest.error = error
744
+ }
745
+ if let kind = value?.kind {
746
+ latest.kind = kind
747
+ }
748
+ if let message = value?.message {
749
+ latest.message = message
750
+ }
751
+ if let sessionKey = value?.session_key {
752
+ latest.sessionKey = sessionKey
753
+ }
754
+ if let data = value?.data {
755
+ latest.data = data
756
+ }
757
+ if let manifest = value?.manifest {
758
+ latest.manifest = manifest
759
+ }
760
+ if let link = value?.link {
761
+ latest.link = link
762
+ }
763
+ if let comment = value?.comment {
764
+ latest.comment = comment
765
+ }
766
+ }
767
+
481
768
  var parameters: InfoObject = self.createInfoObject()
482
769
  if let channel = channel {
483
770
  parameters.defaultChannel = channel
484
771
  }
485
- logger.info("Auto-update parameters: \(parameters)")
486
- let request = alamofireSession.request(url, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
772
+ guard let request = createRequest(url: url, method: "POST", parameters: parameters.toParameters()) else {
773
+ latest.message = "Error getting Latest"
774
+ latest.error = "request_error"
775
+ latest.kind = "failed"
776
+ return latest
777
+ }
487
778
 
488
- request.validate().responseDecodable(of: AppVersionDec.self) { response in
489
- switch response.result {
490
- case .success:
491
- latest.statusCode = response.response?.statusCode ?? 0
492
- if let url = response.value?.url {
493
- latest.url = url
494
- }
495
- if let checksum = response.value?.checksum {
496
- latest.checksum = checksum
497
- }
498
- if let version = response.value?.version {
499
- latest.version = version
500
- }
501
- if let major = response.value?.major {
502
- latest.major = major
503
- }
504
- if let breaking = response.value?.breaking {
505
- latest.breaking = breaking
506
- }
507
- if let error = response.value?.error {
508
- latest.error = error
509
- }
510
- if let message = response.value?.message {
511
- latest.message = message
512
- }
513
- if let sessionKey = response.value?.session_key {
514
- latest.sessionKey = sessionKey
515
- }
516
- if let data = response.value?.data {
517
- latest.data = data
518
- }
519
- if let manifest = response.value?.manifest {
520
- latest.manifest = manifest
521
- }
522
- if let link = response.value?.link {
523
- latest.link = link
524
- }
525
- if let comment = response.value?.comment {
526
- latest.comment = comment
527
- }
528
- case let .failure(error):
529
- self.logger.error("Error getting latest version")
530
- self.logger.debug("Response: \(response.value.debugDescription), Error: \(error)")
531
- latest.message = "Error getting Latest"
532
- latest.error = "response_error"
533
- latest.statusCode = response.response?.statusCode ?? 0
779
+ let result = performRequest(request, label: "getLatest")
780
+ latest.statusCode = result.response?.statusCode ?? 0
781
+
782
+ if result.timedOut {
783
+ latest.message = "Error getting Latest"
784
+ latest.error = "timeout_error"
785
+ latest.kind = "failed"
786
+ return latest
787
+ }
788
+
789
+ if let error = result.error {
790
+ self.logger.error("Error getting latest version")
791
+ self.logger.debug("Error: \(error.localizedDescription)")
792
+ latest.message = "Error getting Latest"
793
+ latest.error = "response_error"
794
+ latest.kind = "failed"
795
+ return latest
796
+ }
797
+
798
+ guard let data = result.data else {
799
+ self.logger.error("Missing latest version response data")
800
+ latest.message = "Error getting Latest"
801
+ latest.error = "response_error"
802
+ latest.kind = "failed"
803
+ return latest
804
+ }
805
+
806
+ if self.checkAndHandleRateLimitResponse(statusCode: latest.statusCode) {
807
+ latest.message = "Rate limit exceeded"
808
+ latest.error = "rate_limit_exceeded"
809
+ latest.kind = "failed"
810
+ return latest
811
+ }
812
+
813
+ guard let responseValue = try? JSONDecoder().decode(AppVersionDec.self, from: data) else {
814
+ self.logger.error("Error decoding latest version")
815
+ latest.message = "Error getting Latest"
816
+ latest.error = "decode_error"
817
+ latest.kind = "failed"
818
+ return latest
819
+ }
820
+
821
+ applyLatestResponse(responseValue)
822
+
823
+ if latest.statusCode < 200 || latest.statusCode >= 300 {
824
+ if latest.message == nil || latest.message?.isEmpty == true {
825
+ latest.message = responseValue.message ?? "Server error: \(latest.statusCode)"
534
826
  }
535
- semaphore.signal()
827
+ if latest.error == nil || latest.error?.isEmpty == true {
828
+ latest.error = responseValue.error ?? "response_error"
829
+ }
830
+ if latest.kind == nil || latest.kind?.isEmpty == true {
831
+ latest.kind = responseValue.kind ?? "failed"
832
+ }
833
+ return latest
536
834
  }
537
- semaphore.wait()
835
+
538
836
  return latest
539
837
  }
540
838
 
@@ -741,13 +1039,13 @@ import UIKit
741
1039
  userInfo: [NSLocalizedDescriptionKey: "Manifest download failed due to invalid or missing entries"]
742
1040
  )
743
1041
  // Update bundle status to ERROR if download failed
744
- let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.localizedString)
1042
+ let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.storedValue)
745
1043
  self.saveBundleInfo(id: id, bundle: errorBundle)
746
1044
  throw resolvedError
747
1045
  }
748
1046
 
749
1047
  // Update bundle status to PENDING after successful download
750
- let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.localizedString)
1048
+ let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.storedValue)
751
1049
  self.saveBundleInfo(id: id, bundle: updatedBundle)
752
1050
 
753
1051
  // Send stats for manifest download complete
@@ -772,85 +1070,105 @@ import UIKit
772
1070
  version: String,
773
1071
  bundleId: String
774
1072
  ) throws {
775
- let semaphore = DispatchSemaphore(value: 0)
776
- var downloadError: Error?
777
-
778
- self.alamofireSession.download(downloadUrl).responseData { response in
779
- defer { semaphore.signal() }
1073
+ guard let url = URL(string: downloadUrl) else {
1074
+ throw NSError(
1075
+ domain: "ManifestDownloadError",
1076
+ code: 1,
1077
+ userInfo: [NSLocalizedDescriptionKey: "Invalid manifest download URL for file \(fileName): \(downloadUrl)"]
1078
+ )
1079
+ }
780
1080
 
781
- switch response.result {
782
- case .success(let data):
783
- do {
784
- let statusCode = response.response?.statusCode ?? 200
785
- if statusCode < 200 || statusCode >= 300 {
786
- self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
787
- if let stringData = String(data: data, encoding: .utf8) {
788
- throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid. Data: \(stringData) for file \(fileName) at url \(downloadUrl)"])
789
- } else {
790
- throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
791
- }
792
- }
1081
+ guard let request = createRequest(url: url, method: "GET") else {
1082
+ throw NSError(
1083
+ domain: "ManifestDownloadError",
1084
+ code: 2,
1085
+ userInfo: [NSLocalizedDescriptionKey: "Invalid manifest request for file \(fileName): \(downloadUrl)"]
1086
+ )
1087
+ }
793
1088
 
794
- // Add decryption step if public key is set and sessionKey is provided
795
- var finalData = data
796
- if !self.publicKey.isEmpty && !sessionKey.isEmpty {
797
- let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
798
- try finalData.write(to: tempFile)
799
- do {
800
- try CryptoCipher.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
801
- } catch {
802
- self.sendStats(action: "decrypt_fail", versionName: version)
803
- throw error
804
- }
805
- finalData = try Data(contentsOf: tempFile)
806
- try FileManager.default.removeItem(at: tempFile)
807
- }
1089
+ let result = performRequest(request, label: "downloadManifestFile \(fileName)")
808
1090
 
809
- // Decompress Brotli if needed
810
- if isBrotli {
811
- guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
812
- self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
813
- throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
814
- }
815
- finalData = decompressedData
816
- }
1091
+ if result.timedOut {
1092
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1093
+ throw NSError(
1094
+ domain: NSURLErrorDomain,
1095
+ code: NSURLErrorTimedOut,
1096
+ userInfo: [NSLocalizedDescriptionKey: "Timed out downloading manifest file \(fileName) at url \(downloadUrl)"]
1097
+ )
1098
+ }
817
1099
 
818
- // Write to destination
819
- try finalData.write(to: destFilePath)
820
-
821
- // Always verify checksum when file_hash is present
822
- let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
823
- CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
824
- CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
825
- if calculatedChecksum != fileHash {
826
- try? FileManager.default.removeItem(at: destFilePath)
827
- self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
828
- throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
829
- }
1100
+ if let error = result.error {
1101
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1102
+ self.logger.error("Manifest file download network error")
1103
+ self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
1104
+ throw error
1105
+ }
830
1106
 
831
- // Save to cache
832
- try finalData.write(to: cacheFilePath)
1107
+ guard let data = result.data else {
1108
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1109
+ throw NSError(
1110
+ domain: "ManifestDownloadError",
1111
+ code: 3,
1112
+ userInfo: [NSLocalizedDescriptionKey: "Manifest file response was empty for \(fileName) at url \(downloadUrl)"]
1113
+ )
1114
+ }
833
1115
 
834
- self.logger.info("Manifest file downloaded and cached")
835
- self.logger.debug("Bundle: \(bundleId), File: \(fileName), Brotli: \(isBrotli), Encrypted: \(!self.publicKey.isEmpty && !sessionKey.isEmpty)")
1116
+ let statusCode = result.response?.statusCode ?? 200
1117
+ if statusCode < 200 || statusCode >= 300 {
1118
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1119
+ if let stringData = String(data: data, encoding: .utf8) {
1120
+ throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid. Data: \(stringData) for file \(fileName) at url \(downloadUrl)"])
1121
+ } else {
1122
+ throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
1123
+ }
1124
+ }
836
1125
 
1126
+ do {
1127
+ // Add decryption step if public key is set and sessionKey is provided
1128
+ var finalData = data
1129
+ if !self.publicKey.isEmpty && !sessionKey.isEmpty {
1130
+ let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
1131
+ try finalData.write(to: tempFile)
1132
+ do {
1133
+ try CryptoCipher.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
837
1134
  } catch {
838
- downloadError = error
839
- self.logger.error("Manifest file download failed")
840
- self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
1135
+ self.sendStats(action: "decrypt_fail", versionName: version)
1136
+ throw error
841
1137
  }
1138
+ finalData = try Data(contentsOf: tempFile)
1139
+ try FileManager.default.removeItem(at: tempFile)
1140
+ }
842
1141
 
843
- case .failure(let error):
844
- downloadError = error
845
- self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
846
- self.logger.error("Manifest file download network error")
847
- self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription), Response: \(response.debugDescription)")
1142
+ // Decompress Brotli if needed
1143
+ if isBrotli {
1144
+ guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
1145
+ self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
1146
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
1147
+ }
1148
+ finalData = decompressedData
848
1149
  }
849
- }
850
1150
 
851
- semaphore.wait()
1151
+ // Write to destination
1152
+ try finalData.write(to: destFilePath)
1153
+
1154
+ // Always verify checksum when file_hash is present
1155
+ let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
1156
+ CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
1157
+ CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
1158
+ if calculatedChecksum != fileHash {
1159
+ try? FileManager.default.removeItem(at: destFilePath)
1160
+ self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
1161
+ throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
1162
+ }
1163
+
1164
+ // Save to cache
1165
+ try finalData.write(to: cacheFilePath)
852
1166
 
853
- if let error = downloadError {
1167
+ self.logger.info("Manifest file downloaded and cached")
1168
+ self.logger.debug("Bundle: \(bundleId), File: \(fileName), Brotli: \(isBrotli), Encrypted: \(!self.publicKey.isEmpty && !sessionKey.isEmpty)")
1169
+ } catch {
1170
+ self.logger.error("Manifest file download failed")
1171
+ self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
854
1172
  throw error
855
1173
  }
856
1174
  }
@@ -999,7 +1317,6 @@ import UIKit
999
1317
 
1000
1318
  public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
1001
1319
  let id: String = self.randomString(length: 10)
1002
- let semaphore = DispatchSemaphore(value: 0)
1003
1320
  // Each download uses its own temp files keyed by bundle ID to prevent collisions
1004
1321
  if version != getLocalUpdateVersion(for: id) {
1005
1322
  cleanDownloadData(for: id)
@@ -1011,10 +1328,11 @@ import UIKit
1011
1328
  try checkDiskSpace()
1012
1329
 
1013
1330
  var checksum = ""
1014
- var targetSize = -1
1015
1331
  var lastSentProgress = 0
1016
- var totalReceivedBytes: Int64 = loadDownloadProgress(for: id) // Retrieving the amount of already downloaded data if exist, defined at 0 otherwise
1017
- let requestHeaders: HTTPHeaders = ["Range": "bytes=\(totalReceivedBytes)-"]
1332
+ let totalReceivedBytes: Int64 = loadDownloadProgress(for: id) // Retrieving the amount of already downloaded data if exist, defined at 0 otherwise
1333
+ let tempPath = tempDataPath(for: id)
1334
+ let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum, link: link, comment: comment)
1335
+ self.saveBundleInfo(id: id, bundle: bundleInfo)
1018
1336
 
1019
1337
  // Send stats for zip download start
1020
1338
  self.sendStats(action: "download_zip_start", versionName: version)
@@ -1024,66 +1342,61 @@ import UIKit
1024
1342
  self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
1025
1343
  }
1026
1344
  var mainError: NSError?
1027
- let monitor = ClosureEventMonitor()
1028
- monitor.requestDidCompleteTaskWithError = { (_, _, error) in
1029
- if error != nil {
1030
- self.logger.error("Downloading failed - ClosureEventMonitor activated")
1031
- mainError = error as NSError?
1032
- }
1033
- }
1034
- let configuration = URLSessionConfiguration.default
1035
- configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
1036
- let session = Session(configuration: configuration, eventMonitors: [monitor])
1037
1345
 
1038
- let request = session.streamRequest(url, headers: requestHeaders).validate().onHTTPResponse(perform: { response in
1039
- if let contentLength = response.headers.value(for: "Content-Length") {
1040
- targetSize = (Int(contentLength) ?? -1) + Int(totalReceivedBytes)
1041
- }
1042
- }).responseStream { [weak self] streamResponse in
1043
- guard let self = self else { return }
1044
- switch streamResponse.event {
1045
- case .stream(let result):
1046
- if case .success(let data) = result {
1047
- self.tempData.append(data)
1346
+ guard var request = createRequest(url: url, method: "GET") else {
1347
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
1348
+ throw NSError(
1349
+ domain: "DownloadError",
1350
+ code: 2,
1351
+ userInfo: [NSLocalizedDescriptionKey: "Invalid download request for \(url.absoluteString)"]
1352
+ )
1353
+ }
1048
1354
 
1049
- self.savePartialData(startingAt: UInt64(totalReceivedBytes), for: id) // Saving the received data in the package_<id>.tmp file
1050
- totalReceivedBytes += Int64(data.count)
1355
+ if totalReceivedBytes > 0 {
1356
+ request.setValue("bytes=\(totalReceivedBytes)-", forHTTPHeaderField: "Range")
1357
+ }
1051
1358
 
1052
- let percent = max(10, Int((Double(totalReceivedBytes) / Double(targetSize)) * 70.0))
1359
+ let downloadResult = performDownloadRequest(request, label: "download \(version)")
1053
1360
 
1054
- let currentMilestone = (percent / 10) * 10
1055
- if currentMilestone > lastSentProgress && currentMilestone <= 70 {
1056
- for milestone in stride(from: lastSentProgress + 10, through: currentMilestone, by: 10) {
1057
- self.notifyDownload(id: id, percent: milestone, ignoreMultipleOfTen: false)
1058
- }
1059
- lastSentProgress = currentMilestone
1060
- }
1361
+ if downloadResult.timedOut {
1362
+ persistPartialDownload(downloadResult, id: id, tempPath: tempPath, existingBytes: totalReceivedBytes)
1363
+ mainError = NSError(
1364
+ domain: NSURLErrorDomain,
1365
+ code: NSURLErrorTimedOut,
1366
+ userInfo: [NSLocalizedDescriptionKey: "Timed out downloading bundle from \(url.absoluteString)"]
1367
+ )
1368
+ } else if let error = downloadResult.error {
1369
+ logger.error("Download failed")
1370
+ persistPartialDownload(downloadResult, id: id, tempPath: tempPath, existingBytes: totalReceivedBytes)
1371
+ mainError = error as NSError
1372
+ } else if let statusCode = downloadResult.response?.statusCode, statusCode < 200 || statusCode >= 300 {
1373
+ logger.error("Download failed")
1374
+ mainError = NSError(
1375
+ domain: "DownloadError",
1376
+ code: statusCode,
1377
+ userInfo: [NSLocalizedDescriptionKey: "Download request failed with status code \(statusCode)"]
1378
+ )
1379
+ } else if let downloadedFileURL = downloadResult.fileURL {
1380
+ do {
1381
+ try storeDownloadedFile(downloadedFileURL, at: tempPath, existingBytes: totalReceivedBytes, response: downloadResult.response)
1061
1382
 
1062
- } else {
1063
- self.logger.error("Download failed")
1383
+ if lastSentProgress < 70 {
1384
+ self.notifyDownload(id: id, percent: 70, ignoreMultipleOfTen: true)
1385
+ lastSentProgress = 70
1064
1386
  }
1065
-
1066
- case .complete:
1067
- self.logger.info("Download complete, total received bytes: \(totalReceivedBytes)")
1068
- self.notifyDownload(id: id, percent: 70, ignoreMultipleOfTen: true)
1069
- semaphore.signal()
1070
- }
1071
- }
1072
- self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum, link: link, comment: comment))
1073
- let reachabilityManager = NetworkReachabilityManager()
1074
- reachabilityManager?.startListening { status in
1075
- switch status {
1076
- case .notReachable:
1077
- // Stop the download request if the network is not reachable
1078
- request.cancel()
1079
- mainError = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet, userInfo: nil)
1080
- semaphore.signal()
1081
- default:
1082
- break
1387
+ self.logger.info("Download complete")
1388
+ } catch let error as NSError {
1389
+ mainError = error
1390
+ } catch {
1391
+ mainError = error as NSError
1083
1392
  }
1393
+ } else {
1394
+ mainError = NSError(
1395
+ domain: "DownloadError",
1396
+ code: 1,
1397
+ userInfo: [NSLocalizedDescriptionKey: "Downloaded file is missing at \(tempPath.path)"]
1398
+ )
1084
1399
  }
1085
- semaphore.wait()
1086
- reachabilityManager?.stopListening()
1087
1400
 
1088
1401
  if mainError != nil {
1089
1402
  logger.error("Failed to download bundle")
@@ -1092,7 +1405,6 @@ import UIKit
1092
1405
  throw mainError!
1093
1406
  }
1094
1407
 
1095
- let tempPath = tempDataPath(for: id)
1096
1408
  let finalPath = tempPath.deletingLastPathComponent().appendingPathComponent("\(id)")
1097
1409
  do {
1098
1410
  try CryptoCipher.decryptFile(filePath: tempPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
@@ -1313,7 +1625,7 @@ import UIKit
1313
1625
  if removeInfo {
1314
1626
  self.removeBundleInfo(id: id)
1315
1627
  } else {
1316
- self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.localizedString))
1628
+ self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.storedValue))
1317
1629
  }
1318
1630
  logger.info("Bundle deleted successfully")
1319
1631
  logger.debug("Version: \(deleted.getVersionName())")
@@ -1668,54 +1980,74 @@ import UIKit
1668
1980
  setChannel.error = "missing_config"
1669
1981
  return setChannel
1670
1982
  }
1671
- let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
1983
+ guard let channelURL = URL(string: self.channelUrl) else {
1984
+ logger.error("Invalid channel URL")
1985
+ setChannel.message = "Channel URL is invalid"
1986
+ setChannel.error = "invalid_config"
1987
+ return setChannel
1988
+ }
1672
1989
  var parameters: InfoObject = self.createInfoObject()
1673
1990
  parameters.channel = channel
1991
+ guard let request = createRequest(url: channelURL, method: "POST", parameters: parameters.toParameters()) else {
1992
+ setChannel.error = "Request failed: invalid request"
1993
+ return setChannel
1994
+ }
1674
1995
 
1675
- let request = alamofireSession.request(self.channelUrl, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1996
+ let result = performRequest(request, label: "setChannel")
1676
1997
 
1677
- request.validate().responseDecodable(of: SetChannelDec.self) { response in
1678
- // Check for 429 rate limit
1679
- if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1680
- setChannel.message = "Rate limit exceeded"
1681
- setChannel.error = "rate_limit_exceeded"
1682
- semaphore.signal()
1683
- return
1684
- }
1998
+ if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
1999
+ setChannel.message = "Rate limit exceeded"
2000
+ setChannel.error = "rate_limit_exceeded"
2001
+ return setChannel
2002
+ }
1685
2003
 
1686
- switch response.result {
1687
- case .success:
1688
- if let responseValue = response.value {
1689
- if let error = responseValue.error {
1690
- setChannel.error = error
1691
- } else if responseValue.unset == true {
1692
- // Server requested to unset channel (public channel was requested)
1693
- // Clear persisted defaultChannel and revert to config value
1694
- UserDefaults.standard.removeObject(forKey: defaultChannelKey)
1695
- UserDefaults.standard.synchronize()
1696
- self.logger.info("Public channel requested, channel override removed")
1697
-
1698
- setChannel.status = responseValue.status ?? "ok"
1699
- setChannel.message = responseValue.message ?? "Public channel requested, channel override removed. Device will use public channel automatically."
1700
- } else {
1701
- // Success - persist defaultChannel
1702
- self.defaultChannel = channel
1703
- UserDefaults.standard.set(channel, forKey: defaultChannelKey)
1704
- UserDefaults.standard.synchronize()
1705
- self.logger.info("defaultChannel persisted locally: \(channel)")
1706
-
1707
- setChannel.status = responseValue.status ?? ""
1708
- setChannel.message = responseValue.message ?? ""
1709
- }
1710
- }
1711
- case let .failure(error):
1712
- self.logger.error("Error setting channel")
1713
- self.logger.debug("Error: \(error)")
1714
- setChannel.error = "Request failed: \(error.localizedDescription)"
1715
- }
1716
- semaphore.signal()
2004
+ if result.timedOut {
2005
+ setChannel.error = "Request timed out"
2006
+ return setChannel
2007
+ }
2008
+
2009
+ if let error = result.error {
2010
+ self.logger.error("Error setting channel")
2011
+ self.logger.debug("Error: \(error.localizedDescription)")
2012
+ setChannel.error = "Request failed: \(error.localizedDescription)"
2013
+ return setChannel
2014
+ }
2015
+
2016
+ guard let data = result.data else {
2017
+ setChannel.error = "Request failed: empty response"
2018
+ return setChannel
2019
+ }
2020
+
2021
+ guard let responseValue = try? JSONDecoder().decode(SetChannelDec.self, from: data) else {
2022
+ setChannel.error = "decode_error"
2023
+ return setChannel
2024
+ }
2025
+
2026
+ let statusCode = result.response?.statusCode ?? 0
2027
+ if statusCode < 200 || statusCode >= 300 {
2028
+ setChannel.message = responseValue.message ?? "Server error: \(statusCode)"
2029
+ setChannel.error = responseValue.error ?? "response_error"
2030
+ return setChannel
2031
+ }
2032
+
2033
+ if let error = responseValue.error {
2034
+ setChannel.error = error
2035
+ } else if responseValue.unset == true {
2036
+ UserDefaults.standard.removeObject(forKey: defaultChannelKey)
2037
+ UserDefaults.standard.synchronize()
2038
+ self.logger.info("Public channel requested, channel override removed")
2039
+
2040
+ setChannel.status = responseValue.status ?? "ok"
2041
+ setChannel.message = responseValue.message ?? "Public channel requested, channel override removed. Device will use public channel automatically."
2042
+ } else {
2043
+ self.defaultChannel = channel
2044
+ UserDefaults.standard.set(channel, forKey: defaultChannelKey)
2045
+ UserDefaults.standard.synchronize()
2046
+ self.logger.info("defaultChannel persisted locally: \(channel)")
2047
+
2048
+ setChannel.status = responseValue.status ?? ""
2049
+ setChannel.message = responseValue.message ?? ""
1717
2050
  }
1718
- semaphore.wait()
1719
2051
  return setChannel
1720
2052
  }
1721
2053
 
@@ -1736,49 +2068,77 @@ import UIKit
1736
2068
  getChannel.error = "missing_config"
1737
2069
  return getChannel
1738
2070
  }
1739
- let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
2071
+ guard let channelURL = URL(string: self.channelUrl) else {
2072
+ logger.error("Invalid channel URL")
2073
+ getChannel.message = "Channel URL is invalid"
2074
+ getChannel.error = "invalid_config"
2075
+ return getChannel
2076
+ }
1740
2077
  let parameters: InfoObject = self.createInfoObject()
1741
- let request = alamofireSession.request(self.channelUrl, method: .put, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
2078
+ guard let request = createRequest(url: channelURL, method: "PUT", parameters: parameters.toParameters()) else {
2079
+ getChannel.error = "Request failed: invalid request"
2080
+ return getChannel
2081
+ }
1742
2082
 
1743
- request.validate().responseDecodable(of: GetChannelDec.self) { response in
1744
- defer {
1745
- semaphore.signal()
1746
- }
2083
+ let result = performRequest(request, label: "getChannel")
1747
2084
 
1748
- // Check for 429 rate limit
1749
- if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1750
- getChannel.message = "Rate limit exceeded"
1751
- getChannel.error = "rate_limit_exceeded"
1752
- return
1753
- }
2085
+ if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
2086
+ getChannel.message = "Rate limit exceeded"
2087
+ getChannel.error = "rate_limit_exceeded"
2088
+ return getChannel
2089
+ }
1754
2090
 
1755
- switch response.result {
1756
- case .success:
1757
- if let responseValue = response.value {
1758
- if let error = responseValue.error {
1759
- getChannel.error = error
1760
- } else {
1761
- getChannel.status = responseValue.status ?? ""
1762
- getChannel.message = responseValue.message ?? ""
1763
- getChannel.channel = responseValue.channel ?? ""
1764
- getChannel.allowSet = responseValue.allowSet ?? true
1765
- }
1766
- }
1767
- case let .failure(error):
1768
- if let data = response.data, let bodyString = String(data: data, encoding: .utf8) {
1769
- if bodyString.contains("channel_not_found") && response.response?.statusCode == 400 && !self.defaultChannel.isEmpty {
1770
- getChannel.channel = self.defaultChannel
1771
- getChannel.status = "default"
1772
- return
1773
- }
2091
+ if result.timedOut {
2092
+ getChannel.error = "Request timed out"
2093
+ return getChannel
2094
+ }
2095
+
2096
+ if let error = result.error {
2097
+ if let data = result.data, let bodyString = String(data: data, encoding: .utf8) {
2098
+ if bodyString.contains("channel_not_found") && result.response?.statusCode == 400 && !self.defaultChannel.isEmpty {
2099
+ getChannel.channel = self.defaultChannel
2100
+ getChannel.status = "default"
2101
+ return getChannel
1774
2102
  }
2103
+ }
1775
2104
 
1776
- self.logger.error("Error getting channel")
1777
- self.logger.debug("Error: \(error)")
1778
- getChannel.error = "Request failed: \(error.localizedDescription)"
2105
+ self.logger.error("Error getting channel")
2106
+ self.logger.debug("Error: \(error.localizedDescription)")
2107
+ getChannel.error = "Request failed: \(error.localizedDescription)"
2108
+ return getChannel
2109
+ }
2110
+
2111
+ guard let data = result.data else {
2112
+ getChannel.error = "Request failed: empty response"
2113
+ return getChannel
2114
+ }
2115
+
2116
+ guard let responseValue = try? JSONDecoder().decode(GetChannelDec.self, from: data) else {
2117
+ getChannel.error = "decode_error"
2118
+ return getChannel
2119
+ }
2120
+
2121
+ let statusCode = result.response?.statusCode ?? 0
2122
+ if let error = responseValue.error {
2123
+ if error == "channel_not_found", statusCode == 400, !self.defaultChannel.isEmpty {
2124
+ getChannel.channel = self.defaultChannel
2125
+ getChannel.status = "default"
2126
+ return getChannel
1779
2127
  }
2128
+ getChannel.error = error
2129
+ getChannel.message = responseValue.message ?? ""
2130
+ return getChannel
2131
+ }
2132
+
2133
+ if statusCode < 200 || statusCode >= 300 {
2134
+ getChannel.message = responseValue.message ?? "Server error: \(statusCode)"
2135
+ getChannel.error = "response_error"
2136
+ } else {
2137
+ getChannel.status = responseValue.status ?? ""
2138
+ getChannel.message = responseValue.message ?? ""
2139
+ getChannel.channel = responseValue.channel ?? ""
2140
+ getChannel.allowSet = responseValue.allowSet ?? true
1780
2141
  }
1781
- semaphore.wait()
1782
2142
  return getChannel
1783
2143
  }
1784
2144
 
@@ -1798,14 +2158,12 @@ import UIKit
1798
2158
  return listChannels
1799
2159
  }
1800
2160
 
1801
- let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
1802
-
1803
2161
  // Create info object and convert to query parameters
1804
2162
  let infoObject = self.createInfoObject()
1805
2163
 
1806
2164
  // Create query parameters from InfoObject
1807
2165
  var urlComponents = URLComponents(string: self.channelUrl)
1808
- var queryItems: [URLQueryItem] = []
2166
+ var queryItems: [URLQueryItem] = urlComponents?.queryItems ?? []
1809
2167
 
1810
2168
  // Convert InfoObject to dictionary using Mirror
1811
2169
  let mirror = Mirror(reflecting: infoObject)
@@ -1829,47 +2187,62 @@ import UIKit
1829
2187
  return listChannels
1830
2188
  }
1831
2189
 
1832
- let request = alamofireSession.request(url, method: .get, requestModifier: { $0.timeoutInterval = self.timeout })
2190
+ guard let request = createRequest(url: url, method: "GET", expectsJSONResponse: true) else {
2191
+ listChannels.error = "Invalid channel URL"
2192
+ return listChannels
2193
+ }
1833
2194
 
1834
- request.validate().responseDecodable(of: ListChannelsDec.self) { response in
1835
- defer {
1836
- semaphore.signal()
1837
- }
2195
+ let result = performRequest(request, label: "listChannels")
1838
2196
 
1839
- // Check for 429 rate limit
1840
- if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1841
- listChannels.error = "rate_limit_exceeded"
1842
- return
1843
- }
2197
+ if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
2198
+ listChannels.error = "rate_limit_exceeded"
2199
+ return listChannels
2200
+ }
1844
2201
 
1845
- switch response.result {
1846
- case .success:
1847
- if let responseValue = response.value {
1848
- // Check for server-side errors
1849
- if let error = responseValue.error {
1850
- listChannels.error = error
1851
- return
1852
- }
2202
+ if result.timedOut {
2203
+ listChannels.error = "Request timed out"
2204
+ return listChannels
2205
+ }
1853
2206
 
1854
- // Backend returns direct array, so channels should be populated by our custom decoder
1855
- if let channels = responseValue.channels {
1856
- listChannels.channels = channels.map { channel in
1857
- var channelDict: [String: Any] = [:]
1858
- channelDict["id"] = channel.id ?? ""
1859
- channelDict["name"] = channel.name ?? ""
1860
- channelDict["public"] = channel.public ?? false
1861
- channelDict["allow_self_set"] = channel.allow_self_set ?? false
1862
- return channelDict
1863
- }
1864
- }
1865
- }
1866
- case let .failure(error):
1867
- self.logger.error("Error listing channels")
1868
- self.logger.debug("Error: \(error)")
1869
- listChannels.error = "Request failed: \(error.localizedDescription)"
2207
+ if let error = result.error {
2208
+ self.logger.error("Error listing channels")
2209
+ self.logger.debug("Error: \(error.localizedDescription)")
2210
+ listChannels.error = "Request failed: \(error.localizedDescription)"
2211
+ return listChannels
2212
+ }
2213
+
2214
+ guard let data = result.data else {
2215
+ listChannels.error = "Request failed: empty response"
2216
+ return listChannels
2217
+ }
2218
+
2219
+ guard let responseValue = try? JSONDecoder().decode(ListChannelsDec.self, from: data) else {
2220
+ listChannels.error = "decode_error"
2221
+ return listChannels
2222
+ }
2223
+
2224
+ let statusCode = result.response?.statusCode ?? 0
2225
+ if let error = responseValue.error {
2226
+ listChannels.error = error
2227
+ return listChannels
2228
+ }
2229
+
2230
+ if statusCode < 200 || statusCode >= 300 {
2231
+ listChannels.error = "response_error"
2232
+ return listChannels
2233
+ }
2234
+
2235
+ if let channels = responseValue.channels {
2236
+ listChannels.channels = channels.map { channel in
2237
+ var channelDict: [String: Any] = [:]
2238
+ channelDict["id"] = channel.id ?? ""
2239
+ channelDict["name"] = channel.name ?? ""
2240
+ channelDict["public"] = channel.public ?? false
2241
+ channelDict["allow_self_set"] = channel.allow_self_set ?? false
2242
+ return channelDict
1870
2243
  }
1871
2244
  }
1872
- semaphore.wait()
2245
+
1873
2246
  return listChannels
1874
2247
  }
1875
2248
 
@@ -2034,13 +2407,12 @@ import UIKit
2034
2407
  logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
2035
2408
  }
2036
2409
  }
2037
- UserDefaults.standard.synchronize()
2038
2410
  }
2039
2411
 
2040
2412
  private func setBundleStatus(id: String, status: BundleStatus) {
2041
2413
  logger.info("Setting status for bundle [\(id)] to \(status)")
2042
2414
  let info = self.getBundleInfo(id: id)
2043
- self.saveBundleInfo(id: id, bundle: info.setStatus(status: status.localizedString))
2415
+ self.saveBundleInfo(id: id, bundle: info.setStatus(status: status.storedValue))
2044
2416
  }
2045
2417
 
2046
2418
  public func getCurrentBundle() -> BundleInfo {