@capgo/capacitor-updater 8.0.0 → 8.1.0

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