@capgo/capacitor-updater 7.13.15 → 7.13.18

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.
@@ -0,0 +1,1301 @@
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 PLUGIN_VERSION: 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
+ private var userAgent: String {
46
+ let safePluginVersion = PLUGIN_VERSION.isEmpty ? "unknown" : PLUGIN_VERSION
47
+ let safeAppId = appId.isEmpty ? "unknown" : appId
48
+ return "CapacitorUpdater/\(safePluginVersion) (\(safeAppId))"
49
+ }
50
+
51
+ private lazy var alamofireSession: Session = {
52
+ let configuration = URLSessionConfiguration.default
53
+ configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
54
+ return Session(configuration: configuration)
55
+ }()
56
+
57
+ public var notifyDownloadRaw: (String, Int, Bool) -> Void = { _, _, _ in }
58
+ public func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false) {
59
+ notifyDownloadRaw(id, percent, ignoreMultipleOfTen)
60
+ }
61
+ public var notifyDownload: (String, Int) -> Void = { _, _ in }
62
+
63
+ public func setLogger(_ logger: Logger) {
64
+ self.logger = logger
65
+ }
66
+
67
+ private func calcTotalPercent(percent: Int, min: Int, max: Int) -> Int {
68
+ return (percent * (max - min)) / 100 + min
69
+ }
70
+
71
+ private func randomString(length: Int) -> String {
72
+ let letters: String = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
73
+ return String((0..<length).map { _ in letters.randomElement()! })
74
+ }
75
+
76
+ private var isDevEnvironment: Bool {
77
+ #if DEBUG
78
+ return true
79
+ #else
80
+ return false
81
+ #endif
82
+ }
83
+
84
+ private func isProd() -> Bool {
85
+ return !self.isDevEnvironment && !self.isAppStoreReceiptSandbox() && !self.hasEmbeddedMobileProvision()
86
+ }
87
+
88
+ // MARK: Private
89
+ private func hasEmbeddedMobileProvision() -> Bool {
90
+ guard Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") == nil else {
91
+ return true
92
+ }
93
+ return false
94
+ }
95
+
96
+ private func isAppStoreReceiptSandbox() -> Bool {
97
+
98
+ if isEmulator() {
99
+ return false
100
+ } else {
101
+ guard let url: URL = Bundle.main.appStoreReceiptURL else {
102
+ return false
103
+ }
104
+ guard url.lastPathComponent == "sandboxReceipt" else {
105
+ return false
106
+ }
107
+ return true
108
+ }
109
+ }
110
+
111
+ private func isEmulator() -> Bool {
112
+ #if targetEnvironment(simulator)
113
+ return true
114
+ #else
115
+ return false
116
+ #endif
117
+ }
118
+ // Persistent path /var/mobile/Containers/Data/Application/8C0C07BE-0FD3-4FD4-B7DF-90A88E12B8C3/Library/NoCloud/ionic_built_snapshots/FOLDER
119
+ // Hot Reload path /var/mobile/Containers/Data/Application/8C0C07BE-0FD3-4FD4-B7DF-90A88E12B8C3/Documents/FOLDER
120
+ // Normal /private/var/containers/Bundle/Application/8C0C07BE-0FD3-4FD4-B7DF-90A88E12B8C3/App.app/public
121
+
122
+ private func prepareFolder(source: URL) throws {
123
+ if !FileManager.default.fileExists(atPath: source.path) {
124
+ do {
125
+ try FileManager.default.createDirectory(atPath: source.path, withIntermediateDirectories: true, attributes: nil)
126
+ } catch {
127
+ logger.error("Cannot createDirectory \(source.path)")
128
+ throw CustomError.cannotCreateDirectory
129
+ }
130
+ }
131
+ }
132
+
133
+ private func deleteFolder(source: URL) throws {
134
+ do {
135
+ try FileManager.default.removeItem(atPath: source.path)
136
+ } catch {
137
+ logger.error("File not removed. \(source.path)")
138
+ throw CustomError.cannotDeleteDirectory
139
+ }
140
+ }
141
+
142
+ private func unflatFolder(source: URL, dest: URL) throws -> Bool {
143
+ let index: URL = source.appendingPathComponent("index.html")
144
+ do {
145
+ let files: [String] = try FileManager.default.contentsOfDirectory(atPath: source.path)
146
+ if files.count == 1 && source.appendingPathComponent(files[0]).isDirectory && !FileManager.default.fileExists(atPath: index.path) {
147
+ try FileManager.default.moveItem(at: source.appendingPathComponent(files[0]), to: dest)
148
+ return true
149
+ } else {
150
+ try FileManager.default.moveItem(at: source, to: dest)
151
+ return false
152
+ }
153
+ } catch {
154
+ logger.error("File not moved. source: \(source.path) dest: \(dest.path)")
155
+ throw CustomError.cannotUnflat
156
+ }
157
+ }
158
+
159
+ private func unzipProgressHandler(entry: String, zipInfo: unz_file_info, entryNumber: Int, total: Int, destUnZip: URL, id: String, unzipError: inout NSError?) {
160
+ if entry.contains("\\") {
161
+ logger.error("unzip: Windows path is not supported, please use unix path as required by zip RFC: \(entry)")
162
+ self.sendStats(action: "windows_path_fail")
163
+ }
164
+
165
+ let fileURL = destUnZip.appendingPathComponent(entry)
166
+ let canonicalPath = fileURL.path
167
+ let canonicalDir = destUnZip.path
168
+
169
+ if !canonicalPath.hasPrefix(canonicalDir) {
170
+ self.sendStats(action: "canonical_path_fail")
171
+ unzipError = NSError(domain: "CanonicalPathError", code: 0, userInfo: nil)
172
+ }
173
+
174
+ let isDirectory = entry.hasSuffix("/")
175
+ if !isDirectory {
176
+ let folderURL = fileURL.deletingLastPathComponent()
177
+ if !FileManager.default.fileExists(atPath: folderURL.path) {
178
+ do {
179
+ try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true, attributes: nil)
180
+ } catch {
181
+ self.sendStats(action: "directory_path_fail")
182
+ unzipError = error as NSError
183
+ }
184
+ }
185
+ }
186
+
187
+ let newPercent = self.calcTotalPercent(percent: Int(Double(entryNumber) / Double(total) * 100), min: 75, max: 81)
188
+ if newPercent != self.unzipPercent {
189
+ self.unzipPercent = newPercent
190
+ self.notifyDownload(id: id, percent: newPercent)
191
+ }
192
+ }
193
+
194
+ private func saveDownloaded(sourceZip: URL, id: String, base: URL, notify: Bool) throws {
195
+ try prepareFolder(source: base)
196
+ let destPersist: URL = base.appendingPathComponent(id)
197
+ let destUnZip: URL = libraryDir.appendingPathComponent(randomString(length: 10))
198
+
199
+ self.unzipPercent = 0
200
+ self.notifyDownload(id: id, percent: 75)
201
+
202
+ let semaphore = DispatchSemaphore(value: 0)
203
+ var unzipError: NSError?
204
+
205
+ let success = SSZipArchive.unzipFile(atPath: sourceZip.path,
206
+ toDestination: destUnZip.path,
207
+ preserveAttributes: true,
208
+ overwrite: true,
209
+ nestedZipLevel: 1,
210
+ password: nil,
211
+ error: &unzipError,
212
+ delegate: nil,
213
+ progressHandler: { [weak self] (entry, zipInfo, entryNumber, total) in
214
+ DispatchQueue.global(qos: .background).async {
215
+ guard let self = self else { return }
216
+ if !notify {
217
+ return
218
+ }
219
+ self.unzipProgressHandler(entry: entry, zipInfo: zipInfo, entryNumber: entryNumber, total: total, destUnZip: destUnZip, id: id, unzipError: &unzipError)
220
+ }
221
+ },
222
+ completionHandler: { _, _, _ in
223
+ semaphore.signal()
224
+ })
225
+
226
+ semaphore.wait()
227
+
228
+ if !success || unzipError != nil {
229
+ self.sendStats(action: "unzip_fail")
230
+ throw unzipError ?? CustomError.cannotUnzip
231
+ }
232
+
233
+ if try unflatFolder(source: destUnZip, dest: destPersist) {
234
+ try deleteFolder(source: destUnZip)
235
+ }
236
+
237
+ // Cleanup: remove the downloaded/decrypted zip after successful extraction
238
+ do {
239
+ if FileManager.default.fileExists(atPath: sourceZip.path) {
240
+ try FileManager.default.removeItem(at: sourceZip)
241
+ }
242
+ } catch {
243
+ logger.error("Could not delete source zip at \(sourceZip.path): \(error)")
244
+ }
245
+ }
246
+
247
+ private func createInfoObject() -> InfoObject {
248
+ return InfoObject(
249
+ platform: "ios",
250
+ device_id: self.deviceID,
251
+ app_id: self.appId,
252
+ custom_id: self.customId,
253
+ version_build: self.versionBuild,
254
+ version_code: self.versionCode,
255
+ version_os: self.versionOs,
256
+ version_name: self.getCurrentBundle().getVersionName(),
257
+ plugin_version: self.PLUGIN_VERSION,
258
+ is_emulator: self.isEmulator(),
259
+ is_prod: self.isProd(),
260
+ action: nil,
261
+ channel: nil,
262
+ defaultChannel: self.defaultChannel
263
+ )
264
+ }
265
+
266
+ public func getLatest(url: URL, channel: String?) -> AppVersion {
267
+ let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
268
+ let latest: AppVersion = AppVersion()
269
+ var parameters: InfoObject = self.createInfoObject()
270
+ if let channel = channel {
271
+ parameters.defaultChannel = channel
272
+ }
273
+ logger.info("Auto-update parameters: \(parameters)")
274
+ let request = alamofireSession.request(url, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
275
+
276
+ request.validate().responseDecodable(of: AppVersionDec.self) { response in
277
+ switch response.result {
278
+ case .success:
279
+ if let url = response.value?.url {
280
+ latest.url = url
281
+ }
282
+ if let checksum = response.value?.checksum {
283
+ latest.checksum = checksum
284
+ }
285
+ if let version = response.value?.version {
286
+ latest.version = version
287
+ }
288
+ if let major = response.value?.major {
289
+ latest.major = major
290
+ }
291
+ if let error = response.value?.error {
292
+ latest.error = error
293
+ }
294
+ if let message = response.value?.message {
295
+ latest.message = message
296
+ }
297
+ if let sessionKey = response.value?.session_key {
298
+ latest.sessionKey = sessionKey
299
+ }
300
+ if let data = response.value?.data {
301
+ latest.data = data
302
+ }
303
+ if let manifest = response.value?.manifest {
304
+ latest.manifest = manifest
305
+ }
306
+ case let .failure(error):
307
+ self.logger.error("Error getting Latest \(response.value.debugDescription) \(error)")
308
+ latest.message = "Error getting Latest \(String(describing: response.value))"
309
+ latest.error = "response_error"
310
+ }
311
+ semaphore.signal()
312
+ }
313
+ semaphore.wait()
314
+ return latest
315
+ }
316
+
317
+ private func setCurrentBundle(bundle: String) {
318
+ UserDefaults.standard.set(bundle, forKey: self.CAP_SERVER_PATH)
319
+ UserDefaults.standard.synchronize()
320
+ logger.info("Current bundle set to: \((bundle ).isEmpty ? BundleInfo.ID_BUILTIN : bundle)")
321
+ }
322
+
323
+ private var tempDataPath: URL {
324
+ return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("package.tmp")
325
+ }
326
+
327
+ private var updateInfo: URL {
328
+ return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("update.dat")
329
+ }
330
+ private var tempData = Data()
331
+
332
+ private func verifyChecksum(file: URL, expectedHash: String) -> Bool {
333
+ let actualHash = CryptoCipher.calcChecksum(filePath: file)
334
+ return actualHash == expectedHash
335
+ }
336
+
337
+ public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String) throws -> BundleInfo {
338
+ let id = self.randomString(length: 10)
339
+ logger.info("downloadManifest start \(id)")
340
+ let destFolder = self.getBundleDirectory(id: id)
341
+ let builtinFolder = Bundle.main.bundleURL.appendingPathComponent("public")
342
+
343
+ try FileManager.default.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
344
+ try FileManager.default.createDirectory(at: destFolder, withIntermediateDirectories: true, attributes: nil)
345
+
346
+ // Create and save BundleInfo before starting the download process
347
+ let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: "")
348
+ self.saveBundleInfo(id: id, bundle: bundleInfo)
349
+
350
+ // Notify the start of the download process
351
+ self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
352
+
353
+ let dispatchGroup = DispatchGroup()
354
+ var downloadError: Error?
355
+
356
+ let totalFiles = manifest.count
357
+ var completedFiles = 0
358
+
359
+ for entry in manifest {
360
+ guard let fileName = entry.file_name,
361
+ var fileHash = entry.file_hash,
362
+ let downloadUrl = entry.download_url else {
363
+ continue
364
+ }
365
+
366
+ if !self.publicKey.isEmpty && !sessionKey.isEmpty {
367
+ do {
368
+ fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
369
+ } catch {
370
+ downloadError = error
371
+ logger.error("CryptoCipher.decryptChecksum error \(id) \(fileName) error: \(error)")
372
+ }
373
+ }
374
+
375
+ let fileNameWithoutPath = (fileName as NSString).lastPathComponent
376
+ let cacheFileName = "\(fileHash)_\(fileNameWithoutPath)"
377
+ let cacheFilePath = cacheFolder.appendingPathComponent(cacheFileName)
378
+ let destFilePath = destFolder.appendingPathComponent(fileName)
379
+ let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
380
+
381
+ // Create necessary subdirectories in the destination folder
382
+ try FileManager.default.createDirectory(at: destFilePath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
383
+
384
+ dispatchGroup.enter()
385
+
386
+ if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
387
+ try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
388
+ logger.info("downloadManifest \(fileName) using builtin file \(id)")
389
+ completedFiles += 1
390
+ self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
391
+ dispatchGroup.leave()
392
+ } else if FileManager.default.fileExists(atPath: cacheFilePath.path) && verifyChecksum(file: cacheFilePath, expectedHash: fileHash) {
393
+ try FileManager.default.copyItem(at: cacheFilePath, to: destFilePath)
394
+ logger.info("downloadManifest \(fileName) copy from cache \(id)")
395
+ completedFiles += 1
396
+ self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
397
+ dispatchGroup.leave()
398
+ } else {
399
+ // File not in cache, download, decompress, and save to both cache and destination
400
+ self.alamofireSession.download(downloadUrl).responseData { response in
401
+ defer { dispatchGroup.leave() }
402
+
403
+ switch response.result {
404
+ case .success(let data):
405
+ do {
406
+ let statusCode = response.response?.statusCode ?? 200
407
+ if statusCode < 200 || statusCode >= 300 {
408
+ if let stringData = String(data: data, encoding: .utf8) {
409
+ throw NSError(domain: "StatusCodeError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid. Data: \(stringData) for file \(fileName) at url \(downloadUrl)"])
410
+ } else {
411
+ throw NSError(domain: "StatusCodeError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
412
+ }
413
+ }
414
+
415
+ // Add decryption step if public key is set and sessionKey is provided
416
+ var finalData = data
417
+ if !self.publicKey.isEmpty && !sessionKey.isEmpty {
418
+ let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
419
+ try finalData.write(to: tempFile)
420
+ do {
421
+ try CryptoCipher.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
422
+ } catch {
423
+ self.sendStats(action: "decrypt_fail", versionName: version)
424
+ throw error
425
+ }
426
+ // TODO: try and do self.sendStats(action: "decrypt_fail", versionName: version) if fail
427
+ finalData = try Data(contentsOf: tempFile)
428
+ try FileManager.default.removeItem(at: tempFile)
429
+ }
430
+
431
+ // Check if file has .br extension for Brotli decompression
432
+ let isBrotli = fileName.hasSuffix(".br")
433
+ let finalFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
434
+ let destFilePath = destFolder.appendingPathComponent(finalFileName)
435
+
436
+ if isBrotli {
437
+ // Decompress the Brotli data
438
+ guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
439
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
440
+ }
441
+ finalData = decompressedData
442
+ }
443
+
444
+ try finalData.write(to: destFilePath)
445
+ if !self.publicKey.isEmpty && !sessionKey.isEmpty {
446
+ // assume that calcChecksum != null
447
+ let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
448
+ if calculatedChecksum != fileHash {
449
+ throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
450
+ }
451
+ }
452
+
453
+ // Save decrypted data to cache and destination
454
+ try finalData.write(to: cacheFilePath)
455
+
456
+ completedFiles += 1
457
+ self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
458
+ self.logger.info("downloadManifest \(id) \(fileName) downloaded\(isBrotli ? ", decompressed" : "")\(!self.publicKey.isEmpty && !sessionKey.isEmpty ? ", decrypted" : ""), and cached")
459
+ } catch {
460
+ downloadError = error
461
+ self.logger.error("downloadManifest \(id) \(fileName) error: \(error.localizedDescription)")
462
+ }
463
+ case .failure(let error):
464
+ self.logger.error("downloadManifest \(id) \(fileName) download error: \(error.localizedDescription). Debug response: \(response.debugDescription).")
465
+ }
466
+ }
467
+ }
468
+ }
469
+
470
+ dispatchGroup.wait()
471
+
472
+ if let error = downloadError {
473
+ // Update bundle status to ERROR if download failed
474
+ let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.localizedString)
475
+ self.saveBundleInfo(id: id, bundle: errorBundle)
476
+ throw error
477
+ }
478
+
479
+ // Update bundle status to PENDING after successful download
480
+ let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.localizedString)
481
+ self.saveBundleInfo(id: id, bundle: updatedBundle)
482
+
483
+ logger.info("downloadManifest done \(id)")
484
+ return updatedBundle
485
+ }
486
+
487
+ private func decompressBrotli(data: Data, fileName: String) -> Data? {
488
+ // Handle empty files
489
+ if data.count == 0 {
490
+ return data
491
+ }
492
+
493
+ // Handle the special EMPTY_BROTLI_STREAM case
494
+ if data.count == 3 && data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 {
495
+ return Data()
496
+ }
497
+
498
+ // For small files, check if it's a minimal Brotli wrapper
499
+ if data.count > 3 {
500
+ let maxBytes = min(32, data.count)
501
+ let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
502
+ // Handle our minimal wrapper pattern
503
+ if data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 && data.last == 0x03 {
504
+ let range = data.index(data.startIndex, offsetBy: 3)..<data.index(data.endIndex, offsetBy: -1)
505
+ return data[range]
506
+ }
507
+
508
+ // Handle brotli.compress minimal wrapper (quality 0)
509
+ if data[0] == 0x0b && data[1] == 0x02 && data[2] == 0x80 && data.last == 0x03 {
510
+ let range = data.index(data.startIndex, offsetBy: 3)..<data.index(data.endIndex, offsetBy: -1)
511
+ return data[range]
512
+ }
513
+ }
514
+
515
+ // For all other cases, try standard decompression
516
+ let outputBufferSize = 65536
517
+ var outputBuffer = [UInt8](repeating: 0, count: outputBufferSize)
518
+ var decompressedData = Data()
519
+
520
+ let streamPointer = UnsafeMutablePointer<compression_stream>.allocate(capacity: 1)
521
+ var status = compression_stream_init(streamPointer, COMPRESSION_STREAM_DECODE, COMPRESSION_BROTLI)
522
+
523
+ guard status != COMPRESSION_STATUS_ERROR else {
524
+ logger.error("Error: Failed to initialize Brotli stream for \(fileName). Status: \(status)")
525
+ return nil
526
+ }
527
+
528
+ defer {
529
+ compression_stream_destroy(streamPointer)
530
+ streamPointer.deallocate()
531
+ }
532
+
533
+ streamPointer.pointee.src_size = 0
534
+ streamPointer.pointee.dst_ptr = UnsafeMutablePointer<UInt8>(&outputBuffer)
535
+ streamPointer.pointee.dst_size = outputBufferSize
536
+
537
+ let input = data
538
+
539
+ while true {
540
+ if streamPointer.pointee.src_size == 0 {
541
+ streamPointer.pointee.src_size = input.count
542
+ input.withUnsafeBytes { rawBufferPointer in
543
+ if let baseAddress = rawBufferPointer.baseAddress {
544
+ streamPointer.pointee.src_ptr = baseAddress.assumingMemoryBound(to: UInt8.self)
545
+ } else {
546
+ logger.error("Error: Failed to get base address for \(fileName)")
547
+ status = COMPRESSION_STATUS_ERROR
548
+ return
549
+ }
550
+ }
551
+ }
552
+
553
+ if status == COMPRESSION_STATUS_ERROR {
554
+ let maxBytes = min(32, data.count)
555
+ let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
556
+ logger.error("Error: Brotli decompression failed for \(fileName). First \(maxBytes) bytes: \(hexDump)")
557
+ break
558
+ }
559
+
560
+ status = compression_stream_process(streamPointer, 0)
561
+
562
+ let have = outputBufferSize - streamPointer.pointee.dst_size
563
+ if have > 0 {
564
+ decompressedData.append(outputBuffer, count: have)
565
+ }
566
+
567
+ if status == COMPRESSION_STATUS_END {
568
+ break
569
+ } else if status == COMPRESSION_STATUS_ERROR {
570
+ logger.error("Error: Brotli process failed for \(fileName). Status: \(status)")
571
+ if let text = String(data: data, encoding: .utf8) {
572
+ let asciiCount = text.unicodeScalars.filter { $0.isASCII }.count
573
+ let totalCount = text.unicodeScalars.count
574
+ if totalCount > 0 && Double(asciiCount) / Double(totalCount) >= 0.8 {
575
+ logger.error("Error: Input appears to be plain text: \(text)")
576
+ }
577
+ }
578
+
579
+ let maxBytes = min(32, data.count)
580
+ let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
581
+ logger.error("Error: Raw data (\(fileName)): \(hexDump)")
582
+
583
+ return nil
584
+ }
585
+
586
+ if streamPointer.pointee.dst_size == 0 {
587
+ streamPointer.pointee.dst_ptr = UnsafeMutablePointer<UInt8>(&outputBuffer)
588
+ streamPointer.pointee.dst_size = outputBufferSize
589
+ }
590
+
591
+ if input.count == 0 {
592
+ logger.error("Error: Zero input size for \(fileName)")
593
+ break
594
+ }
595
+ }
596
+
597
+ return status == COMPRESSION_STATUS_END ? decompressedData : nil
598
+ }
599
+
600
+ public func download(url: URL, version: String, sessionKey: String) throws -> BundleInfo {
601
+ let id: String = self.randomString(length: 10)
602
+ let semaphore = DispatchSemaphore(value: 0)
603
+ if version != getLocalUpdateVersion() {
604
+ cleanDownloadData()
605
+ }
606
+ ensureResumableFilesExist()
607
+ saveDownloadInfo(version)
608
+ var checksum = ""
609
+ var targetSize = -1
610
+ var lastSentProgress = 0
611
+ var totalReceivedBytes: Int64 = loadDownloadProgress() // Retrieving the amount of already downloaded data if exist, defined at 0 otherwise
612
+ let requestHeaders: HTTPHeaders = ["Range": "bytes=\(totalReceivedBytes)-"]
613
+ // Opening connection for streaming the bytes
614
+ if totalReceivedBytes == 0 {
615
+ self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
616
+ }
617
+ var mainError: NSError?
618
+ let monitor = ClosureEventMonitor()
619
+ monitor.requestDidCompleteTaskWithError = { (_, _, error) in
620
+ if error != nil {
621
+ self.logger.error("Downloading failed - ClosureEventMonitor activated")
622
+ mainError = error as NSError?
623
+ }
624
+ }
625
+ let configuration = URLSessionConfiguration.default
626
+ configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
627
+ let session = Session(configuration: configuration, eventMonitors: [monitor])
628
+
629
+ let request = session.streamRequest(url, headers: requestHeaders).validate().onHTTPResponse(perform: { response in
630
+ if let contentLength = response.headers.value(for: "Content-Length") {
631
+ targetSize = (Int(contentLength) ?? -1) + Int(totalReceivedBytes)
632
+ }
633
+ }).responseStream { [weak self] streamResponse in
634
+ guard let self = self else { return }
635
+ switch streamResponse.event {
636
+ case .stream(let result):
637
+ if case .success(let data) = result {
638
+ self.tempData.append(data)
639
+
640
+ self.savePartialData(startingAt: UInt64(totalReceivedBytes)) // Saving the received data in the package.tmp file
641
+ totalReceivedBytes += Int64(data.count)
642
+
643
+ let percent = max(10, Int((Double(totalReceivedBytes) / Double(targetSize)) * 70.0))
644
+
645
+ let currentMilestone = (percent / 10) * 10
646
+ if currentMilestone > lastSentProgress && currentMilestone <= 70 {
647
+ for milestone in stride(from: lastSentProgress + 10, through: currentMilestone, by: 10) {
648
+ self.notifyDownload(id: id, percent: milestone, ignoreMultipleOfTen: false)
649
+ }
650
+ lastSentProgress = currentMilestone
651
+ }
652
+
653
+ } else {
654
+ self.logger.error("Download failed")
655
+ }
656
+
657
+ case .complete:
658
+ self.logger.info("Download complete, total received bytes: \(totalReceivedBytes)")
659
+ self.notifyDownload(id: id, percent: 70, ignoreMultipleOfTen: true)
660
+ semaphore.signal()
661
+ }
662
+ }
663
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum))
664
+ let reachabilityManager = NetworkReachabilityManager()
665
+ reachabilityManager?.startListening { status in
666
+ switch status {
667
+ case .notReachable:
668
+ // Stop the download request if the network is not reachable
669
+ request.cancel()
670
+ mainError = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet, userInfo: nil)
671
+ semaphore.signal()
672
+ default:
673
+ break
674
+ }
675
+ }
676
+ semaphore.wait()
677
+ reachabilityManager?.stopListening()
678
+
679
+ if mainError != nil {
680
+ logger.error("Failed to download: \(String(describing: mainError))")
681
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
682
+ throw mainError!
683
+ }
684
+
685
+ let finalPath = tempDataPath.deletingLastPathComponent().appendingPathComponent("\(id)")
686
+ do {
687
+ try CryptoCipher.decryptFile(filePath: tempDataPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
688
+ try FileManager.default.moveItem(at: tempDataPath, to: finalPath)
689
+ } catch {
690
+ logger.error("Failed decrypt file : \(error)")
691
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
692
+ cleanDownloadData()
693
+ throw error
694
+ }
695
+
696
+ do {
697
+ checksum = CryptoCipher.calcChecksum(filePath: finalPath)
698
+ logger.info("Downloading: 80% (unzipping)")
699
+ try self.saveDownloaded(sourceZip: finalPath, id: id, base: self.libraryDir.appendingPathComponent(self.bundleDirectory), notify: true)
700
+
701
+ } catch {
702
+ logger.error("Failed to unzip file: \(error)")
703
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
704
+ // Best-effort cleanup of the decrypted zip file when unzip fails
705
+ do {
706
+ if FileManager.default.fileExists(atPath: finalPath.path) {
707
+ try FileManager.default.removeItem(at: finalPath)
708
+ }
709
+ } catch {
710
+ logger.error("Could not delete failed zip at \(finalPath.path): \(error)")
711
+ }
712
+ cleanDownloadData()
713
+ throw error
714
+ }
715
+
716
+ self.notifyDownload(id: id, percent: 90)
717
+ logger.info("Downloading: 90% (wrapping up)")
718
+ let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum)
719
+ self.saveBundleInfo(id: id, bundle: info)
720
+ self.cleanDownloadData()
721
+ self.notifyDownload(id: id, percent: 100)
722
+ logger.info("Downloading: 100% (complete)")
723
+ return info
724
+ }
725
+ private func ensureResumableFilesExist() {
726
+ let fileManager = FileManager.default
727
+ if !fileManager.fileExists(atPath: tempDataPath.path) {
728
+ if !fileManager.createFile(atPath: tempDataPath.path, contents: Data()) {
729
+ logger.error("Cannot ensure that a file at \(tempDataPath.path) exists")
730
+ }
731
+ }
732
+
733
+ if !fileManager.fileExists(atPath: updateInfo.path) {
734
+ if !fileManager.createFile(atPath: updateInfo.path, contents: Data()) {
735
+ logger.error("Cannot ensure that a file at \(updateInfo.path) exists")
736
+ }
737
+ }
738
+ }
739
+
740
+ private func cleanDownloadData() {
741
+ // Deleting package.tmp
742
+ let fileManager = FileManager.default
743
+ if fileManager.fileExists(atPath: tempDataPath.path) {
744
+ do {
745
+ try fileManager.removeItem(at: tempDataPath)
746
+ } catch {
747
+ logger.error("Could not delete file at \(tempDataPath): \(error)")
748
+ }
749
+ }
750
+ // Deleting update.dat
751
+ if fileManager.fileExists(atPath: updateInfo.path) {
752
+ do {
753
+ try fileManager.removeItem(at: updateInfo)
754
+ } catch {
755
+ logger.error("Could not delete file at \(updateInfo): \(error)")
756
+ }
757
+ }
758
+ }
759
+
760
+ private func savePartialData(startingAt byteOffset: UInt64) {
761
+ let fileManager = FileManager.default
762
+ do {
763
+ // Check if package.tmp exist
764
+ if !fileManager.fileExists(atPath: tempDataPath.path) {
765
+ try self.tempData.write(to: tempDataPath, options: .atomicWrite)
766
+ } else {
767
+ // If yes, it start writing on it
768
+ let fileHandle = try FileHandle(forWritingTo: tempDataPath)
769
+ fileHandle.seek(toFileOffset: byteOffset) // Moving at the specified position to start writing
770
+ fileHandle.write(self.tempData)
771
+ fileHandle.closeFile()
772
+ }
773
+ } catch {
774
+ logger.error("Failed to write data starting at byte \(byteOffset): \(error)")
775
+ }
776
+ self.tempData.removeAll() // Clearing tempData to avoid writing the same data multiple times
777
+ }
778
+
779
+ private func saveDownloadInfo(_ version: String) {
780
+ do {
781
+ try "\(version)".write(to: updateInfo, atomically: true, encoding: .utf8)
782
+ } catch {
783
+ logger.error("Failed to save progress: \(error)")
784
+ }
785
+ }
786
+ private func getLocalUpdateVersion() -> String { // Return the version that was tried to be downloaded on last download attempt
787
+ if !FileManager.default.fileExists(atPath: updateInfo.path) {
788
+ return "nil"
789
+ }
790
+ guard let versionString = try? String(contentsOf: updateInfo),
791
+ let version = Optional(versionString) else {
792
+ return "nil"
793
+ }
794
+ return version
795
+ }
796
+ private func loadDownloadProgress() -> Int64 {
797
+
798
+ let fileManager = FileManager.default
799
+ do {
800
+ let attributes = try fileManager.attributesOfItem(atPath: tempDataPath.path)
801
+ if let fileSize = attributes[.size] as? NSNumber {
802
+ return fileSize.int64Value
803
+ }
804
+ } catch {
805
+ logger.error("Could not retrieve already downloaded data size : \(error)")
806
+ }
807
+ return 0
808
+ }
809
+
810
+ public func list(raw: Bool = false) -> [BundleInfo] {
811
+ if !raw {
812
+ // UserDefaults.standard.dictionaryRepresentation().values
813
+ let dest: URL = libraryDir.appendingPathComponent(bundleDirectory)
814
+ do {
815
+ let files: [String] = try FileManager.default.contentsOfDirectory(atPath: dest.path)
816
+ var res: [BundleInfo] = []
817
+ logger.info("list File : \(dest.path)")
818
+ if dest.exist {
819
+ for id: String in files {
820
+ res.append(self.getBundleInfo(id: id))
821
+ }
822
+ }
823
+ return res
824
+ } catch {
825
+ logger.info("No version available \(dest.path)")
826
+ return []
827
+ }
828
+ } else {
829
+ guard let regex = try? NSRegularExpression(pattern: "^[0-9A-Za-z]{10}_info$") else {
830
+ logger.error("Invalid regex ?????")
831
+ return []
832
+ }
833
+ return UserDefaults.standard.dictionaryRepresentation().keys.filter {
834
+ let range = NSRange($0.startIndex..., in: $0)
835
+ let matches = regex.matches(in: $0, range: range)
836
+ return !matches.isEmpty
837
+ }.map {
838
+ $0.components(separatedBy: "_")[0]
839
+ }.map {
840
+ self.getBundleInfo(id: $0)
841
+ }
842
+ }
843
+
844
+ }
845
+
846
+ public func delete(id: String, removeInfo: Bool) -> Bool {
847
+ let deleted: BundleInfo = self.getBundleInfo(id: id)
848
+ if deleted.isBuiltin() || self.getCurrentBundleId() == id {
849
+ logger.info("Cannot delete \(id)")
850
+ return false
851
+ }
852
+
853
+ // Check if this is the next bundle and prevent deletion if it is
854
+ if let next = self.getNextBundle(),
855
+ !next.isDeleted() &&
856
+ !next.isErrorStatus() &&
857
+ next.getId() == id {
858
+ logger.info("Cannot delete the next bundle \(id)")
859
+ return false
860
+ }
861
+
862
+ let destPersist: URL = libraryDir.appendingPathComponent(bundleDirectory).appendingPathComponent(id)
863
+ do {
864
+ try FileManager.default.removeItem(atPath: destPersist.path)
865
+ } catch {
866
+ logger.error("Folder \(destPersist.path), not removed.")
867
+ // even if, we don;t care. Android doesn't care
868
+ if removeInfo {
869
+ self.removeBundleInfo(id: id)
870
+ }
871
+ self.sendStats(action: "delete", versionName: deleted.getVersionName())
872
+ return false
873
+ }
874
+ if removeInfo {
875
+ self.removeBundleInfo(id: id)
876
+ } else {
877
+ self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.localizedString))
878
+ }
879
+ logger.info("bundle delete \(deleted.getVersionName())")
880
+ self.sendStats(action: "delete", versionName: deleted.getVersionName())
881
+ return true
882
+ }
883
+
884
+ public func delete(id: String) -> Bool {
885
+ return self.delete(id: id, removeInfo: true)
886
+ }
887
+
888
+ public func getBundleDirectory(id: String) -> URL {
889
+ return libraryDir.appendingPathComponent(self.bundleDirectory).appendingPathComponent(id)
890
+ }
891
+
892
+ public func set(bundle: BundleInfo) -> Bool {
893
+ return self.set(id: bundle.getId())
894
+ }
895
+
896
+ private func bundleExists(id: String) -> Bool {
897
+ let destPersist: URL = self.getBundleDirectory(id: id)
898
+ let indexPersist: URL = destPersist.appendingPathComponent("index.html")
899
+ let bundleIndo: BundleInfo = self.getBundleInfo(id: id)
900
+ if
901
+ destPersist.exist &&
902
+ destPersist.isDirectory &&
903
+ !indexPersist.isDirectory &&
904
+ indexPersist.exist &&
905
+ !bundleIndo.isDeleted() {
906
+ return true
907
+ }
908
+ return false
909
+ }
910
+
911
+ public func set(id: String) -> Bool {
912
+ let newBundle: BundleInfo = self.getBundleInfo(id: id)
913
+ if newBundle.isBuiltin() {
914
+ self.reset()
915
+ return true
916
+ }
917
+ if bundleExists(id: id) {
918
+ let currentBundleName = self.getCurrentBundle().getVersionName()
919
+ self.setCurrentBundle(bundle: self.getBundleDirectory(id: id).path)
920
+ self.setBundleStatus(id: id, status: BundleStatus.PENDING)
921
+ self.sendStats(action: "set", versionName: newBundle.getVersionName(), oldVersionName: currentBundleName)
922
+ return true
923
+ }
924
+ self.setBundleStatus(id: id, status: BundleStatus.ERROR)
925
+ self.sendStats(action: "set_fail", versionName: newBundle.getVersionName())
926
+ return false
927
+ }
928
+
929
+ public func autoReset() {
930
+ let currentBundle: BundleInfo = self.getCurrentBundle()
931
+ if !currentBundle.isBuiltin() && !self.bundleExists(id: currentBundle.getId()) {
932
+ logger.info("Folder at bundle path does not exist. Triggering reset.")
933
+ self.reset()
934
+ }
935
+ }
936
+
937
+ public func reset() {
938
+ self.reset(isInternal: false)
939
+ }
940
+
941
+ public func reset(isInternal: Bool) {
942
+ logger.info("reset: \(isInternal)")
943
+ let currentBundleName = self.getCurrentBundle().getVersionName()
944
+ self.setCurrentBundle(bundle: "")
945
+ self.setFallbackBundle(fallback: Optional<BundleInfo>.none)
946
+ _ = self.setNextBundle(next: Optional<String>.none)
947
+ if !isInternal {
948
+ self.sendStats(action: "reset", versionName: self.getCurrentBundle().getVersionName(), oldVersionName: currentBundleName)
949
+ }
950
+ }
951
+
952
+ public func setSuccess(bundle: BundleInfo, autoDeletePrevious: Bool) {
953
+ self.setBundleStatus(id: bundle.getId(), status: BundleStatus.SUCCESS)
954
+ let fallback: BundleInfo = self.getFallbackBundle()
955
+ logger.info("Fallback bundle is: \(fallback.toString())")
956
+ logger.info("Version successfully loaded: \(bundle.toString())")
957
+ if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() {
958
+ let res = self.delete(id: fallback.getId())
959
+ if res {
960
+ logger.info("Deleted previous bundle: \(fallback.toString())")
961
+ } else {
962
+ logger.error("Failed to delete previous bundle: \(fallback.toString())")
963
+ }
964
+ }
965
+ self.setFallbackBundle(fallback: bundle)
966
+ }
967
+
968
+ public func setError(bundle: BundleInfo) {
969
+ self.setBundleStatus(id: bundle.getId(), status: BundleStatus.ERROR)
970
+ }
971
+
972
+ func unsetChannel() -> SetChannel {
973
+ let setChannel: SetChannel = SetChannel()
974
+ if (self.channelUrl ).isEmpty {
975
+ logger.error("Channel URL is not set")
976
+ setChannel.message = "Channel URL is not set"
977
+ setChannel.error = "missing_config"
978
+ return setChannel
979
+ }
980
+ let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
981
+ let parameters: InfoObject = self.createInfoObject()
982
+
983
+ let request = alamofireSession.request(self.channelUrl, method: .delete, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
984
+
985
+ request.validate().responseDecodable(of: SetChannelDec.self) { response in
986
+ switch response.result {
987
+ case .success:
988
+ if let responseValue = response.value {
989
+ if let error = responseValue.error {
990
+ setChannel.error = error
991
+ } else {
992
+ setChannel.status = responseValue.status ?? ""
993
+ setChannel.message = responseValue.message ?? ""
994
+ }
995
+ }
996
+ case let .failure(error):
997
+ self.logger.error("Error unset Channel \(error)")
998
+ setChannel.error = "Request failed: \(error.localizedDescription)"
999
+ }
1000
+ semaphore.signal()
1001
+ }
1002
+ semaphore.wait()
1003
+ return setChannel
1004
+ }
1005
+
1006
+ func setChannel(channel: String) -> SetChannel {
1007
+ let setChannel: SetChannel = SetChannel()
1008
+ if (self.channelUrl ).isEmpty {
1009
+ logger.error("Channel URL is not set")
1010
+ setChannel.message = "Channel URL is not set"
1011
+ setChannel.error = "missing_config"
1012
+ return setChannel
1013
+ }
1014
+ let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
1015
+ var parameters: InfoObject = self.createInfoObject()
1016
+ parameters.channel = channel
1017
+
1018
+ let request = alamofireSession.request(self.channelUrl, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1019
+
1020
+ request.validate().responseDecodable(of: SetChannelDec.self) { response in
1021
+ switch response.result {
1022
+ case .success:
1023
+ if let responseValue = response.value {
1024
+ if let error = responseValue.error {
1025
+ setChannel.error = error
1026
+ } else {
1027
+ setChannel.status = responseValue.status ?? ""
1028
+ setChannel.message = responseValue.message ?? ""
1029
+ }
1030
+ }
1031
+ case let .failure(error):
1032
+ self.logger.error("Error set Channel \(error)")
1033
+ setChannel.error = "Request failed: \(error.localizedDescription)"
1034
+ }
1035
+ semaphore.signal()
1036
+ }
1037
+ semaphore.wait()
1038
+ return setChannel
1039
+ }
1040
+
1041
+ func getChannel() -> GetChannel {
1042
+ let getChannel: GetChannel = GetChannel()
1043
+ if (self.channelUrl ).isEmpty {
1044
+ logger.error("Channel URL is not set")
1045
+ getChannel.message = "Channel URL is not set"
1046
+ getChannel.error = "missing_config"
1047
+ return getChannel
1048
+ }
1049
+ let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
1050
+ let parameters: InfoObject = self.createInfoObject()
1051
+ let request = alamofireSession.request(self.channelUrl, method: .put, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
1052
+
1053
+ request.validate().responseDecodable(of: GetChannelDec.self) { response in
1054
+ defer {
1055
+ semaphore.signal()
1056
+ }
1057
+ switch response.result {
1058
+ case .success:
1059
+ if let responseValue = response.value {
1060
+ if let error = responseValue.error {
1061
+ getChannel.error = error
1062
+ } else {
1063
+ getChannel.status = responseValue.status ?? ""
1064
+ getChannel.message = responseValue.message ?? ""
1065
+ getChannel.channel = responseValue.channel ?? ""
1066
+ getChannel.allowSet = responseValue.allowSet ?? true
1067
+ }
1068
+ }
1069
+ case let .failure(error):
1070
+ if let data = response.data, let bodyString = String(data: data, encoding: .utf8) {
1071
+ if bodyString.contains("channel_not_found") && response.response?.statusCode == 400 && !self.defaultChannel.isEmpty {
1072
+ getChannel.channel = self.defaultChannel
1073
+ getChannel.status = "default"
1074
+ return
1075
+ }
1076
+ }
1077
+
1078
+ self.logger.error("Error get Channel \(error)")
1079
+ getChannel.error = "Request failed: \(error.localizedDescription)"
1080
+ }
1081
+ }
1082
+ semaphore.wait()
1083
+ return getChannel
1084
+ }
1085
+
1086
+ func listChannels() -> ListChannels {
1087
+ let listChannels: ListChannels = ListChannels()
1088
+ if (self.channelUrl).isEmpty {
1089
+ logger.error("Channel URL is not set")
1090
+ listChannels.error = "Channel URL is not set"
1091
+ return listChannels
1092
+ }
1093
+
1094
+ let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
1095
+
1096
+ // Auto-detect values
1097
+ let appId = self.appId
1098
+ let platform = "ios"
1099
+ let isEmulator = self.isEmulator()
1100
+ let isProd = self.isProd()
1101
+
1102
+ // Create query parameters
1103
+ var urlComponents = URLComponents(string: self.channelUrl)
1104
+ urlComponents?.queryItems = [
1105
+ URLQueryItem(name: "app_id", value: appId),
1106
+ URLQueryItem(name: "platform", value: platform),
1107
+ URLQueryItem(name: "is_emulator", value: String(isEmulator)),
1108
+ URLQueryItem(name: "is_prod", value: String(isProd))
1109
+ ]
1110
+
1111
+ guard let url = urlComponents?.url else {
1112
+ logger.error("Invalid channel URL")
1113
+ listChannels.error = "Invalid channel URL"
1114
+ return listChannels
1115
+ }
1116
+
1117
+ let request = alamofireSession.request(url, method: .get, requestModifier: { $0.timeoutInterval = self.timeout })
1118
+
1119
+ request.validate().responseDecodable(of: ListChannelsDec.self) { response in
1120
+ defer {
1121
+ semaphore.signal()
1122
+ }
1123
+ switch response.result {
1124
+ case .success:
1125
+ if let responseValue = response.value {
1126
+ // Check for server-side errors
1127
+ if let error = responseValue.error {
1128
+ listChannels.error = error
1129
+ return
1130
+ }
1131
+
1132
+ // Backend returns direct array, so channels should be populated by our custom decoder
1133
+ if let channels = responseValue.channels {
1134
+ listChannels.channels = channels.map { channel in
1135
+ var channelDict: [String: Any] = [:]
1136
+ channelDict["id"] = channel.id ?? ""
1137
+ channelDict["name"] = channel.name ?? ""
1138
+ channelDict["public"] = channel.public ?? false
1139
+ channelDict["allow_self_set"] = channel.allow_self_set ?? false
1140
+ return channelDict
1141
+ }
1142
+ }
1143
+ }
1144
+ case let .failure(error):
1145
+ self.logger.error("Error list channels \(error)")
1146
+ listChannels.error = "Request failed: \(error.localizedDescription)"
1147
+ }
1148
+ }
1149
+ semaphore.wait()
1150
+ return listChannels
1151
+ }
1152
+
1153
+ private let operationQueue = OperationQueue()
1154
+
1155
+ func sendStats(action: String, versionName: String? = nil, oldVersionName: String? = "") {
1156
+ guard !statsUrl.isEmpty else {
1157
+ return
1158
+ }
1159
+ operationQueue.maxConcurrentOperationCount = 1
1160
+
1161
+ let versionName = versionName ?? getCurrentBundle().getVersionName()
1162
+
1163
+ var parameters = createInfoObject()
1164
+ parameters.action = action
1165
+ parameters.version_name = versionName
1166
+ parameters.old_version_name = oldVersionName ?? ""
1167
+
1168
+ let operation = BlockOperation {
1169
+ let semaphore = DispatchSemaphore(value: 0)
1170
+ self.alamofireSession.request(
1171
+ self.statsUrl,
1172
+ method: .post,
1173
+ parameters: parameters,
1174
+ encoder: JSONParameterEncoder.default,
1175
+ requestModifier: { $0.timeoutInterval = self.timeout }
1176
+ ).responseData { response in
1177
+ switch response.result {
1178
+ case .success:
1179
+ self.logger.info("Stats sent for \(action), version \(versionName)")
1180
+ case let .failure(error):
1181
+ self.logger.error("Error sending stats: \(response.value?.debugDescription ?? "") \(error.localizedDescription)")
1182
+ }
1183
+ semaphore.signal()
1184
+ }
1185
+ semaphore.signal()
1186
+ }
1187
+ operationQueue.addOperation(operation)
1188
+
1189
+ }
1190
+
1191
+ public func getBundleInfo(id: String?) -> BundleInfo {
1192
+ var trueId = BundleInfo.VERSION_UNKNOWN
1193
+ if id != nil {
1194
+ trueId = id!
1195
+ }
1196
+ let result: BundleInfo
1197
+ if BundleInfo.ID_BUILTIN == trueId {
1198
+ result = BundleInfo(id: trueId, version: "", status: BundleStatus.SUCCESS, checksum: "")
1199
+ } else if BundleInfo.VERSION_UNKNOWN == trueId {
1200
+ result = BundleInfo(id: trueId, version: "", status: BundleStatus.ERROR, checksum: "")
1201
+ } else {
1202
+ do {
1203
+ result = try UserDefaults.standard.getObj(forKey: "\(trueId)\(self.INFO_SUFFIX)", castTo: BundleInfo.self)
1204
+ } catch {
1205
+ logger.error("Failed to parse info for bundle [\(trueId)] \(error.localizedDescription)")
1206
+ result = BundleInfo(id: trueId, version: "", status: BundleStatus.PENDING, checksum: "")
1207
+ }
1208
+ }
1209
+ return result
1210
+ }
1211
+
1212
+ public func getBundleInfoByVersionName(version: String) -> BundleInfo? {
1213
+ let installed: [BundleInfo] = self.list()
1214
+ for i in installed {
1215
+ if i.getVersionName() == version {
1216
+ return i
1217
+ }
1218
+ }
1219
+ return nil
1220
+ }
1221
+
1222
+ private func removeBundleInfo(id: String) {
1223
+ self.saveBundleInfo(id: id, bundle: nil)
1224
+ }
1225
+
1226
+ public func saveBundleInfo(id: String, bundle: BundleInfo?) {
1227
+ if bundle != nil && (bundle!.isBuiltin() || bundle!.isUnknown()) {
1228
+ logger.info("Not saving info for bundle [\(id)] \(bundle?.toString() ?? "")")
1229
+ return
1230
+ }
1231
+ if bundle == nil {
1232
+ logger.info("Removing info for bundle [\(id)]")
1233
+ UserDefaults.standard.removeObject(forKey: "\(id)\(self.INFO_SUFFIX)")
1234
+ } else {
1235
+ let update = bundle!.setId(id: id)
1236
+ logger.info("Storing info for bundle [\(id)] \(update.toString())")
1237
+ do {
1238
+ try UserDefaults.standard.setObj(update, forKey: "\(id)\(self.INFO_SUFFIX)")
1239
+ } catch {
1240
+ logger.error("Failed to save info for bundle [\(id)] \(error.localizedDescription)")
1241
+ }
1242
+ }
1243
+ UserDefaults.standard.synchronize()
1244
+ }
1245
+
1246
+ private func setBundleStatus(id: String, status: BundleStatus) {
1247
+ logger.info("Setting status for bundle [\(id)] to \(status)")
1248
+ let info = self.getBundleInfo(id: id)
1249
+ self.saveBundleInfo(id: id, bundle: info.setStatus(status: status.localizedString))
1250
+ }
1251
+
1252
+ public func getCurrentBundle() -> BundleInfo {
1253
+ return self.getBundleInfo(id: self.getCurrentBundleId())
1254
+ }
1255
+
1256
+ public func getCurrentBundleId() -> String {
1257
+ guard let bundlePath: String = UserDefaults.standard.string(forKey: self.CAP_SERVER_PATH) else {
1258
+ return BundleInfo.ID_BUILTIN
1259
+ }
1260
+ if (bundlePath).isEmpty {
1261
+ return BundleInfo.ID_BUILTIN
1262
+ }
1263
+ let bundleID: String = bundlePath.components(separatedBy: "/").last ?? bundlePath
1264
+ return bundleID
1265
+ }
1266
+
1267
+ public func isUsingBuiltin() -> Bool {
1268
+ return (UserDefaults.standard.string(forKey: self.CAP_SERVER_PATH) ?? "") == self.DEFAULT_FOLDER
1269
+ }
1270
+
1271
+ public func getFallbackBundle() -> BundleInfo {
1272
+ let id: String = UserDefaults.standard.string(forKey: self.FALLBACK_VERSION) ?? BundleInfo.ID_BUILTIN
1273
+ return self.getBundleInfo(id: id)
1274
+ }
1275
+
1276
+ private func setFallbackBundle(fallback: BundleInfo?) {
1277
+ UserDefaults.standard.set(fallback == nil ? BundleInfo.ID_BUILTIN : fallback!.getId(), forKey: self.FALLBACK_VERSION)
1278
+ UserDefaults.standard.synchronize()
1279
+ }
1280
+
1281
+ public func getNextBundle() -> BundleInfo? {
1282
+ let id: String? = UserDefaults.standard.string(forKey: self.NEXT_VERSION)
1283
+ return self.getBundleInfo(id: id)
1284
+ }
1285
+
1286
+ public func setNextBundle(next: String?) -> Bool {
1287
+ guard let nextId: String = next else {
1288
+ UserDefaults.standard.removeObject(forKey: self.NEXT_VERSION)
1289
+ UserDefaults.standard.synchronize()
1290
+ return false
1291
+ }
1292
+ let newBundle: BundleInfo = self.getBundleInfo(id: nextId)
1293
+ if !newBundle.isBuiltin() && !self.bundleExists(id: nextId) {
1294
+ return false
1295
+ }
1296
+ UserDefaults.standard.set(nextId, forKey: self.NEXT_VERSION)
1297
+ UserDefaults.standard.synchronize()
1298
+ self.setBundleStatus(id: nextId, status: BundleStatus.PENDING)
1299
+ return true
1300
+ }
1301
+ }