@capgo/capacitor-updater 7.45.10 → 7.50.2

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 (33) hide show
  1. package/Package.swift +1 -1
  2. package/README.md +510 -92
  3. package/android/src/main/java/ee/forgr/capacitor_updater/AndroidAppExitReporter.java +92 -0
  4. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +3192 -777
  5. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +798 -299
  6. package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +45 -30
  7. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +46 -28
  8. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +2 -0
  9. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +3 -3
  10. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +359 -25
  11. package/android/src/main/java/ee/forgr/capacitor_updater/ThreeFingerPinchDetector.java +323 -0
  12. package/dist/docs.json +1283 -169
  13. package/dist/esm/definitions.d.ts +621 -44
  14. package/dist/esm/definitions.js.map +1 -1
  15. package/dist/esm/web.d.ts +11 -1
  16. package/dist/esm/web.js +59 -1
  17. package/dist/esm/web.js.map +1 -1
  18. package/dist/plugin.cjs.js +59 -1
  19. package/dist/plugin.cjs.js.map +1 -1
  20. package/dist/plugin.js +59 -1
  21. package/dist/plugin.js.map +1 -1
  22. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +0 -1
  23. package/ios/Sources/CapacitorUpdaterPlugin/AppHealthTracker.swift +82 -0
  24. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +0 -16
  25. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +2 -2
  26. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +78 -2
  27. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +2116 -223
  28. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +997 -332
  29. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +0 -1
  30. package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +78 -1
  31. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +418 -36
  32. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +276 -0
  33. package/package.json +15 -3
@@ -21,6 +21,7 @@ import UIKit
21
21
  private let INFO_SUFFIX: String = "_info"
22
22
  private let FALLBACK_VERSION: String = "pastVersion"
23
23
  private let NEXT_VERSION: String = "nextVersion"
24
+ private let PREVIEW_FALLBACK_VERSION: String = "previewFallbackVersion"
24
25
  private var unzipPercent = 0
25
26
  private let TEMP_UNZIP_PREFIX: String = "capgo_unzip_"
26
27
 
@@ -37,6 +38,7 @@ import UIKit
37
38
  public var defaultChannel: String = ""
38
39
  public var appId: String = ""
39
40
  public var deviceID = ""
41
+ public var previewSession = false
40
42
  public var publicKey: String = ""
41
43
 
42
44
  // Cached key ID calculated once from publicKey
@@ -49,11 +51,16 @@ import UIKit
49
51
  private static var rateLimitStatisticSent = false
50
52
 
51
53
  // Stats batching - queue events and send max once per second
52
- private var statsQueue: [StatsEvent] = []
54
+ private var statsQueue: [QueuedStatsEvent] = []
53
55
  private let statsQueueLock = NSLock()
54
56
  private var statsFlushTimer: Timer?
55
57
  private static let statsFlushInterval: TimeInterval = 1.0
56
58
 
59
+ private struct QueuedStatsEvent {
60
+ let event: StatsEvent
61
+ let onSent: (() -> Void)?
62
+ }
63
+
57
64
  private static func sanitizeHeaderValue(_ value: String) -> String {
58
65
  if value.isEmpty {
59
66
  return "unknown"
@@ -81,15 +88,88 @@ import UIKit
81
88
  CapgoUpdater.buildUserAgent(appId: appId, pluginVersion: pluginVersion, versionOs: versionOs)
82
89
  }
83
90
 
91
+ struct RequestResult {
92
+ let data: Data?
93
+ let response: HTTPURLResponse?
94
+ let error: Error?
95
+ let timedOut: Bool
96
+ }
97
+
98
+ private struct DownloadRequestResult {
99
+ let fileURL: URL?
100
+ let response: HTTPURLResponse?
101
+ let error: Error?
102
+ let timedOut: Bool
103
+ }
104
+
105
+ enum SecurePathError: Error {
106
+ case emptyPath
107
+ case windowsPath
108
+ case absolutePath
109
+ case pathTraversal
110
+ }
111
+
112
+ static func resolvePathInsideDirectory(baseDirectory: URL, relativePath: String) throws -> URL {
113
+ if relativePath.isEmpty {
114
+ throw SecurePathError.emptyPath
115
+ }
116
+ if relativePath.contains("\\") || relativePath.contains("\0") {
117
+ throw SecurePathError.windowsPath
118
+ }
119
+ if (relativePath as NSString).isAbsolutePath {
120
+ throw SecurePathError.absolutePath
121
+ }
122
+
123
+ let canonicalBase = baseDirectory.standardizedFileURL
124
+ let canonicalBasePath = canonicalBase.path
125
+ let normalizedBasePath = canonicalBasePath.hasSuffix("/") ? canonicalBasePath : "\(canonicalBasePath)/"
126
+ let canonicalTarget = canonicalBase.appendingPathComponent(relativePath).standardizedFileURL
127
+ let canonicalTargetPath = canonicalTarget.path
128
+
129
+ if canonicalTargetPath != canonicalBasePath && !canonicalTargetPath.hasPrefix(normalizedBasePath) {
130
+ throw SecurePathError.pathTraversal
131
+ }
132
+
133
+ return canonicalTarget
134
+ }
135
+
136
+ static func resolveManifestTargetPath(baseDirectory: URL, fileName: String) throws -> URL {
137
+ let isBrotli = fileName.hasSuffix(".br")
138
+ let targetFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
139
+ return try resolvePathInsideDirectory(baseDirectory: baseDirectory, relativePath: targetFileName)
140
+ }
141
+
142
+ private func isTimedOutError(_ error: Error?) -> Bool {
143
+ guard let nsError = error as NSError? else {
144
+ return false
145
+ }
146
+
147
+ return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut
148
+ }
149
+
84
150
  private lazy var alamofireSession: Session = {
85
- let configuration = URLSessionConfiguration.default
151
+ let configuration = URLSessionConfiguration.ephemeral
86
152
  configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
153
+ configuration.httpCookieStorage = nil
154
+ configuration.httpShouldSetCookies = false
155
+ configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
156
+ configuration.urlCache = nil
87
157
  return Session(configuration: configuration)
88
158
  }()
159
+ private let networkResponseQueue = DispatchQueue(label: "ee.forgr.capacitor-updater.network-response", qos: .utility)
89
160
 
90
161
  public var notifyDownloadRaw: (String, Int, Bool, BundleInfo?) -> Void = { _, _, _, _ in }
91
162
  public func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
92
- notifyDownloadRaw(id, percent, ignoreMultipleOfTen, bundle)
163
+ let emit = {
164
+ self.notifyDownloadRaw(id, percent, ignoreMultipleOfTen, bundle)
165
+ }
166
+ if Thread.isMainThread {
167
+ emit()
168
+ } else {
169
+ DispatchQueue.main.async {
170
+ emit()
171
+ }
172
+ }
93
173
  }
94
174
  public var notifyDownload: (String, Int) -> Void = { _, _ in }
95
175
  public var notifyListeners: (String, [String: Any]) -> Void = { _, _ in }
@@ -98,6 +178,145 @@ import UIKit
98
178
  self.logger = logger
99
179
  }
100
180
 
181
+ private func createRequest(url: URL, method: String, parameters: [String: Any]? = nil, expectsJSONResponse: Bool = false) -> URLRequest? {
182
+ var request = URLRequest(url: url)
183
+ request.httpMethod = method
184
+ request.timeoutInterval = self.timeout
185
+ request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
186
+ if expectsJSONResponse || parameters != nil {
187
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
188
+ }
189
+
190
+ guard let parameters else {
191
+ return request
192
+ }
193
+
194
+ guard JSONSerialization.isValidJSONObject(parameters) else {
195
+ logger.error("Invalid JSON body for \(method) \(url.absoluteString)")
196
+ return nil
197
+ }
198
+
199
+ do {
200
+ request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
201
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
202
+ return request
203
+ } catch {
204
+ logger.error("Error encoding request body for \(method) \(url.absoluteString)")
205
+ logger.debug("Error: \(error.localizedDescription)")
206
+ return nil
207
+ }
208
+ }
209
+
210
+ func performRequest(_ request: URLRequest, label: String) -> RequestResult {
211
+ let waitTimeout = max(self.timeout + 5, 10)
212
+ let semaphore = DispatchSemaphore(value: 0)
213
+ var responseData: Data?
214
+ var httpResponse: HTTPURLResponse?
215
+ var requestError: Error?
216
+ let dataRequest = self.alamofireSession.request(request).responseData(queue: self.networkResponseQueue) { response in
217
+ responseData = response.data
218
+ httpResponse = response.response
219
+ requestError = response.error
220
+ semaphore.signal()
221
+ }
222
+ dataRequest.resume()
223
+
224
+ if semaphore.wait(timeout: .now() + waitTimeout) == .timedOut {
225
+ dataRequest.cancel()
226
+ logger.error("\(label) timed out after \(Int(waitTimeout))s")
227
+ return RequestResult(data: responseData, response: httpResponse, error: requestError, timedOut: true)
228
+ }
229
+
230
+ return RequestResult(data: responseData, response: httpResponse, error: requestError, timedOut: false)
231
+ }
232
+
233
+ private func performDownloadRequest(_ request: URLRequest, label: String) -> DownloadRequestResult {
234
+ let waitTimeout = max(self.timeout + 5, 10)
235
+ let semaphore = DispatchSemaphore(value: 0)
236
+ var tempFileURL: URL?
237
+ var httpResponse: HTTPURLResponse?
238
+ var requestError: Error?
239
+ let temporaryDownloadURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
240
+ let destination: DownloadRequest.Destination = { _, _ in
241
+ (temporaryDownloadURL, [.removePreviousFile, .createIntermediateDirectories])
242
+ }
243
+ let downloadRequest = self.alamofireSession.download(request, to: destination).response(queue: self.networkResponseQueue) { response in
244
+ tempFileURL = response.fileURL
245
+ httpResponse = response.response
246
+ requestError = response.error
247
+ semaphore.signal()
248
+ }
249
+ downloadRequest.resume()
250
+
251
+ if semaphore.wait(timeout: .now() + waitTimeout) == .timedOut {
252
+ downloadRequest.cancel()
253
+ logger.error("\(label) timed out after \(Int(waitTimeout))s")
254
+ return DownloadRequestResult(
255
+ fileURL: existingDownloadFileURL(tempFileURL, fallback: temporaryDownloadURL),
256
+ response: httpResponse,
257
+ error: requestError,
258
+ timedOut: true
259
+ )
260
+ }
261
+
262
+ if isTimedOutError(requestError) {
263
+ logger.error("\(label) timed out after \(Int(waitTimeout))s")
264
+ }
265
+
266
+ return DownloadRequestResult(
267
+ fileURL: existingDownloadFileURL(tempFileURL, fallback: temporaryDownloadURL),
268
+ response: httpResponse,
269
+ error: requestError,
270
+ timedOut: isTimedOutError(requestError)
271
+ )
272
+ }
273
+
274
+ private func existingDownloadFileURL(_ fileURL: URL?, fallback: URL) -> URL? {
275
+ let fileManager = FileManager.default
276
+ if let fileURL, fileManager.fileExists(atPath: fileURL.path) {
277
+ return fileURL
278
+ }
279
+ return fileManager.fileExists(atPath: fallback.path) ? fallback : nil
280
+ }
281
+
282
+ private func storeDownloadedFile(_ downloadedFileURL: URL, at tempPath: URL, existingBytes: Int64, response: HTTPURLResponse?) throws {
283
+ let fileManager = FileManager.default
284
+ if existingBytes > 0 && (response?.statusCode == 206 || response == nil) {
285
+ let resumedData = try Data(contentsOf: downloadedFileURL)
286
+ let fileHandle = try FileHandle(forWritingTo: tempPath)
287
+ fileHandle.seek(toFileOffset: UInt64(existingBytes))
288
+ fileHandle.write(resumedData)
289
+ try fileHandle.close()
290
+ try? fileManager.removeItem(at: downloadedFileURL)
291
+ return
292
+ }
293
+
294
+ if fileManager.fileExists(atPath: tempPath.path) {
295
+ try fileManager.removeItem(at: tempPath)
296
+ }
297
+ try fileManager.moveItem(at: downloadedFileURL, to: tempPath)
298
+ }
299
+
300
+ private func persistPartialDownload(_ downloadResult: DownloadRequestResult, id: String, tempPath: URL, existingBytes: Int64) {
301
+ guard let downloadedFileURL = downloadResult.fileURL else {
302
+ return
303
+ }
304
+ guard FileManager.default.fileExists(atPath: downloadedFileURL.path) else {
305
+ return
306
+ }
307
+ if let statusCode = downloadResult.response?.statusCode, statusCode < 200 || statusCode >= 300 {
308
+ return
309
+ }
310
+
311
+ do {
312
+ try storeDownloadedFile(downloadedFileURL, at: tempPath, existingBytes: existingBytes, response: downloadResult.response)
313
+ logger.info("Stored partial download for retry")
314
+ } catch {
315
+ logger.error("Failed to store partial download")
316
+ logger.debug("Path: \(downloadedFileURL.path), Error: \(error)")
317
+ }
318
+ }
319
+
101
320
  deinit {
102
321
  // Invalidate the stats timer to prevent memory leaks
103
322
  statsFlushTimer?.invalidate()
@@ -149,6 +368,22 @@ import UIKit
149
368
  return !self.isDevEnvironment && !self.isAppStoreReceiptSandbox() && !self.hasEmbeddedMobileProvision()
150
369
  }
151
370
 
371
+ private func installSource() -> String? {
372
+ if isEmulator() || self.isDevEnvironment || self.hasEmbeddedMobileProvision() {
373
+ return nil
374
+ }
375
+ guard let receiptURL = Bundle.main.appStoreReceiptURL else {
376
+ return nil
377
+ }
378
+ if receiptURL.lastPathComponent == "sandboxReceipt" {
379
+ return "testflight"
380
+ }
381
+ guard FileManager.default.fileExists(atPath: receiptURL.path) else {
382
+ return nil
383
+ }
384
+ return "app_store"
385
+ }
386
+
152
387
  /**
153
388
  * Checks if there is sufficient disk space for a download.
154
389
  * Matches Android behavior: 2x safety margin, throws "insufficient_disk_space"
@@ -188,7 +423,7 @@ import UIKit
188
423
  if statusCode == 429 {
189
424
  // Send a statistic about the rate limit BEFORE setting the flag
190
425
  // Only send once to prevent infinite loop if the stat request itself gets rate limited
191
- if !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
426
+ if !previewSession && !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
192
427
  CapgoUpdater.rateLimitStatisticSent = true
193
428
 
194
429
  // Dispatch to background queue to avoid blocking the main thread
@@ -224,8 +459,8 @@ import UIKit
224
459
  self.alamofireSession.request(
225
460
  self.statsUrl,
226
461
  method: .post,
227
- parameters: parameters,
228
- encoder: JSONParameterEncoder.default,
462
+ parameters: parameters.toParameters(),
463
+ encoding: JSONEncoding.default,
229
464
  requestModifier: { $0.timeoutInterval = self.timeout }
230
465
  ).responseData { response in
231
466
  switch response.result {
@@ -314,23 +549,76 @@ import UIKit
314
549
  }
315
550
  }
316
551
 
317
- private func validateZipEntry(path: String, destUnZip: URL) throws {
318
- // Check for Windows paths
319
- if path.contains("\\") {
552
+ private func resolveZipEntry(path: String, destUnZip: URL) throws -> URL {
553
+ do {
554
+ return try Self.resolvePathInsideDirectory(baseDirectory: destUnZip, relativePath: path)
555
+ } catch SecurePathError.windowsPath {
320
556
  logger.error("Unzip failed: Windows path not supported")
321
557
  logger.debug("Invalid path: \(path)")
322
558
  self.sendStats(action: "windows_path_fail")
323
559
  throw CustomError.cannotUnzip
560
+ } catch {
561
+ self.sendStats(action: "canonical_path_fail")
562
+ throw CustomError.cannotUnzip
324
563
  }
564
+ }
325
565
 
326
- // Check for path traversal
327
- let fileURL = destUnZip.appendingPathComponent(path)
328
- let canonicalPath = fileURL.standardizedFileURL.path
329
- let canonicalDir = destUnZip.standardizedFileURL.path
566
+ private func extractZipEntry(_ archive: Archive, entry: Entry, to destPath: URL) throws {
567
+ let fileManager = FileManager.default
330
568
 
331
- if !canonicalPath.hasPrefix(canonicalDir) {
332
- self.sendStats(action: "canonical_path_fail")
333
- throw CustomError.cannotUnzip
569
+ switch entry.type {
570
+ case .directory:
571
+ try fileManager.createDirectory(at: destPath, withIntermediateDirectories: true, attributes: nil)
572
+ case .file:
573
+ let parentDir = destPath.deletingLastPathComponent()
574
+ try fileManager.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)
575
+
576
+ if fileManager.fileExists(atPath: destPath.path) {
577
+ try fileManager.removeItem(at: destPath)
578
+ }
579
+
580
+ guard fileManager.createFile(atPath: destPath.path, contents: nil) else {
581
+ throw CustomError.cannotUnzip
582
+ }
583
+
584
+ let fileHandle = try FileHandle(forWritingTo: destPath)
585
+ defer {
586
+ fileHandle.closeFile()
587
+ }
588
+
589
+ _ = try archive.extract(entry, bufferSize: 16 * 1024, skipCRC32: true) { data in
590
+ if !data.isEmpty {
591
+ fileHandle.write(data)
592
+ }
593
+ }
594
+ case .symlink:
595
+ var linkData = Data()
596
+ _ = try archive.extract(entry, bufferSize: 16 * 1024, skipCRC32: true) { data in
597
+ linkData.append(data)
598
+ }
599
+
600
+ guard let linkPath = String(data: linkData, encoding: .utf8) else {
601
+ throw CustomError.cannotUnzip
602
+ }
603
+
604
+ let parentDir = destPath.deletingLastPathComponent()
605
+ try fileManager.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)
606
+
607
+ let isAbsolutePath = (linkPath as NSString).isAbsolutePath
608
+ let linkURL = URL(fileURLWithPath: linkPath, relativeTo: isAbsolutePath ? nil : parentDir)
609
+ let canonicalPath = linkURL.standardizedFileURL.path
610
+ let canonicalDir = parentDir.standardizedFileURL.path
611
+ let normalizedDir = canonicalDir.hasSuffix("/") ? canonicalDir : "\(canonicalDir)/"
612
+
613
+ if canonicalPath != canonicalDir && !canonicalPath.hasPrefix(normalizedDir) {
614
+ throw CustomError.cannotUnzip
615
+ }
616
+
617
+ if fileManager.fileExists(atPath: destPath.path) {
618
+ try fileManager.removeItem(at: destPath)
619
+ }
620
+
621
+ try fileManager.createSymbolicLink(atPath: destPath.path, withDestinationPath: linkPath)
334
622
  }
335
623
  }
336
624
 
@@ -360,10 +648,20 @@ import UIKit
360
648
 
361
649
  do {
362
650
  for entry in archive {
363
- // Validate entry path for security
364
- try validateZipEntry(path: entry.path, destUnZip: destUnZip)
365
-
366
- let destPath = destUnZip.appendingPathComponent(entry.path)
651
+ let destPath = try resolveZipEntry(path: entry.path, destUnZip: destUnZip)
652
+
653
+ if entry.type == .directory {
654
+ try FileManager.default.createDirectory(at: destPath, withIntermediateDirectories: true, attributes: nil)
655
+ processedEntries += 1
656
+ if notify && totalEntries > 0 {
657
+ let newPercent = self.calcTotalPercent(percent: Int(Double(processedEntries) / Double(totalEntries) * 100), min: 75, max: 81)
658
+ if newPercent != self.unzipPercent {
659
+ self.unzipPercent = newPercent
660
+ self.notifyDownload(id: id, percent: newPercent)
661
+ }
662
+ }
663
+ continue
664
+ }
367
665
 
368
666
  // Create parent directories if needed
369
667
  let parentDir = destPath.deletingLastPathComponent()
@@ -371,8 +669,7 @@ import UIKit
371
669
  try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)
372
670
  }
373
671
 
374
- // Extract the entry
375
- _ = try archive.extract(entry, to: destPath, skipCRC32: true)
672
+ try self.extractZipEntry(archive, entry: entry, to: destPath)
376
673
 
377
674
  // Update progress
378
675
  processedEntries += 1
@@ -455,11 +752,11 @@ import UIKit
455
752
  }
456
753
  }
457
754
 
458
- private func createInfoObject() -> InfoObject {
755
+ private func createInfoObject(appIdOverride: String? = nil) -> InfoObject {
459
756
  return InfoObject(
460
757
  platform: "ios",
461
758
  device_id: self.deviceID,
462
- app_id: self.appId,
759
+ app_id: appIdOverride ?? self.appId,
463
760
  custom_id: self.customId,
464
761
  version_build: self.versionBuild,
465
762
  version_code: self.versionCode,
@@ -468,6 +765,7 @@ import UIKit
468
765
  plugin_version: self.pluginVersion,
469
766
  is_emulator: self.isEmulator(),
470
767
  is_prod: self.isProd(),
768
+ installSource: self.installSource(),
471
769
  action: nil,
472
770
  channel: nil,
473
771
  defaultChannel: self.defaultChannel,
@@ -475,8 +773,7 @@ import UIKit
475
773
  )
476
774
  }
477
775
 
478
- public func getLatest(url: URL, channel: String?) -> AppVersion {
479
- let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
776
+ public func getLatest(url: URL, channel: String?, appIdOverride: String? = nil) -> AppVersion {
480
777
  let latest: AppVersion = AppVersion()
481
778
  func applyLatestResponse(_ value: AppVersionDec?) {
482
779
  if let url = value?.url {
@@ -519,47 +816,75 @@ import UIKit
519
816
  latest.comment = comment
520
817
  }
521
818
  }
522
- var parameters: InfoObject = self.createInfoObject()
819
+
820
+ var parameters: InfoObject = self.createInfoObject(appIdOverride: appIdOverride)
523
821
  if let channel = channel {
524
822
  parameters.defaultChannel = channel
525
823
  }
526
- logger.info("Auto-update parameters: \(parameters)")
527
- let request = alamofireSession.request(url, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
824
+ guard let request = createRequest(url: url, method: "POST", parameters: parameters.toParameters()) else {
825
+ latest.message = "Error getting Latest"
826
+ latest.error = "request_error"
827
+ latest.kind = "failed"
828
+ return latest
829
+ }
528
830
 
529
- request.validate().responseDecodable(of: AppVersionDec.self) { response in
530
- switch response.result {
531
- case .success:
532
- latest.statusCode = response.response?.statusCode ?? 0
533
- applyLatestResponse(response.value)
534
- case let .failure(error):
535
- self.logger.error("Error getting latest version")
536
- self.logger.debug("Response: \(response.value.debugDescription), Error: \(error)")
537
- latest.statusCode = response.response?.statusCode ?? 0
538
- if let data = response.data,
539
- let decoded = try? JSONDecoder().decode(AppVersionDec.self, from: data) {
540
- applyLatestResponse(decoded)
541
- let decodedError = decoded.error ?? ""
542
- let decodedKind = decoded.kind ?? ""
543
- if decodedError.isEmpty && decodedKind.isEmpty {
544
- if latest.message == nil || latest.message?.isEmpty == true {
545
- latest.message = "Error getting Latest"
546
- }
547
- if latest.error == nil || latest.error?.isEmpty == true {
548
- latest.error = "response_error"
549
- }
550
- if latest.kind == nil || latest.kind?.isEmpty == true {
551
- latest.kind = "failed"
552
- }
553
- }
554
- } else {
555
- latest.message = "Error getting Latest"
556
- latest.error = "response_error"
557
- latest.kind = "failed"
558
- }
831
+ let result = performRequest(request, label: "getLatest")
832
+ latest.statusCode = result.response?.statusCode ?? 0
833
+
834
+ if result.timedOut {
835
+ latest.message = "Error getting Latest"
836
+ latest.error = "timeout_error"
837
+ latest.kind = "failed"
838
+ return latest
839
+ }
840
+
841
+ if let error = result.error {
842
+ self.logger.error("Error getting latest version")
843
+ self.logger.debug("Error: \(error.localizedDescription)")
844
+ latest.message = "Error getting Latest"
845
+ latest.error = "response_error"
846
+ latest.kind = "failed"
847
+ return latest
848
+ }
849
+
850
+ guard let data = result.data else {
851
+ self.logger.error("Missing latest version response data")
852
+ latest.message = "Error getting Latest"
853
+ latest.error = "response_error"
854
+ latest.kind = "failed"
855
+ return latest
856
+ }
857
+
858
+ if self.checkAndHandleRateLimitResponse(statusCode: latest.statusCode) {
859
+ latest.message = "Rate limit exceeded"
860
+ latest.error = "rate_limit_exceeded"
861
+ latest.kind = "failed"
862
+ return latest
863
+ }
864
+
865
+ guard let responseValue = try? JSONDecoder().decode(AppVersionDec.self, from: data) else {
866
+ self.logger.error("Error decoding latest version")
867
+ latest.message = "Error getting Latest"
868
+ latest.error = "decode_error"
869
+ latest.kind = "failed"
870
+ return latest
871
+ }
872
+
873
+ applyLatestResponse(responseValue)
874
+
875
+ if latest.statusCode < 200 || latest.statusCode >= 300 {
876
+ if latest.message == nil || latest.message?.isEmpty == true {
877
+ latest.message = responseValue.message ?? "Server error: \(latest.statusCode)"
559
878
  }
560
- semaphore.signal()
879
+ if latest.error == nil || latest.error?.isEmpty == true {
880
+ latest.error = responseValue.error ?? "response_error"
881
+ }
882
+ if latest.kind == nil || latest.kind?.isEmpty == true {
883
+ latest.kind = responseValue.kind ?? "failed"
884
+ }
885
+ return latest
561
886
  }
562
- semaphore.wait()
887
+
563
888
  return latest
564
889
  }
565
890
 
@@ -601,6 +926,132 @@ import UIKit
601
926
  return actualHash == expectedHash
602
927
  }
603
928
 
929
+ private func resolveManifestFileHash(entry: ManifestEntry, sessionKey: String) -> String? {
930
+ guard var fileHash = entry.file_hash, !fileHash.isEmpty else {
931
+ return nil
932
+ }
933
+ if !self.publicKey.isEmpty && !sessionKey.isEmpty {
934
+ do {
935
+ fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
936
+ } catch {
937
+ logger.error("Checksum decryption failed while checking missing manifest files")
938
+ logger.debug("File: \(entry.file_name ?? "unknown"), Error: \(error.localizedDescription)")
939
+ return nil
940
+ }
941
+ }
942
+ return fileHash
943
+ }
944
+
945
+ private func isManifestEntryAvailableLocally(entry: ManifestEntry, sessionKey: String) -> Bool {
946
+ guard let fileName = entry.file_name,
947
+ let fileHash = resolveManifestFileHash(entry: entry, sessionKey: sessionKey) else {
948
+ return false
949
+ }
950
+
951
+ let builtinFolder = Bundle.main.bundleURL.appendingPathComponent("public")
952
+ let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
953
+ if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
954
+ return true
955
+ }
956
+
957
+ let fileNameWithoutPath = (fileName as NSString).lastPathComponent
958
+ let isBrotli = fileName.hasSuffix(".br")
959
+ let cacheBaseName = isBrotli ? String(fileNameWithoutPath.dropLast(3)) : fileNameWithoutPath
960
+ let cacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(cacheBaseName)")
961
+ if FileManager.default.fileExists(atPath: cacheFilePath.path) && verifyChecksum(file: cacheFilePath, expectedHash: fileHash) {
962
+ return true
963
+ }
964
+
965
+ if isBrotli {
966
+ let legacyCacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(fileNameWithoutPath)")
967
+ if FileManager.default.fileExists(atPath: legacyCacheFilePath.path) && verifyChecksum(file: legacyCacheFilePath, expectedHash: fileHash) {
968
+ return true
969
+ }
970
+ }
971
+
972
+ return false
973
+ }
974
+
975
+ public func getMissingBundleFiles(manifest: [ManifestEntry], sessionKey: String) -> [ManifestEntry] {
976
+ return manifest.filter { entry in
977
+ !isManifestEntryAvailableLocally(entry: entry, sessionKey: sessionKey)
978
+ }
979
+ }
980
+
981
+ public func missingBundleFilesResult(manifest: [ManifestEntry], sessionKey: String) -> [String: Any] {
982
+ let missing = getMissingBundleFiles(manifest: manifest, sessionKey: sessionKey)
983
+ return [
984
+ "missing": missing.map { $0.toDict() },
985
+ "total": manifest.count,
986
+ "missingCount": missing.count,
987
+ "reusableCount": manifest.count - missing.count
988
+ ]
989
+ }
990
+
991
+ private func manifestSizeUrl(from updateUrl: URL) -> URL {
992
+ var components = URLComponents(url: updateUrl, resolvingAgainstBaseURL: false)
993
+ let path = components?.path ?? updateUrl.path
994
+ let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
995
+ components?.path = trimmedPath == ""
996
+ ? "/manifest_size"
997
+ : "/\(trimmedPath)/manifest_size"
998
+ components?.query = nil
999
+ return components?.url ?? updateUrl.appendingPathComponent("manifest_size")
1000
+ }
1001
+
1002
+ private func unavailableBundleSizeResult(manifest: [ManifestEntry], error: String) -> [String: Any] {
1003
+ return [
1004
+ "totalSize": 0,
1005
+ "knownFiles": 0,
1006
+ "unknownFiles": manifest.count,
1007
+ "files": manifest.map {
1008
+ var dict = $0.toDict()
1009
+ dict["error"] = error
1010
+ return dict
1011
+ }
1012
+ ]
1013
+ }
1014
+
1015
+ public func getBundleDownloadSize(updateUrl: URL, version: String?, manifest: [ManifestEntry]) -> [String: Any] {
1016
+ if manifest.isEmpty {
1017
+ return [
1018
+ "totalSize": 0,
1019
+ "knownFiles": 0,
1020
+ "unknownFiles": 0,
1021
+ "files": []
1022
+ ]
1023
+ }
1024
+
1025
+ var parameters = self.createInfoObject().toParameters()
1026
+ parameters["version"] = version ?? ""
1027
+ parameters["manifest"] = manifest.map { $0.toDict() }
1028
+
1029
+ guard let request = createRequest(url: manifestSizeUrl(from: updateUrl), method: "POST", parameters: parameters) else {
1030
+ return unavailableBundleSizeResult(manifest: manifest, error: "request_error")
1031
+ }
1032
+
1033
+ let result = performRequest(request, label: "getBundleDownloadSize")
1034
+ if result.timedOut {
1035
+ return unavailableBundleSizeResult(manifest: manifest, error: "timeout_error")
1036
+ }
1037
+ if let error = result.error {
1038
+ logger.error("Error getting bundle download size")
1039
+ logger.debug("Error: \(error.localizedDescription)")
1040
+ return unavailableBundleSizeResult(manifest: manifest, error: "response_error")
1041
+ }
1042
+ guard let data = result.data,
1043
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
1044
+ return unavailableBundleSizeResult(manifest: manifest, error: "parse_error")
1045
+ }
1046
+
1047
+ let statusCode = result.response?.statusCode ?? 0
1048
+ if statusCode < 200 || statusCode >= 300 {
1049
+ return unavailableBundleSizeResult(manifest: manifest, error: "response_error")
1050
+ }
1051
+
1052
+ return json
1053
+ }
1054
+
604
1055
  public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
605
1056
  let id = self.randomString(length: 10)
606
1057
  logger.info("downloadManifest start \(id)")
@@ -626,8 +1077,8 @@ import UIKit
626
1077
 
627
1078
  let totalFiles = manifest.count
628
1079
 
629
- // Configure concurrent operation count similar to Android: min(64, max(32, totalFiles))
630
- manifestDownloadQueue.maxConcurrentOperationCount = min(64, max(32, totalFiles))
1080
+ // Keep this bounded because each manifest operation waits on a URLSession callback.
1081
+ manifestDownloadQueue.maxConcurrentOperationCount = min(8, max(1, totalFiles))
631
1082
 
632
1083
  // Thread-safe counters for concurrent operations
633
1084
  let completedFiles = AtomicCounter()
@@ -699,8 +1150,22 @@ import UIKit
699
1150
  let legacyCacheFilePath: URL? = isBrotli ? cacheFolder.appendingPathComponent("\(finalFileHash)_\(fileNameWithoutPath)") : nil
700
1151
 
701
1152
  let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
702
- let destFilePath = destFolder.appendingPathComponent(destFileName)
703
- let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
1153
+ let destFilePath: URL
1154
+ let builtinFilePath: URL
1155
+ do {
1156
+ destFilePath = try Self.resolveManifestTargetPath(baseDirectory: destFolder, fileName: fileName)
1157
+ builtinFilePath = try Self.resolvePathInsideDirectory(baseDirectory: builtinFolder, relativePath: fileName)
1158
+ } catch {
1159
+ logger.error("Invalid manifest file path: \(fileName)")
1160
+ self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
1161
+ errorLock.lock()
1162
+ if downloadError == nil {
1163
+ downloadError = error
1164
+ }
1165
+ errorLock.unlock()
1166
+ hasError.value = true
1167
+ continue
1168
+ }
704
1169
 
705
1170
  // Create parent directories synchronously (before operations start)
706
1171
  try? FileManager.default.createDirectory(at: destFilePath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
@@ -712,7 +1177,7 @@ import UIKit
712
1177
  do {
713
1178
  // Try builtin first
714
1179
  if FileManager.default.fileExists(atPath: builtinFilePath.path) && self.verifyChecksum(file: builtinFilePath, expectedHash: finalFileHash) {
715
- try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
1180
+ try self.copyItemReplacing(from: builtinFilePath, to: destFilePath)
716
1181
  self.logger.info("downloadManifest \(fileName) using builtin file \(id)")
717
1182
  }
718
1183
  // Try cache
@@ -766,13 +1231,13 @@ import UIKit
766
1231
  userInfo: [NSLocalizedDescriptionKey: "Manifest download failed due to invalid or missing entries"]
767
1232
  )
768
1233
  // Update bundle status to ERROR if download failed
769
- let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.localizedString)
1234
+ let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.storedValue)
770
1235
  self.saveBundleInfo(id: id, bundle: errorBundle)
771
1236
  throw resolvedError
772
1237
  }
773
1238
 
774
1239
  // Update bundle status to PENDING after successful download
775
- let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.localizedString)
1240
+ let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.storedValue)
776
1241
  self.saveBundleInfo(id: id, bundle: updatedBundle)
777
1242
 
778
1243
  // Send stats for manifest download complete
@@ -797,105 +1262,152 @@ import UIKit
797
1262
  version: String,
798
1263
  bundleId: String
799
1264
  ) throws {
800
- let semaphore = DispatchSemaphore(value: 0)
801
- var downloadError: Error?
802
-
803
- self.alamofireSession.download(downloadUrl).responseData { response in
804
- defer { semaphore.signal() }
1265
+ guard let url = URL(string: downloadUrl) else {
1266
+ throw NSError(
1267
+ domain: "ManifestDownloadError",
1268
+ code: 1,
1269
+ userInfo: [NSLocalizedDescriptionKey: "Invalid manifest download URL for file \(fileName): \(downloadUrl)"]
1270
+ )
1271
+ }
805
1272
 
806
- switch response.result {
807
- case .success(let data):
808
- do {
809
- let statusCode = response.response?.statusCode ?? 200
810
- if statusCode < 200 || statusCode >= 300 {
811
- self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
812
- if let stringData = String(data: data, encoding: .utf8) {
813
- throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid. Data: \(stringData) for file \(fileName) at url \(downloadUrl)"])
814
- } else {
815
- throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
816
- }
817
- }
1273
+ guard let request = createRequest(url: url, method: "GET") else {
1274
+ throw NSError(
1275
+ domain: "ManifestDownloadError",
1276
+ code: 2,
1277
+ userInfo: [NSLocalizedDescriptionKey: "Invalid manifest request for file \(fileName): \(downloadUrl)"]
1278
+ )
1279
+ }
818
1280
 
819
- // Add decryption step if public key is set and sessionKey is provided
820
- var finalData = data
821
- if !self.publicKey.isEmpty && !sessionKey.isEmpty {
822
- let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
823
- try finalData.write(to: tempFile)
824
- do {
825
- try CryptoCipher.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
826
- } catch {
827
- self.sendStats(action: "decrypt_fail", versionName: version)
828
- throw error
829
- }
830
- finalData = try Data(contentsOf: tempFile)
831
- try FileManager.default.removeItem(at: tempFile)
832
- }
1281
+ let result = performRequest(request, label: "downloadManifestFile \(fileName)")
833
1282
 
834
- // Decompress Brotli if needed
835
- if isBrotli {
836
- guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
837
- self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
838
- throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
839
- }
840
- finalData = decompressedData
841
- }
1283
+ if result.timedOut {
1284
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1285
+ throw NSError(
1286
+ domain: NSURLErrorDomain,
1287
+ code: NSURLErrorTimedOut,
1288
+ userInfo: [NSLocalizedDescriptionKey: "Timed out downloading manifest file \(fileName) at url \(downloadUrl)"]
1289
+ )
1290
+ }
842
1291
 
843
- // Write to destination
844
- try finalData.write(to: destFilePath)
845
-
846
- // Always verify checksum when file_hash is present
847
- let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
848
- CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
849
- CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
850
- if calculatedChecksum != fileHash {
851
- try? FileManager.default.removeItem(at: destFilePath)
852
- self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
853
- throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
854
- }
1292
+ if let error = result.error {
1293
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1294
+ self.logger.error("Manifest file download network error")
1295
+ self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
1296
+ throw error
1297
+ }
855
1298
 
856
- // Save to cache
857
- try finalData.write(to: cacheFilePath)
1299
+ guard let data = result.data else {
1300
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1301
+ throw NSError(
1302
+ domain: "ManifestDownloadError",
1303
+ code: 3,
1304
+ userInfo: [NSLocalizedDescriptionKey: "Manifest file response was empty for \(fileName) at url \(downloadUrl)"]
1305
+ )
1306
+ }
858
1307
 
859
- self.logger.info("Manifest file downloaded and cached")
860
- self.logger.debug("Bundle: \(bundleId), File: \(fileName), Brotli: \(isBrotli), Encrypted: \(!self.publicKey.isEmpty && !sessionKey.isEmpty)")
1308
+ let statusCode = result.response?.statusCode ?? 200
1309
+ if statusCode < 200 || statusCode >= 300 {
1310
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1311
+ if let stringData = String(data: data, encoding: .utf8) {
1312
+ throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid. Data: \(stringData) for file \(fileName) at url \(downloadUrl)"])
1313
+ } else {
1314
+ throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
1315
+ }
1316
+ }
861
1317
 
1318
+ do {
1319
+ // Add decryption step if public key is set and sessionKey is provided
1320
+ var finalData = data
1321
+ if !self.publicKey.isEmpty && !sessionKey.isEmpty {
1322
+ let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
1323
+ try finalData.write(to: tempFile)
1324
+ do {
1325
+ try CryptoCipher.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
862
1326
  } catch {
863
- downloadError = error
864
- self.logger.error("Manifest file download failed")
865
- self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
1327
+ self.sendStats(action: "decrypt_fail", versionName: version)
1328
+ throw error
866
1329
  }
1330
+ finalData = try Data(contentsOf: tempFile)
1331
+ try FileManager.default.removeItem(at: tempFile)
1332
+ }
867
1333
 
868
- case .failure(let error):
869
- downloadError = error
870
- self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
871
- self.logger.error("Manifest file download network error")
872
- self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription), Response: \(response.debugDescription)")
1334
+ // Decompress Brotli if needed
1335
+ if isBrotli {
1336
+ guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
1337
+ self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
1338
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
1339
+ }
1340
+ finalData = decompressedData
873
1341
  }
874
- }
875
1342
 
876
- semaphore.wait()
1343
+ // Write to destination (replace if leftover from a previous failed download)
1344
+ try writeDataAtomically(finalData, to: destFilePath)
1345
+
1346
+ // Always verify checksum when file_hash is present
1347
+ let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
1348
+ CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
1349
+ CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
1350
+ if calculatedChecksum != fileHash {
1351
+ try? FileManager.default.removeItem(at: destFilePath)
1352
+ self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
1353
+ throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
1354
+ }
1355
+
1356
+ // Save to cache (replace stale cache entries from partial or concurrent downloads)
1357
+ try writeDataAtomically(finalData, to: cacheFilePath)
877
1358
 
878
- if let error = downloadError {
1359
+ self.logger.info("Manifest file downloaded and cached")
1360
+ self.logger.debug("Bundle: \(bundleId), File: \(fileName), Brotli: \(isBrotli), Encrypted: \(!self.publicKey.isEmpty && !sessionKey.isEmpty)")
1361
+ } catch {
1362
+ self.logger.error("Manifest file download failed")
1363
+ self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
879
1364
  throw error
880
1365
  }
881
1366
  }
882
1367
 
1368
+ /// Atomically write data to a file, replacing any existing file at the destination.
1369
+ private func writeDataAtomically(_ data: Data, to destination: URL) throws {
1370
+ let fileManager = FileManager.default
1371
+ let tempURL = destination.deletingLastPathComponent().appendingPathComponent("\(destination.lastPathComponent).\(UUID().uuidString).tmp")
1372
+ defer {
1373
+ try? fileManager.removeItem(at: tempURL)
1374
+ }
1375
+
1376
+ try data.write(to: tempURL, options: .atomic)
1377
+ if fileManager.fileExists(atPath: destination.path) {
1378
+ try fileManager.removeItem(at: destination)
1379
+ }
1380
+ try fileManager.moveItem(at: tempURL, to: destination)
1381
+ }
1382
+
1383
+ /// Copy a file to the destination, replacing any existing file.
1384
+ private func copyItemReplacing(from source: URL, to destination: URL) throws {
1385
+ let fileManager = FileManager.default
1386
+ if fileManager.fileExists(atPath: destination.path) {
1387
+ try fileManager.removeItem(at: destination)
1388
+ }
1389
+ try fileManager.copyItem(at: source, to: destination)
1390
+ }
1391
+
883
1392
  /// Atomically try to copy a file from cache - returns true if successful, false if file doesn't exist or copy failed
884
1393
  /// This handles the race condition where OS can delete cache files between exists() check and copy
885
1394
  private func tryCopyFromCache(from source: URL, to destination: URL, expectedHash: String) -> Bool {
1395
+ let fileManager = FileManager.default
1396
+
886
1397
  // First quick check - if file doesn't exist, don't bother
887
- guard FileManager.default.fileExists(atPath: source.path) else {
1398
+ guard fileManager.fileExists(atPath: source.path) else {
888
1399
  return false
889
1400
  }
890
1401
 
891
- // Verify checksum before copy
1402
+ // Verify checksum before copy; remove stale cache entries that would block re-download
892
1403
  guard verifyChecksum(file: source, expectedHash: expectedHash) else {
1404
+ try? fileManager.removeItem(at: source)
893
1405
  return false
894
1406
  }
895
1407
 
896
1408
  // Try to copy - if it fails (file deleted by OS between check and copy), return false
897
1409
  do {
898
- try FileManager.default.copyItem(at: source, to: destination)
1410
+ try copyItemReplacing(from: source, to: destination)
899
1411
  return true
900
1412
  } catch {
901
1413
  // File was deleted between check and copy, or other IO error - caller should download instead
@@ -1024,7 +1536,6 @@ import UIKit
1024
1536
 
1025
1537
  public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
1026
1538
  let id: String = self.randomString(length: 10)
1027
- let semaphore = DispatchSemaphore(value: 0)
1028
1539
  // Each download uses its own temp files keyed by bundle ID to prevent collisions
1029
1540
  if version != getLocalUpdateVersion(for: id) {
1030
1541
  cleanDownloadData(for: id)
@@ -1036,10 +1547,11 @@ import UIKit
1036
1547
  try checkDiskSpace()
1037
1548
 
1038
1549
  var checksum = ""
1039
- var targetSize = -1
1040
1550
  var lastSentProgress = 0
1041
- var totalReceivedBytes: Int64 = loadDownloadProgress(for: id) // Retrieving the amount of already downloaded data if exist, defined at 0 otherwise
1042
- let requestHeaders: HTTPHeaders = ["Range": "bytes=\(totalReceivedBytes)-"]
1551
+ let totalReceivedBytes: Int64 = loadDownloadProgress(for: id) // Retrieving the amount of already downloaded data if exist, defined at 0 otherwise
1552
+ let tempPath = tempDataPath(for: id)
1553
+ let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum, link: link, comment: comment)
1554
+ self.saveBundleInfo(id: id, bundle: bundleInfo)
1043
1555
 
1044
1556
  // Send stats for zip download start
1045
1557
  self.sendStats(action: "download_zip_start", versionName: version)
@@ -1049,66 +1561,61 @@ import UIKit
1049
1561
  self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
1050
1562
  }
1051
1563
  var mainError: NSError?
1052
- let monitor = ClosureEventMonitor()
1053
- monitor.requestDidCompleteTaskWithError = { (_, _, error) in
1054
- if error != nil {
1055
- self.logger.error("Downloading failed - ClosureEventMonitor activated")
1056
- mainError = error as NSError?
1057
- }
1058
- }
1059
- let configuration = URLSessionConfiguration.default
1060
- configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
1061
- let session = Session(configuration: configuration, eventMonitors: [monitor])
1062
1564
 
1063
- let request = session.streamRequest(url, headers: requestHeaders).validate().onHTTPResponse(perform: { response in
1064
- if let contentLength = response.headers.value(for: "Content-Length") {
1065
- targetSize = (Int(contentLength) ?? -1) + Int(totalReceivedBytes)
1066
- }
1067
- }).responseStream { [weak self] streamResponse in
1068
- guard let self = self else { return }
1069
- switch streamResponse.event {
1070
- case .stream(let result):
1071
- if case .success(let data) = result {
1072
- self.tempData.append(data)
1565
+ guard var request = createRequest(url: url, method: "GET") else {
1566
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
1567
+ throw NSError(
1568
+ domain: "DownloadError",
1569
+ code: 2,
1570
+ userInfo: [NSLocalizedDescriptionKey: "Invalid download request for \(url.absoluteString)"]
1571
+ )
1572
+ }
1073
1573
 
1074
- self.savePartialData(startingAt: UInt64(totalReceivedBytes), for: id) // Saving the received data in the package_<id>.tmp file
1075
- totalReceivedBytes += Int64(data.count)
1574
+ if totalReceivedBytes > 0 {
1575
+ request.setValue("bytes=\(totalReceivedBytes)-", forHTTPHeaderField: "Range")
1576
+ }
1076
1577
 
1077
- let percent = max(10, Int((Double(totalReceivedBytes) / Double(targetSize)) * 70.0))
1578
+ let downloadResult = performDownloadRequest(request, label: "download \(version)")
1078
1579
 
1079
- let currentMilestone = (percent / 10) * 10
1080
- if currentMilestone > lastSentProgress && currentMilestone <= 70 {
1081
- for milestone in stride(from: lastSentProgress + 10, through: currentMilestone, by: 10) {
1082
- self.notifyDownload(id: id, percent: milestone, ignoreMultipleOfTen: false)
1083
- }
1084
- lastSentProgress = currentMilestone
1085
- }
1580
+ if downloadResult.timedOut {
1581
+ persistPartialDownload(downloadResult, id: id, tempPath: tempPath, existingBytes: totalReceivedBytes)
1582
+ mainError = NSError(
1583
+ domain: NSURLErrorDomain,
1584
+ code: NSURLErrorTimedOut,
1585
+ userInfo: [NSLocalizedDescriptionKey: "Timed out downloading bundle from \(url.absoluteString)"]
1586
+ )
1587
+ } else if let error = downloadResult.error {
1588
+ logger.error("Download failed")
1589
+ persistPartialDownload(downloadResult, id: id, tempPath: tempPath, existingBytes: totalReceivedBytes)
1590
+ mainError = error as NSError
1591
+ } else if let statusCode = downloadResult.response?.statusCode, statusCode < 200 || statusCode >= 300 {
1592
+ logger.error("Download failed")
1593
+ mainError = NSError(
1594
+ domain: "DownloadError",
1595
+ code: statusCode,
1596
+ userInfo: [NSLocalizedDescriptionKey: "Download request failed with status code \(statusCode)"]
1597
+ )
1598
+ } else if let downloadedFileURL = downloadResult.fileURL {
1599
+ do {
1600
+ try storeDownloadedFile(downloadedFileURL, at: tempPath, existingBytes: totalReceivedBytes, response: downloadResult.response)
1086
1601
 
1087
- } else {
1088
- self.logger.error("Download failed")
1602
+ if lastSentProgress < 70 {
1603
+ self.notifyDownload(id: id, percent: 70, ignoreMultipleOfTen: true)
1604
+ lastSentProgress = 70
1089
1605
  }
1090
-
1091
- case .complete:
1092
- self.logger.info("Download complete, total received bytes: \(totalReceivedBytes)")
1093
- self.notifyDownload(id: id, percent: 70, ignoreMultipleOfTen: true)
1094
- semaphore.signal()
1095
- }
1096
- }
1097
- self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum, link: link, comment: comment))
1098
- let reachabilityManager = NetworkReachabilityManager()
1099
- reachabilityManager?.startListening { status in
1100
- switch status {
1101
- case .notReachable:
1102
- // Stop the download request if the network is not reachable
1103
- request.cancel()
1104
- mainError = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet, userInfo: nil)
1105
- semaphore.signal()
1106
- default:
1107
- break
1606
+ self.logger.info("Download complete")
1607
+ } catch let error as NSError {
1608
+ mainError = error
1609
+ } catch {
1610
+ mainError = error as NSError
1108
1611
  }
1612
+ } else {
1613
+ mainError = NSError(
1614
+ domain: "DownloadError",
1615
+ code: 1,
1616
+ userInfo: [NSLocalizedDescriptionKey: "Downloaded file is missing at \(tempPath.path)"]
1617
+ )
1109
1618
  }
1110
- semaphore.wait()
1111
- reachabilityManager?.stopListening()
1112
1619
 
1113
1620
  if mainError != nil {
1114
1621
  logger.error("Failed to download bundle")
@@ -1117,7 +1624,6 @@ import UIKit
1117
1624
  throw mainError!
1118
1625
  }
1119
1626
 
1120
- let tempPath = tempDataPath(for: id)
1121
1627
  let finalPath = tempPath.deletingLastPathComponent().appendingPathComponent("\(id)")
1122
1628
  do {
1123
1629
  try CryptoCipher.decryptFile(filePath: tempPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
@@ -1312,6 +1818,15 @@ import UIKit
1312
1818
  return false
1313
1819
  }
1314
1820
 
1821
+ if let previewFallback = self.getPreviewFallbackBundle(),
1822
+ !previewFallback.isDeleted(),
1823
+ !previewFallback.isErrorStatus(),
1824
+ previewFallback.getId() == id {
1825
+ logger.info("Cannot delete the preview fallback bundle")
1826
+ logger.debug("Bundle ID: \(id)")
1827
+ return false
1828
+ }
1829
+
1315
1830
  // Check if this is the next bundle and prevent deletion if it is
1316
1831
  if let next = self.getNextBundle(),
1317
1832
  !next.isDeleted() &&
@@ -1338,7 +1853,7 @@ import UIKit
1338
1853
  if removeInfo {
1339
1854
  self.removeBundleInfo(id: id)
1340
1855
  } else {
1341
- self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.localizedString))
1856
+ self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.storedValue))
1342
1857
  }
1343
1858
  logger.info("Bundle deleted successfully")
1344
1859
  logger.debug("Version: \(deleted.getVersionName())")
@@ -1596,6 +2111,21 @@ import UIKit
1596
2111
  return true
1597
2112
  }
1598
2113
 
2114
+ func stagePreviewFallbackReload(bundle: BundleInfo) -> Bool {
2115
+ guard !bundle.isErrorStatus() else {
2116
+ return false
2117
+ }
2118
+ if bundle.isBuiltin() {
2119
+ self.setCurrentBundle(bundle: self.DEFAULT_FOLDER)
2120
+ return true
2121
+ }
2122
+ guard bundleExists(id: bundle.getId()) else {
2123
+ return false
2124
+ }
2125
+ self.setCurrentBundle(bundle: self.getBundleDirectory(id: bundle.getId()).path)
2126
+ return true
2127
+ }
2128
+
1599
2129
  func finalizePendingReload(bundle: BundleInfo, previousBundleName: String) {
1600
2130
  guard !bundle.isBuiltin() else {
1601
2131
  return
@@ -1635,9 +2165,11 @@ import UIKit
1635
2165
  public func setSuccess(bundle: BundleInfo, autoDeletePrevious: Bool) {
1636
2166
  self.setBundleStatus(id: bundle.getId(), status: BundleStatus.SUCCESS)
1637
2167
  let fallback: BundleInfo = self.getFallbackBundle()
2168
+ let previewFallback = self.getPreviewFallbackBundle()
2169
+ let fallbackIsPreviewFallback = previewFallback?.getId() == fallback.getId()
1638
2170
  logger.info("Fallback bundle is: \(fallback.toString())")
1639
2171
  logger.info("Version successfully loaded: \(bundle.toString())")
1640
- if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() {
2172
+ if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() && !fallbackIsPreviewFallback {
1641
2173
  let res = self.delete(id: fallback.getId())
1642
2174
  if res {
1643
2175
  logger.info("Deleted previous bundle")
@@ -1668,7 +2200,12 @@ import UIKit
1668
2200
  return setChannel
1669
2201
  }
1670
2202
 
1671
- func setChannel(channel: String, defaultChannelKey: String, allowSetDefaultChannel: Bool) -> SetChannel {
2203
+ func setChannel(
2204
+ channel: String,
2205
+ defaultChannelKey: String,
2206
+ allowSetDefaultChannel: Bool,
2207
+ configDefaultChannel: String = ""
2208
+ ) -> SetChannel {
1672
2209
  let setChannel: SetChannel = SetChannel()
1673
2210
 
1674
2211
  // Check if setting defaultChannel is allowed
@@ -1693,58 +2230,79 @@ import UIKit
1693
2230
  setChannel.error = "missing_config"
1694
2231
  return setChannel
1695
2232
  }
1696
- let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
2233
+ guard let channelURL = URL(string: self.channelUrl) else {
2234
+ logger.error("Invalid channel URL")
2235
+ setChannel.message = "Channel URL is invalid"
2236
+ setChannel.error = "invalid_config"
2237
+ return setChannel
2238
+ }
1697
2239
  var parameters: InfoObject = self.createInfoObject()
1698
2240
  parameters.channel = channel
2241
+ guard let request = createRequest(url: channelURL, method: "POST", parameters: parameters.toParameters()) else {
2242
+ setChannel.error = "Request failed: invalid request"
2243
+ return setChannel
2244
+ }
1699
2245
 
1700
- let request = alamofireSession.request(self.channelUrl, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
2246
+ let result = performRequest(request, label: "setChannel")
1701
2247
 
1702
- request.validate().responseDecodable(of: SetChannelDec.self) { response in
1703
- // Check for 429 rate limit
1704
- if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1705
- setChannel.message = "Rate limit exceeded"
1706
- setChannel.error = "rate_limit_exceeded"
1707
- semaphore.signal()
1708
- return
1709
- }
2248
+ if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
2249
+ setChannel.message = "Rate limit exceeded"
2250
+ setChannel.error = "rate_limit_exceeded"
2251
+ return setChannel
2252
+ }
1710
2253
 
1711
- switch response.result {
1712
- case .success:
1713
- if let responseValue = response.value {
1714
- if let error = responseValue.error {
1715
- setChannel.error = error
1716
- } else if responseValue.unset == true {
1717
- // Server requested to unset channel (public channel was requested)
1718
- // Clear persisted defaultChannel and revert to config value
1719
- UserDefaults.standard.removeObject(forKey: defaultChannelKey)
1720
- UserDefaults.standard.synchronize()
1721
- self.logger.info("Public channel requested, channel override removed")
1722
-
1723
- setChannel.status = responseValue.status ?? "ok"
1724
- setChannel.message = responseValue.message ?? "Public channel requested, channel override removed. Device will use public channel automatically."
1725
- } else {
1726
- // Success - persist defaultChannel
1727
- self.defaultChannel = channel
1728
- UserDefaults.standard.set(channel, forKey: defaultChannelKey)
1729
- UserDefaults.standard.synchronize()
1730
- self.logger.info("defaultChannel persisted locally: \(channel)")
1731
-
1732
- setChannel.status = responseValue.status ?? ""
1733
- setChannel.message = responseValue.message ?? ""
1734
- }
1735
- }
1736
- case let .failure(error):
1737
- self.logger.error("Error setting channel")
1738
- self.logger.debug("Error: \(error)")
1739
- setChannel.error = "Request failed: \(error.localizedDescription)"
1740
- }
1741
- semaphore.signal()
2254
+ if result.timedOut {
2255
+ setChannel.error = "Request timed out"
2256
+ return setChannel
2257
+ }
2258
+
2259
+ if let error = result.error {
2260
+ self.logger.error("Error setting channel")
2261
+ self.logger.debug("Error: \(error.localizedDescription)")
2262
+ setChannel.error = "Request failed: \(error.localizedDescription)"
2263
+ return setChannel
2264
+ }
2265
+
2266
+ guard let data = result.data else {
2267
+ setChannel.error = "Request failed: empty response"
2268
+ return setChannel
2269
+ }
2270
+
2271
+ guard let responseValue = try? JSONDecoder().decode(SetChannelDec.self, from: data) else {
2272
+ setChannel.error = "decode_error"
2273
+ return setChannel
2274
+ }
2275
+
2276
+ let statusCode = result.response?.statusCode ?? 0
2277
+ if statusCode < 200 || statusCode >= 300 {
2278
+ setChannel.message = responseValue.message ?? "Server error: \(statusCode)"
2279
+ setChannel.error = responseValue.error ?? "response_error"
2280
+ return setChannel
2281
+ }
2282
+
2283
+ if let error = responseValue.error {
2284
+ setChannel.error = error
2285
+ } else if responseValue.unset == true {
2286
+ UserDefaults.standard.removeObject(forKey: defaultChannelKey)
2287
+ UserDefaults.standard.synchronize()
2288
+ self.defaultChannel = configDefaultChannel
2289
+ self.logger.info("Public channel requested, channel override removed")
2290
+
2291
+ setChannel.status = responseValue.status ?? "ok"
2292
+ setChannel.message = responseValue.message ?? "Public channel requested, channel override removed. Device will use public channel automatically."
2293
+ } else {
2294
+ self.defaultChannel = channel
2295
+ UserDefaults.standard.set(channel, forKey: defaultChannelKey)
2296
+ UserDefaults.standard.synchronize()
2297
+ self.logger.info("defaultChannel persisted locally: \(channel)")
2298
+
2299
+ setChannel.status = responseValue.status ?? ""
2300
+ setChannel.message = responseValue.message ?? ""
1742
2301
  }
1743
- semaphore.wait()
1744
2302
  return setChannel
1745
2303
  }
1746
2304
 
1747
- func getChannel() -> GetChannel {
2305
+ func getChannel(defaultChannelKey: String? = nil) -> GetChannel {
1748
2306
  let getChannel: GetChannel = GetChannel()
1749
2307
 
1750
2308
  // Check if rate limit was exceeded
@@ -1761,52 +2319,96 @@ import UIKit
1761
2319
  getChannel.error = "missing_config"
1762
2320
  return getChannel
1763
2321
  }
1764
- let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
2322
+ guard let channelURL = URL(string: self.channelUrl) else {
2323
+ logger.error("Invalid channel URL")
2324
+ getChannel.message = "Channel URL is invalid"
2325
+ getChannel.error = "invalid_config"
2326
+ return getChannel
2327
+ }
1765
2328
  let parameters: InfoObject = self.createInfoObject()
1766
- let request = alamofireSession.request(self.channelUrl, method: .put, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
2329
+ guard let request = createRequest(url: channelURL, method: "PUT", parameters: parameters.toParameters()) else {
2330
+ getChannel.error = "Request failed: invalid request"
2331
+ return getChannel
2332
+ }
1767
2333
 
1768
- request.validate().responseDecodable(of: GetChannelDec.self) { response in
1769
- defer {
1770
- semaphore.signal()
1771
- }
2334
+ let result = performRequest(request, label: "getChannel")
1772
2335
 
1773
- // Check for 429 rate limit
1774
- if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1775
- getChannel.message = "Rate limit exceeded"
1776
- getChannel.error = "rate_limit_exceeded"
1777
- return
1778
- }
2336
+ if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
2337
+ getChannel.message = "Rate limit exceeded"
2338
+ getChannel.error = "rate_limit_exceeded"
2339
+ return getChannel
2340
+ }
1779
2341
 
1780
- switch response.result {
1781
- case .success:
1782
- if let responseValue = response.value {
1783
- if let error = responseValue.error {
1784
- getChannel.error = error
1785
- } else {
1786
- getChannel.status = responseValue.status ?? ""
1787
- getChannel.message = responseValue.message ?? ""
1788
- getChannel.channel = responseValue.channel ?? ""
1789
- getChannel.allowSet = responseValue.allowSet ?? true
1790
- }
1791
- }
1792
- case let .failure(error):
1793
- if let data = response.data, let bodyString = String(data: data, encoding: .utf8) {
1794
- if bodyString.contains("channel_not_found") && response.response?.statusCode == 400 && !self.defaultChannel.isEmpty {
1795
- getChannel.channel = self.defaultChannel
1796
- getChannel.status = "default"
1797
- return
1798
- }
2342
+ if result.timedOut {
2343
+ getChannel.error = "Request timed out"
2344
+ return getChannel
2345
+ }
2346
+
2347
+ if let error = result.error {
2348
+ if let data = result.data, let bodyString = String(data: data, encoding: .utf8) {
2349
+ if bodyString.contains("channel_not_found") && result.response?.statusCode == 400 && !self.defaultChannel.isEmpty {
2350
+ getChannel.channel = self.defaultChannel
2351
+ getChannel.status = "default"
2352
+ return getChannel
1799
2353
  }
2354
+ }
2355
+
2356
+ self.logger.error("Error getting channel")
2357
+ self.logger.debug("Error: \(error.localizedDescription)")
2358
+ getChannel.error = "Request failed: \(error.localizedDescription)"
2359
+ return getChannel
2360
+ }
2361
+
2362
+ guard let data = result.data else {
2363
+ getChannel.error = "Request failed: empty response"
2364
+ return getChannel
2365
+ }
1800
2366
 
1801
- self.logger.error("Error getting channel")
1802
- self.logger.debug("Error: \(error)")
1803
- getChannel.error = "Request failed: \(error.localizedDescription)"
2367
+ guard let responseValue = try? JSONDecoder().decode(GetChannelDec.self, from: data) else {
2368
+ getChannel.error = "decode_error"
2369
+ return getChannel
2370
+ }
2371
+
2372
+ let statusCode = result.response?.statusCode ?? 0
2373
+ if let error = responseValue.error {
2374
+ if error == "channel_not_found", statusCode == 400, !self.defaultChannel.isEmpty {
2375
+ getChannel.channel = self.defaultChannel
2376
+ getChannel.status = "default"
2377
+ return getChannel
1804
2378
  }
2379
+ getChannel.error = error
2380
+ getChannel.message = responseValue.message ?? ""
2381
+ return getChannel
2382
+ }
2383
+
2384
+ if statusCode < 200 || statusCode >= 300 {
2385
+ getChannel.message = responseValue.message ?? "Server error: \(statusCode)"
2386
+ getChannel.error = "response_error"
2387
+ } else {
2388
+ getChannel.status = responseValue.status ?? ""
2389
+ getChannel.message = responseValue.message ?? ""
2390
+ getChannel.channel = responseValue.channel ?? ""
2391
+ getChannel.allowSet = responseValue.allowSet ?? true
2392
+ persistDefaultChannelFromResponse(channel: responseValue.channel, defaultChannelKey: defaultChannelKey)
1805
2393
  }
1806
- semaphore.wait()
1807
2394
  return getChannel
1808
2395
  }
1809
2396
 
2397
+ func persistDefaultChannelFromResponse(channel: String?, defaultChannelKey: String?) {
2398
+ guard let channelName = channel?.trimmingCharacters(in: .whitespacesAndNewlines),
2399
+ !channelName.isEmpty,
2400
+ channelName != BundleInfo.ID_BUILTIN else {
2401
+ return
2402
+ }
2403
+
2404
+ self.defaultChannel = channelName
2405
+ if let defaultChannelKey, !defaultChannelKey.isEmpty {
2406
+ UserDefaults.standard.set(channelName, forKey: defaultChannelKey)
2407
+ UserDefaults.standard.synchronize()
2408
+ }
2409
+ logger.info("defaultChannel synchronized from getChannel(): \(channelName)")
2410
+ }
2411
+
1810
2412
  func listChannels() -> ListChannels {
1811
2413
  let listChannels: ListChannels = ListChannels()
1812
2414
 
@@ -1823,27 +2425,13 @@ import UIKit
1823
2425
  return listChannels
1824
2426
  }
1825
2427
 
1826
- let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
1827
-
1828
2428
  // Create info object and convert to query parameters
1829
2429
  let infoObject = self.createInfoObject()
1830
-
1831
- // Create query parameters from InfoObject
1832
2430
  var urlComponents = URLComponents(string: self.channelUrl)
1833
- var queryItems: [URLQueryItem] = []
1834
-
1835
- // Convert InfoObject to dictionary using Mirror
1836
- let mirror = Mirror(reflecting: infoObject)
1837
- for child in mirror.children {
1838
- if let key = child.label, let value = child.value as? CustomStringConvertible {
1839
- queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
1840
- } else if let key = child.label {
1841
- // Handle optional values
1842
- let mirror = Mirror(reflecting: child.value)
1843
- if let value = mirror.children.first?.value {
1844
- queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
1845
- }
1846
- }
2431
+ var queryItems: [URLQueryItem] = urlComponents?.queryItems ?? []
2432
+
2433
+ for (key, value) in infoObject.toParameters() {
2434
+ queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
1847
2435
  }
1848
2436
 
1849
2437
  urlComponents?.queryItems = queryItems
@@ -1854,47 +2442,62 @@ import UIKit
1854
2442
  return listChannels
1855
2443
  }
1856
2444
 
1857
- let request = alamofireSession.request(url, method: .get, requestModifier: { $0.timeoutInterval = self.timeout })
2445
+ guard let request = createRequest(url: url, method: "GET", expectsJSONResponse: true) else {
2446
+ listChannels.error = "Invalid channel URL"
2447
+ return listChannels
2448
+ }
1858
2449
 
1859
- request.validate().responseDecodable(of: ListChannelsDec.self) { response in
1860
- defer {
1861
- semaphore.signal()
1862
- }
2450
+ let result = performRequest(request, label: "listChannels")
1863
2451
 
1864
- // Check for 429 rate limit
1865
- if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1866
- listChannels.error = "rate_limit_exceeded"
1867
- return
1868
- }
2452
+ if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
2453
+ listChannels.error = "rate_limit_exceeded"
2454
+ return listChannels
2455
+ }
1869
2456
 
1870
- switch response.result {
1871
- case .success:
1872
- if let responseValue = response.value {
1873
- // Check for server-side errors
1874
- if let error = responseValue.error {
1875
- listChannels.error = error
1876
- return
1877
- }
2457
+ if result.timedOut {
2458
+ listChannels.error = "Request timed out"
2459
+ return listChannels
2460
+ }
1878
2461
 
1879
- // Backend returns direct array, so channels should be populated by our custom decoder
1880
- if let channels = responseValue.channels {
1881
- listChannels.channels = channels.map { channel in
1882
- var channelDict: [String: Any] = [:]
1883
- channelDict["id"] = channel.id ?? ""
1884
- channelDict["name"] = channel.name ?? ""
1885
- channelDict["public"] = channel.public ?? false
1886
- channelDict["allow_self_set"] = channel.allow_self_set ?? false
1887
- return channelDict
1888
- }
1889
- }
1890
- }
1891
- case let .failure(error):
1892
- self.logger.error("Error listing channels")
1893
- self.logger.debug("Error: \(error)")
1894
- listChannels.error = "Request failed: \(error.localizedDescription)"
2462
+ if let error = result.error {
2463
+ self.logger.error("Error listing channels")
2464
+ self.logger.debug("Error: \(error.localizedDescription)")
2465
+ listChannels.error = "Request failed: \(error.localizedDescription)"
2466
+ return listChannels
2467
+ }
2468
+
2469
+ guard let data = result.data else {
2470
+ listChannels.error = "Request failed: empty response"
2471
+ return listChannels
2472
+ }
2473
+
2474
+ guard let responseValue = try? JSONDecoder().decode(ListChannelsDec.self, from: data) else {
2475
+ listChannels.error = "decode_error"
2476
+ return listChannels
2477
+ }
2478
+
2479
+ let statusCode = result.response?.statusCode ?? 0
2480
+ if let error = responseValue.error {
2481
+ listChannels.error = error
2482
+ return listChannels
2483
+ }
2484
+
2485
+ if statusCode < 200 || statusCode >= 300 {
2486
+ listChannels.error = "response_error"
2487
+ return listChannels
2488
+ }
2489
+
2490
+ if let channels = responseValue.channels {
2491
+ listChannels.channels = channels.map { channel in
2492
+ var channelDict: [String: Any] = [:]
2493
+ channelDict["id"] = channel.id
2494
+ channelDict["name"] = channel.name ?? ""
2495
+ channelDict["public"] = channel.public ?? false
2496
+ channelDict["allow_self_set"] = channel.allow_self_set ?? false
2497
+ return channelDict
1895
2498
  }
1896
2499
  }
1897
- semaphore.wait()
2500
+
1898
2501
  return listChannels
1899
2502
  }
1900
2503
 
@@ -1908,6 +2511,29 @@ import UIKit
1908
2511
  }()
1909
2512
 
1910
2513
  func sendStats(action: String, versionName: String? = nil, oldVersionName: String? = "") {
2514
+ sendStatsWithMetadata(action: action, versionName: versionName, oldVersionName: oldVersionName, metadata: nil, onSent: nil)
2515
+ }
2516
+
2517
+ func sendStats(action: String, versionName: String?, oldVersionName: String?, metadata: [String: String]) {
2518
+ sendStatsWithMetadata(action: action, versionName: versionName, oldVersionName: oldVersionName, metadata: metadata, onSent: nil)
2519
+ }
2520
+
2521
+ func sendStats(action: String, versionName: String?, oldVersionName: String?, metadata: [String: String], onSent: @escaping () -> Void) {
2522
+ sendStatsWithMetadata(action: action, versionName: versionName, oldVersionName: oldVersionName, metadata: metadata, onSent: onSent)
2523
+ }
2524
+
2525
+ private func sendStatsWithMetadata(
2526
+ action: String,
2527
+ versionName: String?,
2528
+ oldVersionName: String?,
2529
+ metadata: [String: String]?,
2530
+ onSent: (() -> Void)?
2531
+ ) {
2532
+ if previewSession {
2533
+ logger.debug("Skipping sendStats during preview session.")
2534
+ return
2535
+ }
2536
+
1911
2537
  // Check if rate limit was exceeded
1912
2538
  if CapgoUpdater.rateLimitExceeded {
1913
2539
  logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.")
@@ -1934,15 +2560,17 @@ import UIKit
1934
2560
  plugin_version: info.plugin_version,
1935
2561
  is_emulator: info.is_emulator,
1936
2562
  is_prod: info.is_prod,
2563
+ installSource: info.installSource,
1937
2564
  action: action,
1938
2565
  channel: info.channel,
1939
2566
  defaultChannel: info.defaultChannel,
1940
2567
  key_id: info.key_id,
2568
+ metadata: metadata,
1941
2569
  timestamp: Int64(Date().timeIntervalSince1970 * 1000)
1942
2570
  )
1943
2571
 
1944
2572
  statsQueueLock.lock()
1945
- statsQueue.append(event)
2573
+ statsQueue.append(QueuedStatsEvent(event: event, onSent: onSent))
1946
2574
  statsQueueLock.unlock()
1947
2575
 
1948
2576
  ensureStatsTimerStarted()
@@ -1969,10 +2597,13 @@ import UIKit
1969
2597
  statsQueueLock.unlock()
1970
2598
  return
1971
2599
  }
1972
- let eventsToSend = statsQueue
2600
+ let queuedEvents = statsQueue
1973
2601
  statsQueue.removeAll()
1974
2602
  statsQueueLock.unlock()
1975
2603
 
2604
+ let eventsToSend = queuedEvents.map(\.event)
2605
+ let onSentCallbacks = queuedEvents.compactMap(\.onSent)
2606
+
1976
2607
  operationQueue.maxConcurrentOperationCount = 1
1977
2608
 
1978
2609
  let operation = BlockOperation {
@@ -1990,10 +2621,18 @@ import UIKit
1990
2621
  return
1991
2622
  }
1992
2623
 
2624
+ if let statusCode = response.response?.statusCode, !(200...299).contains(statusCode) {
2625
+ self.logger.error("Error sending stats batch")
2626
+ self.logger.debug("Response code: \(statusCode)")
2627
+ semaphore.signal()
2628
+ return
2629
+ }
2630
+
1993
2631
  switch response.result {
1994
2632
  case .success:
1995
2633
  self.logger.info("Stats batch sent successfully")
1996
2634
  self.logger.debug("Sent \(eventsToSend.count) events")
2635
+ onSentCallbacks.forEach { $0() }
1997
2636
  case let .failure(error):
1998
2637
  self.logger.error("Error sending stats batch")
1999
2638
  self.logger.debug("Response: \(response.value?.debugDescription ?? "nil"), Error: \(error.localizedDescription)")
@@ -2059,13 +2698,12 @@ import UIKit
2059
2698
  logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
2060
2699
  }
2061
2700
  }
2062
- UserDefaults.standard.synchronize()
2063
2701
  }
2064
2702
 
2065
2703
  private func setBundleStatus(id: String, status: BundleStatus) {
2066
2704
  logger.info("Setting status for bundle [\(id)] to \(status)")
2067
2705
  let info = self.getBundleInfo(id: id)
2068
- self.saveBundleInfo(id: id, bundle: info.setStatus(status: status.localizedString))
2706
+ self.saveBundleInfo(id: id, bundle: info.setStatus(status: status.storedValue))
2069
2707
  }
2070
2708
 
2071
2709
  public func getCurrentBundle() -> BundleInfo {
@@ -2102,6 +2740,33 @@ import UIKit
2102
2740
  return self.getBundleInfo(id: id)
2103
2741
  }
2104
2742
 
2743
+ public func getPreviewFallbackBundle() -> BundleInfo? {
2744
+ guard let id = UserDefaults.standard.string(forKey: self.PREVIEW_FALLBACK_VERSION) else {
2745
+ return nil
2746
+ }
2747
+ let bundle = self.getBundleInfo(id: id)
2748
+ if !bundle.isBuiltin() && !self.bundleExists(id: id) {
2749
+ _ = self.setPreviewFallbackBundle(fallback: nil)
2750
+ return nil
2751
+ }
2752
+ return bundle
2753
+ }
2754
+
2755
+ public func setPreviewFallbackBundle(fallback: String?) -> Bool {
2756
+ guard let fallbackId = fallback else {
2757
+ UserDefaults.standard.removeObject(forKey: self.PREVIEW_FALLBACK_VERSION)
2758
+ UserDefaults.standard.synchronize()
2759
+ return true
2760
+ }
2761
+ let newBundle: BundleInfo = self.getBundleInfo(id: fallbackId)
2762
+ if !newBundle.isBuiltin() && !self.bundleExists(id: fallbackId) {
2763
+ return false
2764
+ }
2765
+ UserDefaults.standard.set(fallbackId, forKey: self.PREVIEW_FALLBACK_VERSION)
2766
+ UserDefaults.standard.synchronize()
2767
+ return true
2768
+ }
2769
+
2105
2770
  public func setNextBundle(next: String?) -> Bool {
2106
2771
  guard let nextId: String = next else {
2107
2772
  UserDefaults.standard.removeObject(forKey: self.NEXT_VERSION)