@capgo/capacitor-updater 5.9.4 → 5.10.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 +1030 -212
  4. package/android/build.gradle +28 -11
  5. package/android/proguard-rules.pro +22 -5
  6. package/android/src/main/AndroidManifest.xml +0 -1
  7. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +171 -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 +2 -2
  10. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +2111 -1538
  11. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1551 -0
  12. package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +229 -111
  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 +42 -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 +795 -124
  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 +19 -28
  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 +1072 -162
  25. package/dist/esm/definitions.d.ts +899 -118
  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 +16 -2
  34. package/dist/esm/web.js +79 -40
  35. package/dist/esm/web.js.map +1 -1
  36. package/dist/plugin.cjs.js +361 -40
  37. package/dist/plugin.cjs.js.map +1 -1
  38. package/dist/plugin.js +361 -40
  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}/BundleStatus.swift +1 -1
  43. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1582 -0
  44. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1513 -0
  45. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +187 -0
  46. package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +220 -0
  47. package/ios/Sources/CapacitorUpdaterPlugin/DeviceIdHelper.swift +120 -0
  48. package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +307 -0
  49. package/ios/Sources/CapacitorUpdaterPlugin/Logger.swift +310 -0
  50. package/ios/Sources/CapacitorUpdaterPlugin/RSA.swift +274 -0
  51. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +112 -0
  52. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/UserDefaultsExtension.swift +0 -2
  53. package/package.json +33 -28
  54. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdater.java +0 -1187
  55. package/ios/Plugin/CapacitorUpdater.swift +0 -1032
  56. package/ios/Plugin/CapacitorUpdaterPlugin.h +0 -10
  57. package/ios/Plugin/CapacitorUpdaterPlugin.m +0 -31
  58. package/ios/Plugin/CapacitorUpdaterPlugin.swift +0 -843
  59. package/ios/Plugin/CryptoCipher.swift +0 -246
  60. /package/{LICENCE → LICENSE} +0 -0
  61. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleInfo.swift +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,1513 @@
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
+ case let .failure(error):
376
+ self.logger.error("Error getting Latest \(response.value.debugDescription) \(error)")
377
+ latest.message = "Error getting Latest \(String(describing: response.value))"
378
+ latest.error = "response_error"
379
+ latest.statusCode = response.response?.statusCode ?? 0
380
+ }
381
+ semaphore.signal()
382
+ }
383
+ semaphore.wait()
384
+ return latest
385
+ }
386
+
387
+ private func setCurrentBundle(bundle: String) {
388
+ UserDefaults.standard.set(bundle, forKey: self.CAP_SERVER_PATH)
389
+ UserDefaults.standard.synchronize()
390
+ logger.info("Current bundle set to: \((bundle ).isEmpty ? BundleInfo.ID_BUILTIN : bundle)")
391
+ }
392
+
393
+ private var tempDataPath: URL {
394
+ return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("package.tmp")
395
+ }
396
+
397
+ private var updateInfo: URL {
398
+ return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("update.dat")
399
+ }
400
+ private var tempData = Data()
401
+
402
+ private func verifyChecksum(file: URL, expectedHash: String) -> Bool {
403
+ let actualHash = CryptoCipher.calcChecksum(filePath: file)
404
+ return actualHash == expectedHash
405
+ }
406
+
407
+ public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String) throws -> BundleInfo {
408
+ let id = self.randomString(length: 10)
409
+ logger.info("downloadManifest start \(id)")
410
+ let destFolder = self.getBundleDirectory(id: id)
411
+ let builtinFolder = Bundle.main.bundleURL.appendingPathComponent("public")
412
+
413
+ try FileManager.default.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
414
+ try FileManager.default.createDirectory(at: destFolder, withIntermediateDirectories: true, attributes: nil)
415
+
416
+ // Create and save BundleInfo before starting the download process
417
+ let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: "")
418
+ self.saveBundleInfo(id: id, bundle: bundleInfo)
419
+
420
+ // Send stats for manifest download start
421
+ self.sendStats(action: "download_manifest_start", versionName: version)
422
+
423
+ // Notify the start of the download process
424
+ self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
425
+
426
+ let dispatchGroup = DispatchGroup()
427
+ var downloadError: Error?
428
+
429
+ let totalFiles = manifest.count
430
+ var completedFiles = 0
431
+
432
+ for entry in manifest {
433
+ guard let fileName = entry.file_name,
434
+ var fileHash = entry.file_hash,
435
+ let downloadUrl = entry.download_url else {
436
+ continue
437
+ }
438
+
439
+ if !self.publicKey.isEmpty && !sessionKey.isEmpty {
440
+ do {
441
+ fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
442
+ } catch {
443
+ downloadError = error
444
+ logger.error("CryptoCipher.decryptChecksum error \(id) \(fileName) error: \(error)")
445
+ }
446
+ }
447
+
448
+ let fileNameWithoutPath = (fileName as NSString).lastPathComponent
449
+ let cacheFileName = "\(fileHash)_\(fileNameWithoutPath)"
450
+ let cacheFilePath = cacheFolder.appendingPathComponent(cacheFileName)
451
+ let destFilePath = destFolder.appendingPathComponent(fileName)
452
+ let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
453
+
454
+ // Create necessary subdirectories in the destination folder
455
+ try FileManager.default.createDirectory(at: destFilePath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
456
+
457
+ dispatchGroup.enter()
458
+
459
+ if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
460
+ try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
461
+ logger.info("downloadManifest \(fileName) using builtin file \(id)")
462
+ completedFiles += 1
463
+ self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
464
+ dispatchGroup.leave()
465
+ } else if FileManager.default.fileExists(atPath: cacheFilePath.path) && verifyChecksum(file: cacheFilePath, expectedHash: fileHash) {
466
+ try FileManager.default.copyItem(at: cacheFilePath, to: destFilePath)
467
+ logger.info("downloadManifest \(fileName) copy from cache \(id)")
468
+ completedFiles += 1
469
+ self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
470
+ dispatchGroup.leave()
471
+ } else {
472
+ // File not in cache, download, decompress, and save to both cache and destination
473
+ self.alamofireSession.download(downloadUrl).responseData { response in
474
+ defer { dispatchGroup.leave() }
475
+
476
+ switch response.result {
477
+ case .success(let data):
478
+ do {
479
+ let statusCode = response.response?.statusCode ?? 200
480
+ if statusCode < 200 || statusCode >= 300 {
481
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
482
+ if let stringData = String(data: data, encoding: .utf8) {
483
+ throw NSError(domain: "StatusCodeError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid. Data: \(stringData) for file \(fileName) at url \(downloadUrl)"])
484
+ } else {
485
+ throw NSError(domain: "StatusCodeError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
486
+ }
487
+ }
488
+
489
+ // Add decryption step if public key is set and sessionKey is provided
490
+ var finalData = data
491
+ if !self.publicKey.isEmpty && !sessionKey.isEmpty {
492
+ let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
493
+ try finalData.write(to: tempFile)
494
+ do {
495
+ try CryptoCipher.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
496
+ } catch {
497
+ self.sendStats(action: "decrypt_fail", versionName: version)
498
+ throw error
499
+ }
500
+ // TODO: try and do self.sendStats(action: "decrypt_fail", versionName: version) if fail
501
+ finalData = try Data(contentsOf: tempFile)
502
+ try FileManager.default.removeItem(at: tempFile)
503
+ }
504
+
505
+ // Check if file has .br extension for Brotli decompression
506
+ let isBrotli = fileName.hasSuffix(".br")
507
+ let finalFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
508
+ let destFilePath = destFolder.appendingPathComponent(finalFileName)
509
+
510
+ if isBrotli {
511
+ // Decompress the Brotli data
512
+ guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
513
+ self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(finalFileName)")
514
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
515
+ }
516
+ finalData = decompressedData
517
+ }
518
+
519
+ try finalData.write(to: destFilePath)
520
+ if !self.publicKey.isEmpty && !sessionKey.isEmpty {
521
+ // assume that calcChecksum != null
522
+ let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
523
+ if calculatedChecksum != fileHash {
524
+ self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(finalFileName)")
525
+ throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
526
+ }
527
+ }
528
+
529
+ // Save decrypted data to cache and destination
530
+ try finalData.write(to: cacheFilePath)
531
+
532
+ completedFiles += 1
533
+ self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
534
+ self.logger.info("downloadManifest \(id) \(fileName) downloaded\(isBrotli ? ", decompressed" : "")\(!self.publicKey.isEmpty && !sessionKey.isEmpty ? ", decrypted" : ""), and cached")
535
+ } catch {
536
+ downloadError = error
537
+ self.logger.error("downloadManifest \(id) \(fileName) error: \(error.localizedDescription)")
538
+ }
539
+ case .failure(let error):
540
+ downloadError = error
541
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
542
+ self.logger.error("downloadManifest \(id) \(fileName) download error: \(error.localizedDescription). Debug response: \(response.debugDescription).")
543
+ }
544
+ }
545
+ }
546
+ }
547
+
548
+ dispatchGroup.wait()
549
+
550
+ if let error = downloadError {
551
+ // Update bundle status to ERROR if download failed
552
+ let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.localizedString)
553
+ self.saveBundleInfo(id: id, bundle: errorBundle)
554
+ throw error
555
+ }
556
+
557
+ // Update bundle status to PENDING after successful download
558
+ let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.localizedString)
559
+ self.saveBundleInfo(id: id, bundle: updatedBundle)
560
+
561
+ // Send stats for manifest download complete
562
+ self.sendStats(action: "download_manifest_complete", versionName: version)
563
+
564
+ self.notifyDownload(id: id, percent: 100, bundle: updatedBundle)
565
+ logger.info("downloadManifest done \(id)")
566
+ return updatedBundle
567
+ }
568
+
569
+ private func decompressBrotli(data: Data, fileName: String) -> Data? {
570
+ // Handle empty files
571
+ if data.count == 0 {
572
+ return data
573
+ }
574
+
575
+ // Handle the special EMPTY_BROTLI_STREAM case
576
+ if data.count == 3 && data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 {
577
+ return Data()
578
+ }
579
+
580
+ // For small files, check if it's a minimal Brotli wrapper
581
+ if data.count > 3 {
582
+ let maxBytes = min(32, data.count)
583
+ let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
584
+ // Handle our minimal wrapper pattern
585
+ if data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 && data.last == 0x03 {
586
+ let range = data.index(data.startIndex, offsetBy: 3)..<data.index(data.endIndex, offsetBy: -1)
587
+ return data[range]
588
+ }
589
+
590
+ // Handle brotli.compress minimal wrapper (quality 0)
591
+ if data[0] == 0x0b && data[1] == 0x02 && data[2] == 0x80 && data.last == 0x03 {
592
+ let range = data.index(data.startIndex, offsetBy: 3)..<data.index(data.endIndex, offsetBy: -1)
593
+ return data[range]
594
+ }
595
+ }
596
+
597
+ // For all other cases, try standard decompression
598
+ let outputBufferSize = 65536
599
+ var outputBuffer = [UInt8](repeating: 0, count: outputBufferSize)
600
+ var decompressedData = Data()
601
+
602
+ let streamPointer = UnsafeMutablePointer<compression_stream>.allocate(capacity: 1)
603
+ var status = compression_stream_init(streamPointer, COMPRESSION_STREAM_DECODE, COMPRESSION_BROTLI)
604
+
605
+ guard status != COMPRESSION_STATUS_ERROR else {
606
+ logger.error("Error: Failed to initialize Brotli stream for \(fileName). Status: \(status)")
607
+ return nil
608
+ }
609
+
610
+ defer {
611
+ compression_stream_destroy(streamPointer)
612
+ streamPointer.deallocate()
613
+ }
614
+
615
+ streamPointer.pointee.src_size = 0
616
+ streamPointer.pointee.dst_ptr = UnsafeMutablePointer<UInt8>(&outputBuffer)
617
+ streamPointer.pointee.dst_size = outputBufferSize
618
+
619
+ let input = data
620
+
621
+ while true {
622
+ if streamPointer.pointee.src_size == 0 {
623
+ streamPointer.pointee.src_size = input.count
624
+ input.withUnsafeBytes { rawBufferPointer in
625
+ if let baseAddress = rawBufferPointer.baseAddress {
626
+ streamPointer.pointee.src_ptr = baseAddress.assumingMemoryBound(to: UInt8.self)
627
+ } else {
628
+ logger.error("Error: Failed to get base address for \(fileName)")
629
+ status = COMPRESSION_STATUS_ERROR
630
+ return
631
+ }
632
+ }
633
+ }
634
+
635
+ if status == COMPRESSION_STATUS_ERROR {
636
+ let maxBytes = min(32, data.count)
637
+ let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
638
+ logger.error("Error: Brotli decompression failed for \(fileName). First \(maxBytes) bytes: \(hexDump)")
639
+ break
640
+ }
641
+
642
+ status = compression_stream_process(streamPointer, 0)
643
+
644
+ let have = outputBufferSize - streamPointer.pointee.dst_size
645
+ if have > 0 {
646
+ decompressedData.append(outputBuffer, count: have)
647
+ }
648
+
649
+ if status == COMPRESSION_STATUS_END {
650
+ break
651
+ } else if status == COMPRESSION_STATUS_ERROR {
652
+ logger.error("Error: Brotli process failed for \(fileName). Status: \(status)")
653
+ if let text = String(data: data, encoding: .utf8) {
654
+ let asciiCount = text.unicodeScalars.filter { $0.isASCII }.count
655
+ let totalCount = text.unicodeScalars.count
656
+ if totalCount > 0 && Double(asciiCount) / Double(totalCount) >= 0.8 {
657
+ logger.error("Error: Input appears to be plain text: \(text)")
658
+ }
659
+ }
660
+
661
+ let maxBytes = min(32, data.count)
662
+ let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
663
+ logger.error("Error: Raw data (\(fileName)): \(hexDump)")
664
+
665
+ return nil
666
+ }
667
+
668
+ if streamPointer.pointee.dst_size == 0 {
669
+ streamPointer.pointee.dst_ptr = UnsafeMutablePointer<UInt8>(&outputBuffer)
670
+ streamPointer.pointee.dst_size = outputBufferSize
671
+ }
672
+
673
+ if input.count == 0 {
674
+ logger.error("Error: Zero input size for \(fileName)")
675
+ break
676
+ }
677
+ }
678
+
679
+ return status == COMPRESSION_STATUS_END ? decompressedData : nil
680
+ }
681
+
682
+ public func download(url: URL, version: String, sessionKey: String) throws -> BundleInfo {
683
+ let id: String = self.randomString(length: 10)
684
+ let semaphore = DispatchSemaphore(value: 0)
685
+ if version != getLocalUpdateVersion() {
686
+ cleanDownloadData()
687
+ }
688
+ ensureResumableFilesExist()
689
+ saveDownloadInfo(version)
690
+ var checksum = ""
691
+ var targetSize = -1
692
+ var lastSentProgress = 0
693
+ var totalReceivedBytes: Int64 = loadDownloadProgress() // Retrieving the amount of already downloaded data if exist, defined at 0 otherwise
694
+ let requestHeaders: HTTPHeaders = ["Range": "bytes=\(totalReceivedBytes)-"]
695
+
696
+ // Send stats for zip download start
697
+ self.sendStats(action: "download_zip_start", versionName: version)
698
+
699
+ // Opening connection for streaming the bytes
700
+ if totalReceivedBytes == 0 {
701
+ self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
702
+ }
703
+ var mainError: NSError?
704
+ let monitor = ClosureEventMonitor()
705
+ monitor.requestDidCompleteTaskWithError = { (_, _, error) in
706
+ if error != nil {
707
+ self.logger.error("Downloading failed - ClosureEventMonitor activated")
708
+ mainError = error as NSError?
709
+ }
710
+ }
711
+ let configuration = URLSessionConfiguration.default
712
+ configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
713
+ let session = Session(configuration: configuration, eventMonitors: [monitor])
714
+
715
+ let request = session.streamRequest(url, headers: requestHeaders).validate().onHTTPResponse(perform: { response in
716
+ if let contentLength = response.headers.value(for: "Content-Length") {
717
+ targetSize = (Int(contentLength) ?? -1) + Int(totalReceivedBytes)
718
+ }
719
+ }).responseStream { [weak self] streamResponse in
720
+ guard let self = self else { return }
721
+ switch streamResponse.event {
722
+ case .stream(let result):
723
+ if case .success(let data) = result {
724
+ self.tempData.append(data)
725
+
726
+ self.savePartialData(startingAt: UInt64(totalReceivedBytes)) // Saving the received data in the package.tmp file
727
+ totalReceivedBytes += Int64(data.count)
728
+
729
+ let percent = max(10, Int((Double(totalReceivedBytes) / Double(targetSize)) * 70.0))
730
+
731
+ let currentMilestone = (percent / 10) * 10
732
+ if currentMilestone > lastSentProgress && currentMilestone <= 70 {
733
+ for milestone in stride(from: lastSentProgress + 10, through: currentMilestone, by: 10) {
734
+ self.notifyDownload(id: id, percent: milestone, ignoreMultipleOfTen: false)
735
+ }
736
+ lastSentProgress = currentMilestone
737
+ }
738
+
739
+ } else {
740
+ self.logger.error("Download failed")
741
+ }
742
+
743
+ case .complete:
744
+ self.logger.info("Download complete, total received bytes: \(totalReceivedBytes)")
745
+ self.notifyDownload(id: id, percent: 70, ignoreMultipleOfTen: true)
746
+ semaphore.signal()
747
+ }
748
+ }
749
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum))
750
+ let reachabilityManager = NetworkReachabilityManager()
751
+ reachabilityManager?.startListening { status in
752
+ switch status {
753
+ case .notReachable:
754
+ // Stop the download request if the network is not reachable
755
+ request.cancel()
756
+ mainError = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet, userInfo: nil)
757
+ semaphore.signal()
758
+ default:
759
+ break
760
+ }
761
+ }
762
+ semaphore.wait()
763
+ reachabilityManager?.stopListening()
764
+
765
+ if mainError != nil {
766
+ logger.error("Failed to download: \(String(describing: mainError))")
767
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
768
+ throw mainError!
769
+ }
770
+
771
+ let finalPath = tempDataPath.deletingLastPathComponent().appendingPathComponent("\(id)")
772
+ do {
773
+ try CryptoCipher.decryptFile(filePath: tempDataPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
774
+ try FileManager.default.moveItem(at: tempDataPath, to: finalPath)
775
+ } catch {
776
+ logger.error("Failed decrypt file : \(error)")
777
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
778
+ cleanDownloadData()
779
+ throw error
780
+ }
781
+
782
+ do {
783
+ checksum = CryptoCipher.calcChecksum(filePath: finalPath)
784
+ logger.info("Downloading: 80% (unzipping)")
785
+ try self.saveDownloaded(sourceZip: finalPath, id: id, base: self.libraryDir.appendingPathComponent(self.bundleDirectory), notify: true)
786
+
787
+ } catch {
788
+ logger.error("Failed to unzip file: \(error)")
789
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
790
+ // Best-effort cleanup of the decrypted zip file when unzip fails
791
+ do {
792
+ if FileManager.default.fileExists(atPath: finalPath.path) {
793
+ try FileManager.default.removeItem(at: finalPath)
794
+ }
795
+ } catch {
796
+ logger.error("Could not delete failed zip at \(finalPath.path): \(error)")
797
+ }
798
+ cleanDownloadData()
799
+ throw error
800
+ }
801
+
802
+ self.notifyDownload(id: id, percent: 90)
803
+ logger.info("Downloading: 90% (wrapping up)")
804
+ let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum)
805
+ self.saveBundleInfo(id: id, bundle: info)
806
+ self.cleanDownloadData()
807
+
808
+ // Send stats for zip download complete
809
+ self.sendStats(action: "download_zip_complete", versionName: version)
810
+
811
+ self.notifyDownload(id: id, percent: 100, bundle: info)
812
+ logger.info("Downloading: 100% (complete)")
813
+ return info
814
+ }
815
+ private func ensureResumableFilesExist() {
816
+ let fileManager = FileManager.default
817
+ if !fileManager.fileExists(atPath: tempDataPath.path) {
818
+ if !fileManager.createFile(atPath: tempDataPath.path, contents: Data()) {
819
+ logger.error("Cannot ensure that a file at \(tempDataPath.path) exists")
820
+ }
821
+ }
822
+
823
+ if !fileManager.fileExists(atPath: updateInfo.path) {
824
+ if !fileManager.createFile(atPath: updateInfo.path, contents: Data()) {
825
+ logger.error("Cannot ensure that a file at \(updateInfo.path) exists")
826
+ }
827
+ }
828
+ }
829
+
830
+ private func cleanDownloadData() {
831
+ // Deleting package.tmp
832
+ let fileManager = FileManager.default
833
+ if fileManager.fileExists(atPath: tempDataPath.path) {
834
+ do {
835
+ try fileManager.removeItem(at: tempDataPath)
836
+ } catch {
837
+ logger.error("Could not delete file at \(tempDataPath): \(error)")
838
+ }
839
+ }
840
+ // Deleting update.dat
841
+ if fileManager.fileExists(atPath: updateInfo.path) {
842
+ do {
843
+ try fileManager.removeItem(at: updateInfo)
844
+ } catch {
845
+ logger.error("Could not delete file at \(updateInfo): \(error)")
846
+ }
847
+ }
848
+ }
849
+
850
+ private func savePartialData(startingAt byteOffset: UInt64) {
851
+ let fileManager = FileManager.default
852
+ do {
853
+ // Check if package.tmp exist
854
+ if !fileManager.fileExists(atPath: tempDataPath.path) {
855
+ try self.tempData.write(to: tempDataPath, options: .atomicWrite)
856
+ } else {
857
+ // If yes, it start writing on it
858
+ let fileHandle = try FileHandle(forWritingTo: tempDataPath)
859
+ fileHandle.seek(toFileOffset: byteOffset) // Moving at the specified position to start writing
860
+ fileHandle.write(self.tempData)
861
+ fileHandle.closeFile()
862
+ }
863
+ } catch {
864
+ logger.error("Failed to write data starting at byte \(byteOffset): \(error)")
865
+ }
866
+ self.tempData.removeAll() // Clearing tempData to avoid writing the same data multiple times
867
+ }
868
+
869
+ private func saveDownloadInfo(_ version: String) {
870
+ do {
871
+ try "\(version)".write(to: updateInfo, atomically: true, encoding: .utf8)
872
+ } catch {
873
+ logger.error("Failed to save progress: \(error)")
874
+ }
875
+ }
876
+ private func getLocalUpdateVersion() -> String { // Return the version that was tried to be downloaded on last download attempt
877
+ if !FileManager.default.fileExists(atPath: updateInfo.path) {
878
+ return "nil"
879
+ }
880
+ guard let versionString = try? String(contentsOf: updateInfo),
881
+ let version = Optional(versionString) else {
882
+ return "nil"
883
+ }
884
+ return version
885
+ }
886
+ private func loadDownloadProgress() -> Int64 {
887
+
888
+ let fileManager = FileManager.default
889
+ do {
890
+ let attributes = try fileManager.attributesOfItem(atPath: tempDataPath.path)
891
+ if let fileSize = attributes[.size] as? NSNumber {
892
+ return fileSize.int64Value
893
+ }
894
+ } catch {
895
+ logger.error("Could not retrieve already downloaded data size : \(error)")
896
+ }
897
+ return 0
898
+ }
899
+
900
+ public func list(raw: Bool = false) -> [BundleInfo] {
901
+ if !raw {
902
+ // UserDefaults.standard.dictionaryRepresentation().values
903
+ let dest: URL = libraryDir.appendingPathComponent(bundleDirectory)
904
+ do {
905
+ let files: [String] = try FileManager.default.contentsOfDirectory(atPath: dest.path)
906
+ var res: [BundleInfo] = []
907
+ logger.info("list File : \(dest.path)")
908
+ if dest.exist {
909
+ for id: String in files {
910
+ res.append(self.getBundleInfo(id: id))
911
+ }
912
+ }
913
+ return res
914
+ } catch {
915
+ logger.info("No version available \(dest.path)")
916
+ return []
917
+ }
918
+ } else {
919
+ guard let regex = try? NSRegularExpression(pattern: "^[0-9A-Za-z]{10}_info$") else {
920
+ logger.error("Invalid regex ?????")
921
+ return []
922
+ }
923
+ return UserDefaults.standard.dictionaryRepresentation().keys.filter {
924
+ let range = NSRange($0.startIndex..., in: $0)
925
+ let matches = regex.matches(in: $0, range: range)
926
+ return !matches.isEmpty
927
+ }.map {
928
+ $0.components(separatedBy: "_")[0]
929
+ }.map {
930
+ self.getBundleInfo(id: $0)
931
+ }
932
+ }
933
+
934
+ }
935
+
936
+ public func delete(id: String, removeInfo: Bool) -> Bool {
937
+ let deleted: BundleInfo = self.getBundleInfo(id: id)
938
+ if deleted.isBuiltin() || self.getCurrentBundleId() == id {
939
+ logger.info("Cannot delete \(id)")
940
+ return false
941
+ }
942
+
943
+ // Check if this is the next bundle and prevent deletion if it is
944
+ if let next = self.getNextBundle(),
945
+ !next.isDeleted() &&
946
+ !next.isErrorStatus() &&
947
+ next.getId() == id {
948
+ logger.info("Cannot delete the next bundle \(id)")
949
+ return false
950
+ }
951
+
952
+ let destPersist: URL = libraryDir.appendingPathComponent(bundleDirectory).appendingPathComponent(id)
953
+ do {
954
+ try FileManager.default.removeItem(atPath: destPersist.path)
955
+ } catch {
956
+ logger.error("Folder \(destPersist.path), not removed.")
957
+ // even if, we don;t care. Android doesn't care
958
+ if removeInfo {
959
+ self.removeBundleInfo(id: id)
960
+ }
961
+ self.sendStats(action: "delete", versionName: deleted.getVersionName())
962
+ return false
963
+ }
964
+ if removeInfo {
965
+ self.removeBundleInfo(id: id)
966
+ } else {
967
+ self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.localizedString))
968
+ }
969
+ logger.info("bundle delete \(deleted.getVersionName())")
970
+ self.sendStats(action: "delete", versionName: deleted.getVersionName())
971
+ return true
972
+ }
973
+
974
+ public func delete(id: String) -> Bool {
975
+ return self.delete(id: id, removeInfo: true)
976
+ }
977
+
978
+ public func cleanupDownloadDirectories(allowedIds: Set<String>) {
979
+ let bundleRoot = libraryDir.appendingPathComponent(bundleDirectory)
980
+ let fileManager = FileManager.default
981
+
982
+ guard fileManager.fileExists(atPath: bundleRoot.path) else {
983
+ return
984
+ }
985
+
986
+ do {
987
+ let contents = try fileManager.contentsOfDirectory(at: bundleRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
988
+
989
+ for url in contents {
990
+ let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
991
+ if resourceValues.isDirectory != true {
992
+ continue
993
+ }
994
+
995
+ let id = url.lastPathComponent
996
+
997
+ if allowedIds.contains(id) {
998
+ continue
999
+ }
1000
+
1001
+ do {
1002
+ try fileManager.removeItem(at: url)
1003
+ self.removeBundleInfo(id: id)
1004
+ logger.info("Deleted orphan bundle directory: \(id)")
1005
+ } catch {
1006
+ logger.error("Failed to delete orphan bundle directory: \(id) \(error.localizedDescription)")
1007
+ }
1008
+ }
1009
+ } catch {
1010
+ logger.error("Failed to enumerate bundle directory for cleanup: \(error.localizedDescription)")
1011
+ }
1012
+ }
1013
+
1014
+ public func getBundleDirectory(id: String) -> URL {
1015
+ return libraryDir.appendingPathComponent(self.bundleDirectory).appendingPathComponent(id)
1016
+ }
1017
+
1018
+ public func set(bundle: BundleInfo) -> Bool {
1019
+ return self.set(id: bundle.getId())
1020
+ }
1021
+
1022
+ private func bundleExists(id: String) -> Bool {
1023
+ let destPersist: URL = self.getBundleDirectory(id: id)
1024
+ let indexPersist: URL = destPersist.appendingPathComponent("index.html")
1025
+ let bundleIndo: BundleInfo = self.getBundleInfo(id: id)
1026
+ if
1027
+ destPersist.exist &&
1028
+ destPersist.isDirectory &&
1029
+ !indexPersist.isDirectory &&
1030
+ indexPersist.exist &&
1031
+ !bundleIndo.isDeleted() {
1032
+ return true
1033
+ }
1034
+ return false
1035
+ }
1036
+
1037
+ public func set(id: String) -> Bool {
1038
+ let newBundle: BundleInfo = self.getBundleInfo(id: id)
1039
+ if newBundle.isBuiltin() {
1040
+ self.reset()
1041
+ return true
1042
+ }
1043
+ if bundleExists(id: id) {
1044
+ let currentBundleName = self.getCurrentBundle().getVersionName()
1045
+ self.setCurrentBundle(bundle: self.getBundleDirectory(id: id).path)
1046
+ self.setBundleStatus(id: id, status: BundleStatus.PENDING)
1047
+ self.sendStats(action: "set", versionName: newBundle.getVersionName(), oldVersionName: currentBundleName)
1048
+ return true
1049
+ }
1050
+ self.setBundleStatus(id: id, status: BundleStatus.ERROR)
1051
+ self.sendStats(action: "set_fail", versionName: newBundle.getVersionName())
1052
+ return false
1053
+ }
1054
+
1055
+ public func autoReset() {
1056
+ let currentBundle: BundleInfo = self.getCurrentBundle()
1057
+ if !currentBundle.isBuiltin() && !self.bundleExists(id: currentBundle.getId()) {
1058
+ logger.info("Folder at bundle path does not exist. Triggering reset.")
1059
+ self.reset()
1060
+ }
1061
+ }
1062
+
1063
+ public func reset() {
1064
+ self.reset(isInternal: false)
1065
+ }
1066
+
1067
+ public func reset(isInternal: Bool) {
1068
+ logger.info("reset: \(isInternal)")
1069
+ let currentBundleName = self.getCurrentBundle().getVersionName()
1070
+ self.setCurrentBundle(bundle: "")
1071
+ self.setFallbackBundle(fallback: Optional<BundleInfo>.none)
1072
+ _ = self.setNextBundle(next: Optional<String>.none)
1073
+ if !isInternal {
1074
+ self.sendStats(action: "reset", versionName: self.getCurrentBundle().getVersionName(), oldVersionName: currentBundleName)
1075
+ }
1076
+ }
1077
+
1078
+ public func setSuccess(bundle: BundleInfo, autoDeletePrevious: Bool) {
1079
+ self.setBundleStatus(id: bundle.getId(), status: BundleStatus.SUCCESS)
1080
+ let fallback: BundleInfo = self.getFallbackBundle()
1081
+ logger.info("Fallback bundle is: \(fallback.toString())")
1082
+ logger.info("Version successfully loaded: \(bundle.toString())")
1083
+ if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() {
1084
+ let res = self.delete(id: fallback.getId())
1085
+ if res {
1086
+ logger.info("Deleted previous bundle: \(fallback.toString())")
1087
+ } else {
1088
+ logger.error("Failed to delete previous bundle: \(fallback.toString())")
1089
+ }
1090
+ }
1091
+ self.setFallbackBundle(fallback: bundle)
1092
+ }
1093
+
1094
+ public func setError(bundle: BundleInfo) {
1095
+ self.setBundleStatus(id: bundle.getId(), status: BundleStatus.ERROR)
1096
+ }
1097
+
1098
+ func unsetChannel() -> SetChannel {
1099
+ let setChannel: SetChannel = SetChannel()
1100
+
1101
+ // Check if rate limit was exceeded
1102
+ if CapgoUpdater.rateLimitExceeded {
1103
+ logger.debug("Skipping unsetChannel due to rate limit (429). Requests will resume after app restart.")
1104
+ setChannel.message = "Rate limit exceeded"
1105
+ setChannel.error = "rate_limit_exceeded"
1106
+ return setChannel
1107
+ }
1108
+
1109
+ if (self.channelUrl ).isEmpty {
1110
+ logger.error("Channel URL is not set")
1111
+ setChannel.message = "Channel URL is not set"
1112
+ setChannel.error = "missing_config"
1113
+ return setChannel
1114
+ }
1115
+ let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
1116
+ let parameters: InfoObject = self.createInfoObject()
1117
+
1118
+ let request = alamofireSession.request(self.channelUrl, method: .delete, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1119
+
1120
+ request.validate().responseDecodable(of: SetChannelDec.self) { response in
1121
+ // Check for 429 rate limit
1122
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1123
+ setChannel.message = "Rate limit exceeded"
1124
+ setChannel.error = "rate_limit_exceeded"
1125
+ semaphore.signal()
1126
+ return
1127
+ }
1128
+
1129
+ switch response.result {
1130
+ case .success:
1131
+ if let responseValue = response.value {
1132
+ if let error = responseValue.error {
1133
+ setChannel.error = error
1134
+ } else {
1135
+ setChannel.status = responseValue.status ?? ""
1136
+ setChannel.message = responseValue.message ?? ""
1137
+ }
1138
+ }
1139
+ case let .failure(error):
1140
+ self.logger.error("Error unset Channel \(error)")
1141
+ setChannel.error = "Request failed: \(error.localizedDescription)"
1142
+ }
1143
+ semaphore.signal()
1144
+ }
1145
+ semaphore.wait()
1146
+ return setChannel
1147
+ }
1148
+
1149
+ func setChannel(channel: String) -> SetChannel {
1150
+ let setChannel: SetChannel = SetChannel()
1151
+
1152
+ // Check if rate limit was exceeded
1153
+ if CapgoUpdater.rateLimitExceeded {
1154
+ logger.debug("Skipping setChannel due to rate limit (429). Requests will resume after app restart.")
1155
+ setChannel.message = "Rate limit exceeded"
1156
+ setChannel.error = "rate_limit_exceeded"
1157
+ return setChannel
1158
+ }
1159
+
1160
+ if (self.channelUrl ).isEmpty {
1161
+ logger.error("Channel URL is not set")
1162
+ setChannel.message = "Channel URL is not set"
1163
+ setChannel.error = "missing_config"
1164
+ return setChannel
1165
+ }
1166
+ let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
1167
+ var parameters: InfoObject = self.createInfoObject()
1168
+ parameters.channel = channel
1169
+
1170
+ let request = alamofireSession.request(self.channelUrl, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1171
+
1172
+ request.validate().responseDecodable(of: SetChannelDec.self) { response in
1173
+ // Check for 429 rate limit
1174
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1175
+ setChannel.message = "Rate limit exceeded"
1176
+ setChannel.error = "rate_limit_exceeded"
1177
+ semaphore.signal()
1178
+ return
1179
+ }
1180
+
1181
+ switch response.result {
1182
+ case .success:
1183
+ if let responseValue = response.value {
1184
+ if let error = responseValue.error {
1185
+ setChannel.error = error
1186
+ } else {
1187
+ setChannel.status = responseValue.status ?? ""
1188
+ setChannel.message = responseValue.message ?? ""
1189
+ }
1190
+ }
1191
+ case let .failure(error):
1192
+ self.logger.error("Error set Channel \(error)")
1193
+ setChannel.error = "Request failed: \(error.localizedDescription)"
1194
+ }
1195
+ semaphore.signal()
1196
+ }
1197
+ semaphore.wait()
1198
+ return setChannel
1199
+ }
1200
+
1201
+ func getChannel() -> GetChannel {
1202
+ let getChannel: GetChannel = GetChannel()
1203
+
1204
+ // Check if rate limit was exceeded
1205
+ if CapgoUpdater.rateLimitExceeded {
1206
+ logger.debug("Skipping getChannel due to rate limit (429). Requests will resume after app restart.")
1207
+ getChannel.message = "Rate limit exceeded"
1208
+ getChannel.error = "rate_limit_exceeded"
1209
+ return getChannel
1210
+ }
1211
+
1212
+ if (self.channelUrl ).isEmpty {
1213
+ logger.error("Channel URL is not set")
1214
+ getChannel.message = "Channel URL is not set"
1215
+ getChannel.error = "missing_config"
1216
+ return getChannel
1217
+ }
1218
+ let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
1219
+ let parameters: InfoObject = self.createInfoObject()
1220
+ let request = alamofireSession.request(self.channelUrl, method: .put, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1221
+
1222
+ request.validate().responseDecodable(of: GetChannelDec.self) { response in
1223
+ defer {
1224
+ semaphore.signal()
1225
+ }
1226
+
1227
+ // Check for 429 rate limit
1228
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1229
+ getChannel.message = "Rate limit exceeded"
1230
+ getChannel.error = "rate_limit_exceeded"
1231
+ return
1232
+ }
1233
+
1234
+ switch response.result {
1235
+ case .success:
1236
+ if let responseValue = response.value {
1237
+ if let error = responseValue.error {
1238
+ getChannel.error = error
1239
+ } else {
1240
+ getChannel.status = responseValue.status ?? ""
1241
+ getChannel.message = responseValue.message ?? ""
1242
+ getChannel.channel = responseValue.channel ?? ""
1243
+ getChannel.allowSet = responseValue.allowSet ?? true
1244
+ }
1245
+ }
1246
+ case let .failure(error):
1247
+ if let data = response.data, let bodyString = String(data: data, encoding: .utf8) {
1248
+ if bodyString.contains("channel_not_found") && response.response?.statusCode == 400 && !self.defaultChannel.isEmpty {
1249
+ getChannel.channel = self.defaultChannel
1250
+ getChannel.status = "default"
1251
+ return
1252
+ }
1253
+ }
1254
+
1255
+ self.logger.error("Error get Channel \(error)")
1256
+ getChannel.error = "Request failed: \(error.localizedDescription)"
1257
+ }
1258
+ }
1259
+ semaphore.wait()
1260
+ return getChannel
1261
+ }
1262
+
1263
+ func listChannels() -> ListChannels {
1264
+ let listChannels: ListChannels = ListChannels()
1265
+
1266
+ // Check if rate limit was exceeded
1267
+ if CapgoUpdater.rateLimitExceeded {
1268
+ logger.debug("Skipping listChannels due to rate limit (429). Requests will resume after app restart.")
1269
+ listChannels.error = "rate_limit_exceeded"
1270
+ return listChannels
1271
+ }
1272
+
1273
+ if (self.channelUrl).isEmpty {
1274
+ logger.error("Channel URL is not set")
1275
+ listChannels.error = "Channel URL is not set"
1276
+ return listChannels
1277
+ }
1278
+
1279
+ let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
1280
+
1281
+ // Create info object and convert to query parameters
1282
+ let infoObject = self.createInfoObject()
1283
+
1284
+ // Create query parameters from InfoObject
1285
+ var urlComponents = URLComponents(string: self.channelUrl)
1286
+ var queryItems: [URLQueryItem] = []
1287
+
1288
+ // Convert InfoObject to dictionary using Mirror
1289
+ let mirror = Mirror(reflecting: infoObject)
1290
+ for child in mirror.children {
1291
+ if let key = child.label, let value = child.value as? CustomStringConvertible {
1292
+ queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
1293
+ } else if let key = child.label {
1294
+ // Handle optional values
1295
+ let mirror = Mirror(reflecting: child.value)
1296
+ if let value = mirror.children.first?.value {
1297
+ queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
1298
+ }
1299
+ }
1300
+ }
1301
+
1302
+ urlComponents?.queryItems = queryItems
1303
+
1304
+ guard let url = urlComponents?.url else {
1305
+ logger.error("Invalid channel URL")
1306
+ listChannels.error = "Invalid channel URL"
1307
+ return listChannels
1308
+ }
1309
+
1310
+ let request = alamofireSession.request(url, method: .get, requestModifier: { $0.timeoutInterval = self.timeout })
1311
+
1312
+ request.validate().responseDecodable(of: ListChannelsDec.self) { response in
1313
+ defer {
1314
+ semaphore.signal()
1315
+ }
1316
+
1317
+ // Check for 429 rate limit
1318
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1319
+ listChannels.error = "rate_limit_exceeded"
1320
+ return
1321
+ }
1322
+
1323
+ switch response.result {
1324
+ case .success:
1325
+ if let responseValue = response.value {
1326
+ // Check for server-side errors
1327
+ if let error = responseValue.error {
1328
+ listChannels.error = error
1329
+ return
1330
+ }
1331
+
1332
+ // Backend returns direct array, so channels should be populated by our custom decoder
1333
+ if let channels = responseValue.channels {
1334
+ listChannels.channels = channels.map { channel in
1335
+ var channelDict: [String: Any] = [:]
1336
+ channelDict["id"] = channel.id ?? ""
1337
+ channelDict["name"] = channel.name ?? ""
1338
+ channelDict["public"] = channel.public ?? false
1339
+ channelDict["allow_self_set"] = channel.allow_self_set ?? false
1340
+ return channelDict
1341
+ }
1342
+ }
1343
+ }
1344
+ case let .failure(error):
1345
+ self.logger.error("Error list channels \(error)")
1346
+ listChannels.error = "Request failed: \(error.localizedDescription)"
1347
+ }
1348
+ }
1349
+ semaphore.wait()
1350
+ return listChannels
1351
+ }
1352
+
1353
+ private let operationQueue = OperationQueue()
1354
+
1355
+ func sendStats(action: String, versionName: String? = nil, oldVersionName: String? = "") {
1356
+ // Check if rate limit was exceeded
1357
+ if CapgoUpdater.rateLimitExceeded {
1358
+ logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.")
1359
+ return
1360
+ }
1361
+
1362
+ guard !statsUrl.isEmpty else {
1363
+ return
1364
+ }
1365
+ operationQueue.maxConcurrentOperationCount = 1
1366
+
1367
+ let versionName = versionName ?? getCurrentBundle().getVersionName()
1368
+
1369
+ var parameters = createInfoObject()
1370
+ parameters.action = action
1371
+ parameters.version_name = versionName
1372
+ parameters.old_version_name = oldVersionName ?? ""
1373
+
1374
+ let operation = BlockOperation {
1375
+ let semaphore = DispatchSemaphore(value: 0)
1376
+ self.alamofireSession.request(
1377
+ self.statsUrl,
1378
+ method: .post,
1379
+ parameters: parameters,
1380
+ encoder: JSONParameterEncoder.default,
1381
+ requestModifier: { $0.timeoutInterval = self.timeout }
1382
+ ).responseData { response in
1383
+ // Check for 429 rate limit
1384
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
1385
+ semaphore.signal()
1386
+ return
1387
+ }
1388
+
1389
+ switch response.result {
1390
+ case .success:
1391
+ self.logger.info("Stats sent for \(action), version \(versionName)")
1392
+ case let .failure(error):
1393
+ self.logger.error("Error sending stats: \(response.value?.debugDescription ?? "") \(error.localizedDescription)")
1394
+ }
1395
+ semaphore.signal()
1396
+ }
1397
+ semaphore.wait()
1398
+ }
1399
+ operationQueue.addOperation(operation)
1400
+
1401
+ }
1402
+
1403
+ public func getBundleInfo(id: String?) -> BundleInfo {
1404
+ var trueId = BundleInfo.VERSION_UNKNOWN
1405
+ if id != nil {
1406
+ trueId = id!
1407
+ }
1408
+ let result: BundleInfo
1409
+ if BundleInfo.ID_BUILTIN == trueId {
1410
+ result = BundleInfo(id: trueId, version: "", status: BundleStatus.SUCCESS, checksum: "")
1411
+ } else if BundleInfo.VERSION_UNKNOWN == trueId {
1412
+ result = BundleInfo(id: trueId, version: "", status: BundleStatus.ERROR, checksum: "")
1413
+ } else {
1414
+ do {
1415
+ result = try UserDefaults.standard.getObj(forKey: "\(trueId)\(self.INFO_SUFFIX)", castTo: BundleInfo.self)
1416
+ } catch {
1417
+ logger.error("Failed to parse info for bundle [\(trueId)] \(error.localizedDescription)")
1418
+ result = BundleInfo(id: trueId, version: "", status: BundleStatus.PENDING, checksum: "")
1419
+ }
1420
+ }
1421
+ return result
1422
+ }
1423
+
1424
+ public func getBundleInfoByVersionName(version: String) -> BundleInfo? {
1425
+ let installed: [BundleInfo] = self.list()
1426
+ for i in installed {
1427
+ if i.getVersionName() == version {
1428
+ return i
1429
+ }
1430
+ }
1431
+ return nil
1432
+ }
1433
+
1434
+ private func removeBundleInfo(id: String) {
1435
+ self.saveBundleInfo(id: id, bundle: nil)
1436
+ }
1437
+
1438
+ public func saveBundleInfo(id: String, bundle: BundleInfo?) {
1439
+ if bundle != nil && (bundle!.isBuiltin() || bundle!.isUnknown()) {
1440
+ logger.info("Not saving info for bundle [\(id)] \(bundle?.toString() ?? "")")
1441
+ return
1442
+ }
1443
+ if bundle == nil {
1444
+ logger.info("Removing info for bundle [\(id)]")
1445
+ UserDefaults.standard.removeObject(forKey: "\(id)\(self.INFO_SUFFIX)")
1446
+ } else {
1447
+ let update = bundle!.setId(id: id)
1448
+ logger.info("Storing info for bundle [\(id)] \(update.toString())")
1449
+ do {
1450
+ try UserDefaults.standard.setObj(update, forKey: "\(id)\(self.INFO_SUFFIX)")
1451
+ } catch {
1452
+ logger.error("Failed to save info for bundle [\(id)] \(error.localizedDescription)")
1453
+ }
1454
+ }
1455
+ UserDefaults.standard.synchronize()
1456
+ }
1457
+
1458
+ private func setBundleStatus(id: String, status: BundleStatus) {
1459
+ logger.info("Setting status for bundle [\(id)] to \(status)")
1460
+ let info = self.getBundleInfo(id: id)
1461
+ self.saveBundleInfo(id: id, bundle: info.setStatus(status: status.localizedString))
1462
+ }
1463
+
1464
+ public func getCurrentBundle() -> BundleInfo {
1465
+ return self.getBundleInfo(id: self.getCurrentBundleId())
1466
+ }
1467
+
1468
+ public func getCurrentBundleId() -> String {
1469
+ guard let bundlePath: String = UserDefaults.standard.string(forKey: self.CAP_SERVER_PATH) else {
1470
+ return BundleInfo.ID_BUILTIN
1471
+ }
1472
+ if (bundlePath).isEmpty {
1473
+ return BundleInfo.ID_BUILTIN
1474
+ }
1475
+ let bundleID: String = bundlePath.components(separatedBy: "/").last ?? bundlePath
1476
+ return bundleID
1477
+ }
1478
+
1479
+ public func isUsingBuiltin() -> Bool {
1480
+ return (UserDefaults.standard.string(forKey: self.CAP_SERVER_PATH) ?? "") == self.DEFAULT_FOLDER
1481
+ }
1482
+
1483
+ public func getFallbackBundle() -> BundleInfo {
1484
+ let id: String = UserDefaults.standard.string(forKey: self.FALLBACK_VERSION) ?? BundleInfo.ID_BUILTIN
1485
+ return self.getBundleInfo(id: id)
1486
+ }
1487
+
1488
+ private func setFallbackBundle(fallback: BundleInfo?) {
1489
+ UserDefaults.standard.set(fallback == nil ? BundleInfo.ID_BUILTIN : fallback!.getId(), forKey: self.FALLBACK_VERSION)
1490
+ UserDefaults.standard.synchronize()
1491
+ }
1492
+
1493
+ public func getNextBundle() -> BundleInfo? {
1494
+ let id: String? = UserDefaults.standard.string(forKey: self.NEXT_VERSION)
1495
+ return self.getBundleInfo(id: id)
1496
+ }
1497
+
1498
+ public func setNextBundle(next: String?) -> Bool {
1499
+ guard let nextId: String = next else {
1500
+ UserDefaults.standard.removeObject(forKey: self.NEXT_VERSION)
1501
+ UserDefaults.standard.synchronize()
1502
+ return false
1503
+ }
1504
+ let newBundle: BundleInfo = self.getBundleInfo(id: nextId)
1505
+ if !newBundle.isBuiltin() && !self.bundleExists(id: nextId) {
1506
+ return false
1507
+ }
1508
+ UserDefaults.standard.set(nextId, forKey: self.NEXT_VERSION)
1509
+ UserDefaults.standard.synchronize()
1510
+ self.setBundleStatus(id: nextId, status: BundleStatus.PENDING)
1511
+ return true
1512
+ }
1513
+ }