@capgo/capacitor-updater 7.43.3 → 7.50.1

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 (36) hide show
  1. package/Package.swift +5 -2
  2. package/README.md +643 -115
  3. package/android/build.gradle +3 -3
  4. package/android/src/main/java/ee/forgr/capacitor_updater/AndroidAppExitReporter.java +92 -0
  5. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +3121 -353
  6. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +924 -290
  7. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +49 -13
  8. package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +45 -30
  9. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +75 -32
  10. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +2 -0
  11. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +3 -3
  12. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +401 -27
  13. package/android/src/main/java/ee/forgr/capacitor_updater/ThreeFingerPinchDetector.java +323 -0
  14. package/dist/docs.json +1590 -196
  15. package/dist/esm/definitions.d.ts +755 -66
  16. package/dist/esm/definitions.js.map +1 -1
  17. package/dist/esm/web.d.ts +11 -2
  18. package/dist/esm/web.js +59 -5
  19. package/dist/esm/web.js.map +1 -1
  20. package/dist/plugin.cjs.js +59 -5
  21. package/dist/plugin.cjs.js.map +1 -1
  22. package/dist/plugin.js +59 -5
  23. package/dist/plugin.js.map +1 -1
  24. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +0 -1
  25. package/ios/Sources/CapacitorUpdaterPlugin/AppHealthTracker.swift +82 -0
  26. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +0 -16
  27. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +2 -2
  28. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +78 -2
  29. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +2732 -417
  30. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1191 -363
  31. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +0 -1
  32. package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +37 -16
  33. package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +80 -1
  34. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +438 -39
  35. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +276 -0
  36. package/package.json +24 -9
@@ -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,33 +51,272 @@ 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
+
64
+ private static func sanitizeHeaderValue(_ value: String) -> String {
65
+ if value.isEmpty {
66
+ return "unknown"
67
+ }
68
+
69
+ let filteredScalars = value.unicodeScalars.filter { scalar in
70
+ let cp = scalar.value
71
+ let isVisibleAscii = (0x20...0x7E).contains(cp)
72
+ let isIso88591 = (0xA0...0xFF).contains(cp)
73
+ return isVisibleAscii || isIso88591
74
+ }
75
+
76
+ let sanitized = String(String.UnicodeScalarView(filteredScalars)).trimmingCharacters(in: .whitespacesAndNewlines)
77
+ return sanitized.isEmpty ? "unknown" : sanitized
78
+ }
79
+
80
+ static func buildUserAgent(appId: String, pluginVersion: String, versionOs: String) -> String {
81
+ let safePluginVersion = sanitizeHeaderValue(pluginVersion)
82
+ let safeAppId = sanitizeHeaderValue(appId)
83
+ let safeVersionOs = sanitizeHeaderValue(versionOs)
84
+ return "CapacitorUpdater/\(safePluginVersion) (\(safeAppId)) ios/\(safeVersionOs)"
85
+ }
86
+
57
87
  private var userAgent: String {
58
- let safePluginVersion = pluginVersion.isEmpty ? "unknown" : pluginVersion
59
- let safeAppId = appId.isEmpty ? "unknown" : appId
60
- return "CapacitorUpdater/\(safePluginVersion) (\(safeAppId)) ios/\(versionOs)"
88
+ CapgoUpdater.buildUserAgent(appId: appId, pluginVersion: pluginVersion, versionOs: versionOs)
89
+ }
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
61
148
  }
62
149
 
63
150
  private lazy var alamofireSession: Session = {
64
- let configuration = URLSessionConfiguration.default
151
+ let configuration = URLSessionConfiguration.ephemeral
65
152
  configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
153
+ configuration.httpCookieStorage = nil
154
+ configuration.httpShouldSetCookies = false
155
+ configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
156
+ configuration.urlCache = nil
66
157
  return Session(configuration: configuration)
67
158
  }()
159
+ private let networkResponseQueue = DispatchQueue(label: "ee.forgr.capacitor-updater.network-response", qos: .utility)
68
160
 
69
161
  public var notifyDownloadRaw: (String, Int, Bool, BundleInfo?) -> Void = { _, _, _, _ in }
70
162
  public func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
71
- 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
+ }
72
173
  }
73
174
  public var notifyDownload: (String, Int) -> Void = { _, _ in }
175
+ public var notifyListeners: (String, [String: Any]) -> Void = { _, _ in }
74
176
 
75
177
  public func setLogger(_ logger: Logger) {
76
178
  self.logger = logger
77
179
  }
78
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
+
79
320
  deinit {
80
321
  // Invalidate the stats timer to prevent memory leaks
81
322
  statsFlushTimer?.invalidate()
@@ -127,6 +368,22 @@ import UIKit
127
368
  return !self.isDevEnvironment && !self.isAppStoreReceiptSandbox() && !self.hasEmbeddedMobileProvision()
128
369
  }
129
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
+
130
387
  /**
131
388
  * Checks if there is sufficient disk space for a download.
132
389
  * Matches Android behavior: 2x safety margin, throws "insufficient_disk_space"
@@ -166,7 +423,7 @@ import UIKit
166
423
  if statusCode == 429 {
167
424
  // Send a statistic about the rate limit BEFORE setting the flag
168
425
  // Only send once to prevent infinite loop if the stat request itself gets rate limited
169
- if !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
426
+ if !previewSession && !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
170
427
  CapgoUpdater.rateLimitStatisticSent = true
171
428
 
172
429
  // Dispatch to background queue to avoid blocking the main thread
@@ -202,8 +459,8 @@ import UIKit
202
459
  self.alamofireSession.request(
203
460
  self.statsUrl,
204
461
  method: .post,
205
- parameters: parameters,
206
- encoder: JSONParameterEncoder.default,
462
+ parameters: parameters.toParameters(),
463
+ encoding: JSONEncoding.default,
207
464
  requestModifier: { $0.timeoutInterval = self.timeout }
208
465
  ).responseData { response in
209
466
  switch response.result {
@@ -292,23 +549,76 @@ import UIKit
292
549
  }
293
550
  }
294
551
 
295
- private func validateZipEntry(path: String, destUnZip: URL) throws {
296
- // Check for Windows paths
297
- 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 {
298
556
  logger.error("Unzip failed: Windows path not supported")
299
557
  logger.debug("Invalid path: \(path)")
300
558
  self.sendStats(action: "windows_path_fail")
301
559
  throw CustomError.cannotUnzip
560
+ } catch {
561
+ self.sendStats(action: "canonical_path_fail")
562
+ throw CustomError.cannotUnzip
302
563
  }
564
+ }
303
565
 
304
- // Check for path traversal
305
- let fileURL = destUnZip.appendingPathComponent(path)
306
- let canonicalPath = fileURL.standardizedFileURL.path
307
- let canonicalDir = destUnZip.standardizedFileURL.path
566
+ private func extractZipEntry(_ archive: Archive, entry: Entry, to destPath: URL) throws {
567
+ let fileManager = FileManager.default
308
568
 
309
- if !canonicalPath.hasPrefix(canonicalDir) {
310
- self.sendStats(action: "canonical_path_fail")
311
- 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)
312
622
  }
313
623
  }
314
624
 
@@ -338,10 +648,20 @@ import UIKit
338
648
 
339
649
  do {
340
650
  for entry in archive {
341
- // Validate entry path for security
342
- try validateZipEntry(path: entry.path, destUnZip: destUnZip)
343
-
344
- 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
+ }
345
665
 
346
666
  // Create parent directories if needed
347
667
  let parentDir = destPath.deletingLastPathComponent()
@@ -349,8 +669,7 @@ import UIKit
349
669
  try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)
350
670
  }
351
671
 
352
- // Extract the entry
353
- _ = try archive.extract(entry, to: destPath, skipCRC32: true)
672
+ try self.extractZipEntry(archive, entry: entry, to: destPath)
354
673
 
355
674
  // Update progress
356
675
  processedEntries += 1
@@ -433,11 +752,11 @@ import UIKit
433
752
  }
434
753
  }
435
754
 
436
- private func createInfoObject() -> InfoObject {
755
+ private func createInfoObject(appIdOverride: String? = nil) -> InfoObject {
437
756
  return InfoObject(
438
757
  platform: "ios",
439
758
  device_id: self.deviceID,
440
- app_id: self.appId,
759
+ app_id: appIdOverride ?? self.appId,
441
760
  custom_id: self.customId,
442
761
  version_build: self.versionBuild,
443
762
  version_code: self.versionCode,
@@ -446,6 +765,7 @@ import UIKit
446
765
  plugin_version: self.pluginVersion,
447
766
  is_emulator: self.isEmulator(),
448
767
  is_prod: self.isProd(),
768
+ installSource: self.installSource(),
449
769
  action: nil,
450
770
  channel: nil,
451
771
  defaultChannel: self.defaultChannel,
@@ -453,66 +773,118 @@ import UIKit
453
773
  )
454
774
  }
455
775
 
456
- public func getLatest(url: URL, channel: String?) -> AppVersion {
457
- let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
776
+ public func getLatest(url: URL, channel: String?, appIdOverride: String? = nil) -> AppVersion {
458
777
  let latest: AppVersion = AppVersion()
459
- var parameters: InfoObject = self.createInfoObject()
778
+ func applyLatestResponse(_ value: AppVersionDec?) {
779
+ if let url = value?.url {
780
+ latest.url = url
781
+ }
782
+ if let checksum = value?.checksum {
783
+ latest.checksum = checksum
784
+ }
785
+ if let version = value?.version {
786
+ latest.version = version
787
+ }
788
+ if let major = value?.major {
789
+ latest.major = major
790
+ }
791
+ if let breaking = value?.breaking {
792
+ latest.breaking = breaking
793
+ }
794
+ if let error = value?.error {
795
+ latest.error = error
796
+ }
797
+ if let kind = value?.kind {
798
+ latest.kind = kind
799
+ }
800
+ if let message = value?.message {
801
+ latest.message = message
802
+ }
803
+ if let sessionKey = value?.session_key {
804
+ latest.sessionKey = sessionKey
805
+ }
806
+ if let data = value?.data {
807
+ latest.data = data
808
+ }
809
+ if let manifest = value?.manifest {
810
+ latest.manifest = manifest
811
+ }
812
+ if let link = value?.link {
813
+ latest.link = link
814
+ }
815
+ if let comment = value?.comment {
816
+ latest.comment = comment
817
+ }
818
+ }
819
+
820
+ var parameters: InfoObject = self.createInfoObject(appIdOverride: appIdOverride)
460
821
  if let channel = channel {
461
822
  parameters.defaultChannel = channel
462
823
  }
463
- logger.info("Auto-update parameters: \(parameters)")
464
- 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
+ }
465
830
 
466
- request.validate().responseDecodable(of: AppVersionDec.self) { response in
467
- switch response.result {
468
- case .success:
469
- latest.statusCode = response.response?.statusCode ?? 0
470
- if let url = response.value?.url {
471
- latest.url = url
472
- }
473
- if let checksum = response.value?.checksum {
474
- latest.checksum = checksum
475
- }
476
- if let version = response.value?.version {
477
- latest.version = version
478
- }
479
- if let major = response.value?.major {
480
- latest.major = major
481
- }
482
- if let breaking = response.value?.breaking {
483
- latest.breaking = breaking
484
- }
485
- if let error = response.value?.error {
486
- latest.error = error
487
- }
488
- if let message = response.value?.message {
489
- latest.message = message
490
- }
491
- if let sessionKey = response.value?.session_key {
492
- latest.sessionKey = sessionKey
493
- }
494
- if let data = response.value?.data {
495
- latest.data = data
496
- }
497
- if let manifest = response.value?.manifest {
498
- latest.manifest = manifest
499
- }
500
- if let link = response.value?.link {
501
- latest.link = link
502
- }
503
- if let comment = response.value?.comment {
504
- latest.comment = comment
505
- }
506
- case let .failure(error):
507
- self.logger.error("Error getting latest version")
508
- self.logger.debug("Response: \(response.value.debugDescription), Error: \(error)")
509
- latest.message = "Error getting Latest"
510
- latest.error = "response_error"
511
- latest.statusCode = response.response?.statusCode ?? 0
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)"
512
878
  }
513
- 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
514
886
  }
515
- semaphore.wait()
887
+
516
888
  return latest
517
889
  }
518
890
 
@@ -522,6 +894,22 @@ import UIKit
522
894
  logger.info("Current bundle set to: \((bundle ).isEmpty ? BundleInfo.ID_BUILTIN : bundle)")
523
895
  }
524
896
 
897
+ static func shouldResetForForeignBundle(bundlePath: String?, isBuiltin: Bool, hasStoredBundleInfo: Bool) -> Bool {
898
+ guard let bundlePath, !bundlePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
899
+ return false
900
+ }
901
+ return !isBuiltin && !hasStoredBundleInfo
902
+ }
903
+
904
+ private func hasStoredBundleInfo(id: String) -> Bool {
905
+ guard !id.isEmpty,
906
+ id != BundleInfo.ID_BUILTIN,
907
+ id != BundleInfo.VERSION_UNKNOWN else {
908
+ return false
909
+ }
910
+ return UserDefaults.standard.object(forKey: "\(id)\(self.INFO_SUFFIX)") != nil
911
+ }
912
+
525
913
  // Per-download temp file paths to prevent collisions when multiple downloads run concurrently
526
914
  private func tempDataPath(for id: String) -> URL {
527
915
  return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("package_\(id).tmp")
@@ -538,6 +926,132 @@ import UIKit
538
926
  return actualHash == expectedHash
539
927
  }
540
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
+
541
1055
  public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
542
1056
  let id = self.randomString(length: 10)
543
1057
  logger.info("downloadManifest start \(id)")
@@ -563,8 +1077,8 @@ import UIKit
563
1077
 
564
1078
  let totalFiles = manifest.count
565
1079
 
566
- // Configure concurrent operation count similar to Android: min(64, max(32, totalFiles))
567
- 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))
568
1082
 
569
1083
  // Thread-safe counters for concurrent operations
570
1084
  let completedFiles = AtomicCounter()
@@ -578,10 +1092,36 @@ import UIKit
578
1092
  for entry in manifest {
579
1093
  guard let fileName = entry.file_name,
580
1094
  let downloadUrl = entry.download_url else {
1095
+ let error = NSError(
1096
+ domain: "ManifestEntryError",
1097
+ code: 1,
1098
+ userInfo: [
1099
+ NSLocalizedDescriptionKey: "Manifest entry is missing file_name or download_url"
1100
+ ]
1101
+ )
1102
+ errorLock.lock()
1103
+ if downloadError == nil {
1104
+ downloadError = error
1105
+ }
1106
+ errorLock.unlock()
1107
+ hasError.value = true
1108
+ logger.error("Manifest entry is missing file_name or download_url")
581
1109
  continue
582
1110
  }
583
1111
  guard let entryFileHash = entry.file_hash, !entryFileHash.isEmpty else {
584
1112
  logger.error("Missing file_hash for manifest entry: \(entry.file_name ?? "unknown")")
1113
+ let error = NSError(
1114
+ domain: "ManifestEntryError",
1115
+ code: 2,
1116
+ userInfo: [
1117
+ NSLocalizedDescriptionKey: "Manifest entry is missing file_hash for \(entry.file_name ?? "unknown")"
1118
+ ]
1119
+ )
1120
+ errorLock.lock()
1121
+ if downloadError == nil {
1122
+ downloadError = error
1123
+ }
1124
+ errorLock.unlock()
585
1125
  hasError.value = true
586
1126
  continue
587
1127
  }
@@ -610,8 +1150,22 @@ import UIKit
610
1150
  let legacyCacheFilePath: URL? = isBrotli ? cacheFolder.appendingPathComponent("\(finalFileHash)_\(fileNameWithoutPath)") : nil
611
1151
 
612
1152
  let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
613
- let destFilePath = destFolder.appendingPathComponent(destFileName)
614
- 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
+ }
615
1169
 
616
1170
  // Create parent directories synchronously (before operations start)
617
1171
  try? FileManager.default.createDirectory(at: destFilePath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
@@ -623,7 +1177,7 @@ import UIKit
623
1177
  do {
624
1178
  // Try builtin first
625
1179
  if FileManager.default.fileExists(atPath: builtinFilePath.path) && self.verifyChecksum(file: builtinFilePath, expectedHash: finalFileHash) {
626
- try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
1180
+ try self.copyItemReplacing(from: builtinFilePath, to: destFilePath)
627
1181
  self.logger.info("downloadManifest \(fileName) using builtin file \(id)")
628
1182
  }
629
1183
  // Try cache
@@ -670,15 +1224,20 @@ import UIKit
670
1224
  // Execute all operations concurrently and wait for completion
671
1225
  manifestDownloadQueue.addOperations(operations, waitUntilFinished: true)
672
1226
 
673
- if hasError.value, let error = downloadError {
1227
+ if hasError.value {
1228
+ let resolvedError = downloadError ?? NSError(
1229
+ domain: "ManifestDownloadError",
1230
+ code: 1,
1231
+ userInfo: [NSLocalizedDescriptionKey: "Manifest download failed due to invalid or missing entries"]
1232
+ )
674
1233
  // Update bundle status to ERROR if download failed
675
- let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.localizedString)
1234
+ let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.storedValue)
676
1235
  self.saveBundleInfo(id: id, bundle: errorBundle)
677
- throw error
1236
+ throw resolvedError
678
1237
  }
679
1238
 
680
1239
  // Update bundle status to PENDING after successful download
681
- let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.localizedString)
1240
+ let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.storedValue)
682
1241
  self.saveBundleInfo(id: id, bundle: updatedBundle)
683
1242
 
684
1243
  // Send stats for manifest download complete
@@ -703,105 +1262,152 @@ import UIKit
703
1262
  version: String,
704
1263
  bundleId: String
705
1264
  ) throws {
706
- let semaphore = DispatchSemaphore(value: 0)
707
- var downloadError: Error?
708
-
709
- self.alamofireSession.download(downloadUrl).responseData { response in
710
- 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
+ }
711
1272
 
712
- switch response.result {
713
- case .success(let data):
714
- do {
715
- let statusCode = response.response?.statusCode ?? 200
716
- if statusCode < 200 || statusCode >= 300 {
717
- self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
718
- if let stringData = String(data: data, encoding: .utf8) {
719
- throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid. Data: \(stringData) for file \(fileName) at url \(downloadUrl)"])
720
- } else {
721
- throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
722
- }
723
- }
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
+ }
724
1280
 
725
- // Add decryption step if public key is set and sessionKey is provided
726
- var finalData = data
727
- if !self.publicKey.isEmpty && !sessionKey.isEmpty {
728
- let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
729
- try finalData.write(to: tempFile)
730
- do {
731
- try CryptoCipher.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
732
- } catch {
733
- self.sendStats(action: "decrypt_fail", versionName: version)
734
- throw error
735
- }
736
- finalData = try Data(contentsOf: tempFile)
737
- try FileManager.default.removeItem(at: tempFile)
738
- }
1281
+ let result = performRequest(request, label: "downloadManifestFile \(fileName)")
739
1282
 
740
- // Decompress Brotli if needed
741
- if isBrotli {
742
- guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
743
- self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
744
- throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
745
- }
746
- finalData = decompressedData
747
- }
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
+ }
748
1291
 
749
- // Write to destination
750
- try finalData.write(to: destFilePath)
751
-
752
- // Always verify checksum when file_hash is present
753
- let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
754
- CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
755
- CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
756
- if calculatedChecksum != fileHash {
757
- try? FileManager.default.removeItem(at: destFilePath)
758
- self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
759
- throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
760
- }
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
+ }
761
1298
 
762
- // Save to cache
763
- 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
+ }
764
1307
 
765
- self.logger.info("Manifest file downloaded and cached")
766
- 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
+ }
767
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)
768
1326
  } catch {
769
- downloadError = error
770
- self.logger.error("Manifest file download failed")
771
- self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
1327
+ self.sendStats(action: "decrypt_fail", versionName: version)
1328
+ throw error
772
1329
  }
1330
+ finalData = try Data(contentsOf: tempFile)
1331
+ try FileManager.default.removeItem(at: tempFile)
1332
+ }
1333
+
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
1341
+ }
773
1342
 
774
- case .failure(let error):
775
- downloadError = error
776
- self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
777
- self.logger.error("Manifest file download network error")
778
- self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription), Response: \(response.debugDescription)")
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)"])
779
1354
  }
780
- }
781
1355
 
782
- semaphore.wait()
1356
+ // Save to cache (replace stale cache entries from partial or concurrent downloads)
1357
+ try writeDataAtomically(finalData, to: cacheFilePath)
783
1358
 
784
- 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)")
785
1364
  throw error
786
1365
  }
787
1366
  }
788
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
+
789
1392
  /// Atomically try to copy a file from cache - returns true if successful, false if file doesn't exist or copy failed
790
1393
  /// This handles the race condition where OS can delete cache files between exists() check and copy
791
1394
  private func tryCopyFromCache(from source: URL, to destination: URL, expectedHash: String) -> Bool {
1395
+ let fileManager = FileManager.default
1396
+
792
1397
  // First quick check - if file doesn't exist, don't bother
793
- guard FileManager.default.fileExists(atPath: source.path) else {
1398
+ guard fileManager.fileExists(atPath: source.path) else {
794
1399
  return false
795
1400
  }
796
1401
 
797
- // Verify checksum before copy
1402
+ // Verify checksum before copy; remove stale cache entries that would block re-download
798
1403
  guard verifyChecksum(file: source, expectedHash: expectedHash) else {
1404
+ try? fileManager.removeItem(at: source)
799
1405
  return false
800
1406
  }
801
1407
 
802
1408
  // Try to copy - if it fails (file deleted by OS between check and copy), return false
803
1409
  do {
804
- try FileManager.default.copyItem(at: source, to: destination)
1410
+ try copyItemReplacing(from: source, to: destination)
805
1411
  return true
806
1412
  } catch {
807
1413
  // File was deleted between check and copy, or other IO error - caller should download instead
@@ -930,7 +1536,6 @@ import UIKit
930
1536
 
931
1537
  public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
932
1538
  let id: String = self.randomString(length: 10)
933
- let semaphore = DispatchSemaphore(value: 0)
934
1539
  // Each download uses its own temp files keyed by bundle ID to prevent collisions
935
1540
  if version != getLocalUpdateVersion(for: id) {
936
1541
  cleanDownloadData(for: id)
@@ -942,10 +1547,11 @@ import UIKit
942
1547
  try checkDiskSpace()
943
1548
 
944
1549
  var checksum = ""
945
- var targetSize = -1
946
1550
  var lastSentProgress = 0
947
- var totalReceivedBytes: Int64 = loadDownloadProgress(for: id) // Retrieving the amount of already downloaded data if exist, defined at 0 otherwise
948
- 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)
949
1555
 
950
1556
  // Send stats for zip download start
951
1557
  self.sendStats(action: "download_zip_start", versionName: version)
@@ -955,66 +1561,61 @@ import UIKit
955
1561
  self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
956
1562
  }
957
1563
  var mainError: NSError?
958
- let monitor = ClosureEventMonitor()
959
- monitor.requestDidCompleteTaskWithError = { (_, _, error) in
960
- if error != nil {
961
- self.logger.error("Downloading failed - ClosureEventMonitor activated")
962
- mainError = error as NSError?
963
- }
964
- }
965
- let configuration = URLSessionConfiguration.default
966
- configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
967
- let session = Session(configuration: configuration, eventMonitors: [monitor])
968
-
969
- let request = session.streamRequest(url, headers: requestHeaders).validate().onHTTPResponse(perform: { response in
970
- if let contentLength = response.headers.value(for: "Content-Length") {
971
- targetSize = (Int(contentLength) ?? -1) + Int(totalReceivedBytes)
972
- }
973
- }).responseStream { [weak self] streamResponse in
974
- guard let self = self else { return }
975
- switch streamResponse.event {
976
- case .stream(let result):
977
- if case .success(let data) = result {
978
- self.tempData.append(data)
979
-
980
- self.savePartialData(startingAt: UInt64(totalReceivedBytes), for: id) // Saving the received data in the package_<id>.tmp file
981
- totalReceivedBytes += Int64(data.count)
982
1564
 
983
- let percent = max(10, Int((Double(totalReceivedBytes) / Double(targetSize)) * 70.0))
984
-
985
- let currentMilestone = (percent / 10) * 10
986
- if currentMilestone > lastSentProgress && currentMilestone <= 70 {
987
- for milestone in stride(from: lastSentProgress + 10, through: currentMilestone, by: 10) {
988
- self.notifyDownload(id: id, percent: milestone, ignoreMultipleOfTen: false)
989
- }
990
- lastSentProgress = currentMilestone
991
- }
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
+ }
1573
+
1574
+ if totalReceivedBytes > 0 {
1575
+ request.setValue("bytes=\(totalReceivedBytes)-", forHTTPHeaderField: "Range")
1576
+ }
1577
+
1578
+ let downloadResult = performDownloadRequest(request, label: "download \(version)")
1579
+
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)
992
1601
 
993
- } else {
994
- self.logger.error("Download failed")
1602
+ if lastSentProgress < 70 {
1603
+ self.notifyDownload(id: id, percent: 70, ignoreMultipleOfTen: true)
1604
+ lastSentProgress = 70
995
1605
  }
996
-
997
- case .complete:
998
- self.logger.info("Download complete, total received bytes: \(totalReceivedBytes)")
999
- self.notifyDownload(id: id, percent: 70, ignoreMultipleOfTen: true)
1000
- semaphore.signal()
1001
- }
1002
- }
1003
- self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum, link: link, comment: comment))
1004
- let reachabilityManager = NetworkReachabilityManager()
1005
- reachabilityManager?.startListening { status in
1006
- switch status {
1007
- case .notReachable:
1008
- // Stop the download request if the network is not reachable
1009
- request.cancel()
1010
- mainError = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet, userInfo: nil)
1011
- semaphore.signal()
1012
- default:
1013
- break
1606
+ self.logger.info("Download complete")
1607
+ } catch let error as NSError {
1608
+ mainError = error
1609
+ } catch {
1610
+ mainError = error as NSError
1014
1611
  }
1612
+ } else {
1613
+ mainError = NSError(
1614
+ domain: "DownloadError",
1615
+ code: 1,
1616
+ userInfo: [NSLocalizedDescriptionKey: "Downloaded file is missing at \(tempPath.path)"]
1617
+ )
1015
1618
  }
1016
- semaphore.wait()
1017
- reachabilityManager?.stopListening()
1018
1619
 
1019
1620
  if mainError != nil {
1020
1621
  logger.error("Failed to download bundle")
@@ -1023,7 +1624,6 @@ import UIKit
1023
1624
  throw mainError!
1024
1625
  }
1025
1626
 
1026
- let tempPath = tempDataPath(for: id)
1027
1627
  let finalPath = tempPath.deletingLastPathComponent().appendingPathComponent("\(id)")
1028
1628
  do {
1029
1629
  try CryptoCipher.decryptFile(filePath: tempPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
@@ -1218,6 +1818,15 @@ import UIKit
1218
1818
  return false
1219
1819
  }
1220
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
+
1221
1830
  // Check if this is the next bundle and prevent deletion if it is
1222
1831
  if let next = self.getNextBundle(),
1223
1832
  !next.isDeleted() &&
@@ -1244,7 +1853,7 @@ import UIKit
1244
1853
  if removeInfo {
1245
1854
  self.removeBundleInfo(id: id)
1246
1855
  } else {
1247
- self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.localizedString))
1856
+ self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.storedValue))
1248
1857
  }
1249
1858
  logger.info("Bundle deleted successfully")
1250
1859
  logger.debug("Version: \(deleted.getVersionName())")
@@ -1411,6 +2020,52 @@ import UIKit
1411
2020
  return libraryDir.appendingPathComponent(self.bundleDirectory).appendingPathComponent(id)
1412
2021
  }
1413
2022
 
2023
+ struct ResetState {
2024
+ let currentBundlePath: String
2025
+ let fallbackBundleId: String
2026
+ let nextBundleId: String?
2027
+ }
2028
+
2029
+ func captureResetState() -> ResetState {
2030
+ ResetState(
2031
+ currentBundlePath: UserDefaults.standard.string(forKey: self.CAP_SERVER_PATH) ?? self.DEFAULT_FOLDER,
2032
+ fallbackBundleId: UserDefaults.standard.string(forKey: self.FALLBACK_VERSION) ?? BundleInfo.ID_BUILTIN,
2033
+ nextBundleId: UserDefaults.standard.string(forKey: self.NEXT_VERSION)
2034
+ )
2035
+ }
2036
+
2037
+ func restoreResetState(_ state: ResetState) {
2038
+ let currentBundlePath = state.currentBundlePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2039
+ ? self.DEFAULT_FOLDER
2040
+ : state.currentBundlePath
2041
+ let fallbackBundleId = state.fallbackBundleId.isEmpty ? BundleInfo.ID_BUILTIN : state.fallbackBundleId
2042
+
2043
+ self.setCurrentBundle(bundle: currentBundlePath)
2044
+ UserDefaults.standard.set(fallbackBundleId, forKey: self.FALLBACK_VERSION)
2045
+ if let nextBundleId = state.nextBundleId, !nextBundleId.isEmpty {
2046
+ UserDefaults.standard.set(nextBundleId, forKey: self.NEXT_VERSION)
2047
+ } else {
2048
+ UserDefaults.standard.removeObject(forKey: self.NEXT_VERSION)
2049
+ }
2050
+ UserDefaults.standard.synchronize()
2051
+ }
2052
+
2053
+ func prepareResetStateForTransition() {
2054
+ self.setCurrentBundle(bundle: "")
2055
+ self.setFallbackBundle(fallback: Optional<BundleInfo>.none)
2056
+ _ = self.setNextBundle(next: Optional<String>.none)
2057
+ }
2058
+
2059
+ func finalizeResetTransition(previousBundleName: String, isInternal: Bool) {
2060
+ if !isInternal {
2061
+ self.sendStats(action: "reset", versionName: self.getCurrentBundle().getVersionName(), oldVersionName: previousBundleName)
2062
+ }
2063
+ }
2064
+
2065
+ func canSet(bundle: BundleInfo) -> Bool {
2066
+ bundle.isBuiltin() || self.bundleExists(id: bundle.getId())
2067
+ }
2068
+
1414
2069
  public func set(bundle: BundleInfo) -> Bool {
1415
2070
  return self.set(id: bundle.getId())
1416
2071
  }
@@ -1448,11 +2103,51 @@ import UIKit
1448
2103
  return false
1449
2104
  }
1450
2105
 
2106
+ func stagePendingReload(bundle: BundleInfo) -> Bool {
2107
+ guard !bundle.isBuiltin(), bundleExists(id: bundle.getId()) else {
2108
+ return false
2109
+ }
2110
+ self.setCurrentBundle(bundle: self.getBundleDirectory(id: bundle.getId()).path)
2111
+ return true
2112
+ }
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
+
2129
+ func finalizePendingReload(bundle: BundleInfo, previousBundleName: String) {
2130
+ guard !bundle.isBuiltin() else {
2131
+ return
2132
+ }
2133
+ self.sendStats(action: "set", versionName: bundle.getVersionName(), oldVersionName: previousBundleName)
2134
+ }
2135
+
1451
2136
  public func autoReset() {
1452
2137
  let currentBundle: BundleInfo = self.getCurrentBundle()
1453
2138
  if !currentBundle.isBuiltin() && !self.bundleExists(id: currentBundle.getId()) {
1454
2139
  logger.info("Folder at bundle path does not exist. Triggering reset.")
1455
2140
  self.reset()
2141
+ return
2142
+ }
2143
+ let bundlePath = UserDefaults.standard.string(forKey: self.CAP_SERVER_PATH)
2144
+ if Self.shouldResetForForeignBundle(
2145
+ bundlePath: bundlePath,
2146
+ isBuiltin: currentBundle.isBuiltin(),
2147
+ hasStoredBundleInfo: self.hasStoredBundleInfo(id: currentBundle.getId())
2148
+ ) {
2149
+ logger.info("Current bundle id is not one of the bundle ids stored by this plugin. Triggering reset.")
2150
+ self.reset()
1456
2151
  }
1457
2152
  }
1458
2153
 
@@ -1463,20 +2158,18 @@ import UIKit
1463
2158
  public func reset(isInternal: Bool) {
1464
2159
  logger.info("reset: \(isInternal)")
1465
2160
  let currentBundleName = self.getCurrentBundle().getVersionName()
1466
- self.setCurrentBundle(bundle: "")
1467
- self.setFallbackBundle(fallback: Optional<BundleInfo>.none)
1468
- _ = self.setNextBundle(next: Optional<String>.none)
1469
- if !isInternal {
1470
- self.sendStats(action: "reset", versionName: self.getCurrentBundle().getVersionName(), oldVersionName: currentBundleName)
1471
- }
2161
+ self.prepareResetStateForTransition()
2162
+ self.finalizeResetTransition(previousBundleName: currentBundleName, isInternal: isInternal)
1472
2163
  }
1473
2164
 
1474
2165
  public func setSuccess(bundle: BundleInfo, autoDeletePrevious: Bool) {
1475
2166
  self.setBundleStatus(id: bundle.getId(), status: BundleStatus.SUCCESS)
1476
2167
  let fallback: BundleInfo = self.getFallbackBundle()
2168
+ let previewFallback = self.getPreviewFallbackBundle()
2169
+ let fallbackIsPreviewFallback = previewFallback?.getId() == fallback.getId()
1477
2170
  logger.info("Fallback bundle is: \(fallback.toString())")
1478
2171
  logger.info("Version successfully loaded: \(bundle.toString())")
1479
- if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() {
2172
+ if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() && !fallbackIsPreviewFallback {
1480
2173
  let res = self.delete(id: fallback.getId())
1481
2174
  if res {
1482
2175
  logger.info("Deleted previous bundle")
@@ -1507,7 +2200,12 @@ import UIKit
1507
2200
  return setChannel
1508
2201
  }
1509
2202
 
1510
- 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 {
1511
2209
  let setChannel: SetChannel = SetChannel()
1512
2210
 
1513
2211
  // Check if setting defaultChannel is allowed
@@ -1532,58 +2230,79 @@ import UIKit
1532
2230
  setChannel.error = "missing_config"
1533
2231
  return setChannel
1534
2232
  }
1535
- 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
+ }
1536
2239
  var parameters: InfoObject = self.createInfoObject()
1537
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
+ }
1538
2245
 
1539
- 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")
1540
2247
 
1541
- request.validate().responseDecodable(of: SetChannelDec.self) { response in
1542
- // Check for 429 rate limit
1543
- if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1544
- setChannel.message = "Rate limit exceeded"
1545
- setChannel.error = "rate_limit_exceeded"
1546
- semaphore.signal()
1547
- return
1548
- }
2248
+ if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
2249
+ setChannel.message = "Rate limit exceeded"
2250
+ setChannel.error = "rate_limit_exceeded"
2251
+ return setChannel
2252
+ }
1549
2253
 
1550
- switch response.result {
1551
- case .success:
1552
- if let responseValue = response.value {
1553
- if let error = responseValue.error {
1554
- setChannel.error = error
1555
- } else if responseValue.unset == true {
1556
- // Server requested to unset channel (public channel was requested)
1557
- // Clear persisted defaultChannel and revert to config value
1558
- UserDefaults.standard.removeObject(forKey: defaultChannelKey)
1559
- UserDefaults.standard.synchronize()
1560
- self.logger.info("Public channel requested, channel override removed")
1561
-
1562
- setChannel.status = responseValue.status ?? "ok"
1563
- setChannel.message = responseValue.message ?? "Public channel requested, channel override removed. Device will use public channel automatically."
1564
- } else {
1565
- // Success - persist defaultChannel
1566
- self.defaultChannel = channel
1567
- UserDefaults.standard.set(channel, forKey: defaultChannelKey)
1568
- UserDefaults.standard.synchronize()
1569
- self.logger.info("defaultChannel persisted locally: \(channel)")
1570
-
1571
- setChannel.status = responseValue.status ?? ""
1572
- setChannel.message = responseValue.message ?? ""
1573
- }
1574
- }
1575
- case let .failure(error):
1576
- self.logger.error("Error setting channel")
1577
- self.logger.debug("Error: \(error)")
1578
- setChannel.error = "Request failed: \(error.localizedDescription)"
1579
- }
1580
- 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 ?? ""
1581
2301
  }
1582
- semaphore.wait()
1583
2302
  return setChannel
1584
2303
  }
1585
2304
 
1586
- func getChannel() -> GetChannel {
2305
+ func getChannel(defaultChannelKey: String? = nil) -> GetChannel {
1587
2306
  let getChannel: GetChannel = GetChannel()
1588
2307
 
1589
2308
  // Check if rate limit was exceeded
@@ -1600,52 +2319,96 @@ import UIKit
1600
2319
  getChannel.error = "missing_config"
1601
2320
  return getChannel
1602
2321
  }
1603
- 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
+ }
1604
2328
  let parameters: InfoObject = self.createInfoObject()
1605
- 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
+ }
1606
2333
 
1607
- request.validate().responseDecodable(of: GetChannelDec.self) { response in
1608
- defer {
1609
- semaphore.signal()
1610
- }
2334
+ let result = performRequest(request, label: "getChannel")
1611
2335
 
1612
- // Check for 429 rate limit
1613
- if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1614
- getChannel.message = "Rate limit exceeded"
1615
- getChannel.error = "rate_limit_exceeded"
1616
- return
1617
- }
2336
+ if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
2337
+ getChannel.message = "Rate limit exceeded"
2338
+ getChannel.error = "rate_limit_exceeded"
2339
+ return getChannel
2340
+ }
1618
2341
 
1619
- switch response.result {
1620
- case .success:
1621
- if let responseValue = response.value {
1622
- if let error = responseValue.error {
1623
- getChannel.error = error
1624
- } else {
1625
- getChannel.status = responseValue.status ?? ""
1626
- getChannel.message = responseValue.message ?? ""
1627
- getChannel.channel = responseValue.channel ?? ""
1628
- getChannel.allowSet = responseValue.allowSet ?? true
1629
- }
1630
- }
1631
- case let .failure(error):
1632
- if let data = response.data, let bodyString = String(data: data, encoding: .utf8) {
1633
- if bodyString.contains("channel_not_found") && response.response?.statusCode == 400 && !self.defaultChannel.isEmpty {
1634
- getChannel.channel = self.defaultChannel
1635
- getChannel.status = "default"
1636
- return
1637
- }
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
1638
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
+ }
2366
+
2367
+ guard let responseValue = try? JSONDecoder().decode(GetChannelDec.self, from: data) else {
2368
+ getChannel.error = "decode_error"
2369
+ return getChannel
2370
+ }
1639
2371
 
1640
- self.logger.error("Error getting channel")
1641
- self.logger.debug("Error: \(error)")
1642
- getChannel.error = "Request failed: \(error.localizedDescription)"
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
1643
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)
1644
2393
  }
1645
- semaphore.wait()
1646
2394
  return getChannel
1647
2395
  }
1648
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
+
1649
2412
  func listChannels() -> ListChannels {
1650
2413
  let listChannels: ListChannels = ListChannels()
1651
2414
 
@@ -1662,27 +2425,13 @@ import UIKit
1662
2425
  return listChannels
1663
2426
  }
1664
2427
 
1665
- let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
1666
-
1667
2428
  // Create info object and convert to query parameters
1668
2429
  let infoObject = self.createInfoObject()
1669
-
1670
- // Create query parameters from InfoObject
1671
2430
  var urlComponents = URLComponents(string: self.channelUrl)
1672
- var queryItems: [URLQueryItem] = []
1673
-
1674
- // Convert InfoObject to dictionary using Mirror
1675
- let mirror = Mirror(reflecting: infoObject)
1676
- for child in mirror.children {
1677
- if let key = child.label, let value = child.value as? CustomStringConvertible {
1678
- queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
1679
- } else if let key = child.label {
1680
- // Handle optional values
1681
- let mirror = Mirror(reflecting: child.value)
1682
- if let value = mirror.children.first?.value {
1683
- queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
1684
- }
1685
- }
2431
+ var queryItems: [URLQueryItem] = urlComponents?.queryItems ?? []
2432
+
2433
+ for (key, value) in infoObject.toParameters() {
2434
+ queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
1686
2435
  }
1687
2436
 
1688
2437
  urlComponents?.queryItems = queryItems
@@ -1693,47 +2442,62 @@ import UIKit
1693
2442
  return listChannels
1694
2443
  }
1695
2444
 
1696
- 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
+ }
1697
2449
 
1698
- request.validate().responseDecodable(of: ListChannelsDec.self) { response in
1699
- defer {
1700
- semaphore.signal()
1701
- }
2450
+ let result = performRequest(request, label: "listChannels")
1702
2451
 
1703
- // Check for 429 rate limit
1704
- if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1705
- listChannels.error = "rate_limit_exceeded"
1706
- return
1707
- }
2452
+ if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
2453
+ listChannels.error = "rate_limit_exceeded"
2454
+ return listChannels
2455
+ }
1708
2456
 
1709
- switch response.result {
1710
- case .success:
1711
- if let responseValue = response.value {
1712
- // Check for server-side errors
1713
- if let error = responseValue.error {
1714
- listChannels.error = error
1715
- return
1716
- }
2457
+ if result.timedOut {
2458
+ listChannels.error = "Request timed out"
2459
+ return listChannels
2460
+ }
1717
2461
 
1718
- // Backend returns direct array, so channels should be populated by our custom decoder
1719
- if let channels = responseValue.channels {
1720
- listChannels.channels = channels.map { channel in
1721
- var channelDict: [String: Any] = [:]
1722
- channelDict["id"] = channel.id ?? ""
1723
- channelDict["name"] = channel.name ?? ""
1724
- channelDict["public"] = channel.public ?? false
1725
- channelDict["allow_self_set"] = channel.allow_self_set ?? false
1726
- return channelDict
1727
- }
1728
- }
1729
- }
1730
- case let .failure(error):
1731
- self.logger.error("Error listing channels")
1732
- self.logger.debug("Error: \(error)")
1733
- 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
1734
2498
  }
1735
2499
  }
1736
- semaphore.wait()
2500
+
1737
2501
  return listChannels
1738
2502
  }
1739
2503
 
@@ -1747,6 +2511,29 @@ import UIKit
1747
2511
  }()
1748
2512
 
1749
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
+
1750
2537
  // Check if rate limit was exceeded
1751
2538
  if CapgoUpdater.rateLimitExceeded {
1752
2539
  logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.")
@@ -1773,15 +2560,17 @@ import UIKit
1773
2560
  plugin_version: info.plugin_version,
1774
2561
  is_emulator: info.is_emulator,
1775
2562
  is_prod: info.is_prod,
2563
+ installSource: info.installSource,
1776
2564
  action: action,
1777
2565
  channel: info.channel,
1778
2566
  defaultChannel: info.defaultChannel,
1779
2567
  key_id: info.key_id,
2568
+ metadata: metadata,
1780
2569
  timestamp: Int64(Date().timeIntervalSince1970 * 1000)
1781
2570
  )
1782
2571
 
1783
2572
  statsQueueLock.lock()
1784
- statsQueue.append(event)
2573
+ statsQueue.append(QueuedStatsEvent(event: event, onSent: onSent))
1785
2574
  statsQueueLock.unlock()
1786
2575
 
1787
2576
  ensureStatsTimerStarted()
@@ -1808,10 +2597,13 @@ import UIKit
1808
2597
  statsQueueLock.unlock()
1809
2598
  return
1810
2599
  }
1811
- let eventsToSend = statsQueue
2600
+ let queuedEvents = statsQueue
1812
2601
  statsQueue.removeAll()
1813
2602
  statsQueueLock.unlock()
1814
2603
 
2604
+ let eventsToSend = queuedEvents.map(\.event)
2605
+ let onSentCallbacks = queuedEvents.compactMap(\.onSent)
2606
+
1815
2607
  operationQueue.maxConcurrentOperationCount = 1
1816
2608
 
1817
2609
  let operation = BlockOperation {
@@ -1829,10 +2621,18 @@ import UIKit
1829
2621
  return
1830
2622
  }
1831
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
+
1832
2631
  switch response.result {
1833
2632
  case .success:
1834
2633
  self.logger.info("Stats batch sent successfully")
1835
2634
  self.logger.debug("Sent \(eventsToSend.count) events")
2635
+ onSentCallbacks.forEach { $0() }
1836
2636
  case let .failure(error):
1837
2637
  self.logger.error("Error sending stats batch")
1838
2638
  self.logger.debug("Response: \(response.value?.debugDescription ?? "nil"), Error: \(error.localizedDescription)")
@@ -1851,7 +2651,7 @@ import UIKit
1851
2651
  }
1852
2652
  let result: BundleInfo
1853
2653
  if BundleInfo.ID_BUILTIN == trueId {
1854
- result = BundleInfo(id: trueId, version: "", status: BundleStatus.SUCCESS, checksum: "")
2654
+ result = BundleInfo(id: trueId, version: self.versionBuild, status: BundleStatus.SUCCESS, checksum: "")
1855
2655
  } else if BundleInfo.VERSION_UNKNOWN == trueId {
1856
2656
  result = BundleInfo(id: trueId, version: "", status: BundleStatus.ERROR, checksum: "")
1857
2657
  } else {
@@ -1898,13 +2698,12 @@ import UIKit
1898
2698
  logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
1899
2699
  }
1900
2700
  }
1901
- UserDefaults.standard.synchronize()
1902
2701
  }
1903
2702
 
1904
2703
  private func setBundleStatus(id: String, status: BundleStatus) {
1905
2704
  logger.info("Setting status for bundle [\(id)] to \(status)")
1906
2705
  let info = self.getBundleInfo(id: id)
1907
- self.saveBundleInfo(id: id, bundle: info.setStatus(status: status.localizedString))
2706
+ self.saveBundleInfo(id: id, bundle: info.setStatus(status: status.storedValue))
1908
2707
  }
1909
2708
 
1910
2709
  public func getCurrentBundle() -> BundleInfo {
@@ -1941,6 +2740,33 @@ import UIKit
1941
2740
  return self.getBundleInfo(id: id)
1942
2741
  }
1943
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
+
1944
2770
  public func setNextBundle(next: String?) -> Bool {
1945
2771
  guard let nextId: String = next else {
1946
2772
  UserDefaults.standard.removeObject(forKey: self.NEXT_VERSION)
@@ -1954,6 +2780,8 @@ import UIKit
1954
2780
  UserDefaults.standard.set(nextId, forKey: self.NEXT_VERSION)
1955
2781
  UserDefaults.standard.synchronize()
1956
2782
  self.setBundleStatus(id: nextId, status: BundleStatus.PENDING)
2783
+ self.sendStats(action: "set_next", versionName: newBundle.getVersionName(), oldVersionName: self.getCurrentBundle().getVersionName())
2784
+ self.notifyListeners("setNext", ["bundle": newBundle.toJSON()])
1957
2785
  return true
1958
2786
  }
1959
2787
  }