@capgo/capacitor-updater 5.40.5 → 5.42.3
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.
- package/Package.swift +3 -3
- package/README.md +27 -0
- package/android/build.gradle +4 -2
- package/android/src/main/java/ee/forgr/capacitor_updater/AppLifecycleObserver.java +88 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +82 -16
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +181 -11
- package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +19 -5
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +21 -13
- package/android/src/main/java/ee/forgr/capacitor_updater/Logger.java +36 -14
- package/dist/docs.json +16 -0
- package/dist/esm/definitions.d.ts +14 -1
- package/dist/esm/definitions.js +0 -6
- package/dist/esm/definitions.js.map +1 -1
- package/dist/plugin.cjs.js +0 -6
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +0 -5
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +42 -2
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +446 -163
- package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +15 -5
- package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +70 -0
- package/package.json +1 -1
|
@@ -48,6 +48,12 @@ import UIKit
|
|
|
48
48
|
// Flag to track if we've already sent the rate limit statistic - prevents infinite loop
|
|
49
49
|
private static var rateLimitStatisticSent = false
|
|
50
50
|
|
|
51
|
+
// Stats batching - queue events and send max once per second
|
|
52
|
+
private var statsQueue: [StatsEvent] = []
|
|
53
|
+
private let statsQueueLock = NSLock()
|
|
54
|
+
private var statsFlushTimer: Timer?
|
|
55
|
+
private static let statsFlushInterval: TimeInterval = 1.0
|
|
56
|
+
|
|
51
57
|
private var userAgent: String {
|
|
52
58
|
let safePluginVersion = pluginVersion.isEmpty ? "unknown" : pluginVersion
|
|
53
59
|
let safeAppId = appId.isEmpty ? "unknown" : appId
|
|
@@ -70,6 +76,15 @@ import UIKit
|
|
|
70
76
|
self.logger = logger
|
|
71
77
|
}
|
|
72
78
|
|
|
79
|
+
deinit {
|
|
80
|
+
// Invalidate the stats timer to prevent memory leaks
|
|
81
|
+
statsFlushTimer?.invalidate()
|
|
82
|
+
statsFlushTimer = nil
|
|
83
|
+
|
|
84
|
+
// Flush any remaining stats before deallocation
|
|
85
|
+
flushStatsQueue()
|
|
86
|
+
}
|
|
87
|
+
|
|
73
88
|
private func calcTotalPercent(percent: Int, min: Int, max: Int) -> Int {
|
|
74
89
|
return (percent * (max - min)) / 100 + min
|
|
75
90
|
}
|
|
@@ -80,12 +95,20 @@ import UIKit
|
|
|
80
95
|
}
|
|
81
96
|
|
|
82
97
|
public func setPublicKey(_ publicKey: String) {
|
|
83
|
-
|
|
84
|
-
if
|
|
85
|
-
self.
|
|
86
|
-
} else {
|
|
98
|
+
// Empty string means no encryption - proceed normally
|
|
99
|
+
if publicKey.isEmpty {
|
|
100
|
+
self.publicKey = ""
|
|
87
101
|
self.cachedKeyId = nil
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Non-empty: must be a valid RSA key or crash
|
|
106
|
+
guard RSAPublicKey.load(rsaPublicKey: publicKey) != nil else {
|
|
107
|
+
fatalError("Invalid public key in capacitor.config.json: failed to parse RSA key. Remove the key or provide a valid PEM-formatted RSA public key.")
|
|
88
108
|
}
|
|
109
|
+
|
|
110
|
+
self.publicKey = publicKey
|
|
111
|
+
self.cachedKeyId = CryptoCipher.calcKeyId(publicKey: publicKey)
|
|
89
112
|
}
|
|
90
113
|
|
|
91
114
|
public func getKeyId() -> String? {
|
|
@@ -104,6 +127,38 @@ import UIKit
|
|
|
104
127
|
return !self.isDevEnvironment && !self.isAppStoreReceiptSandbox() && !self.hasEmbeddedMobileProvision()
|
|
105
128
|
}
|
|
106
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Checks if there is sufficient disk space for a download.
|
|
132
|
+
* Matches Android behavior: 2x safety margin, throws "insufficient_disk_space"
|
|
133
|
+
* - Parameter estimatedSize: The estimated size of the download in bytes. Defaults to 50MB.
|
|
134
|
+
*/
|
|
135
|
+
private func checkDiskSpace(estimatedSize: Int64 = 50 * 1024 * 1024) throws {
|
|
136
|
+
let fileManager = FileManager.default
|
|
137
|
+
guard let documentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
do {
|
|
142
|
+
let attributes = try fileManager.attributesOfFileSystem(forPath: documentDirectory.path)
|
|
143
|
+
guard let freeSpace = attributes[.systemFreeSize] as? Int64 else {
|
|
144
|
+
logger.warn("Could not determine free disk space, proceeding with download")
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let requiredSpace = estimatedSize * 2 // 2x safety margin like Android
|
|
149
|
+
|
|
150
|
+
if freeSpace < requiredSpace {
|
|
151
|
+
logger.error("Insufficient disk space. Available: \(freeSpace), Required: \(requiredSpace)")
|
|
152
|
+
self.sendStats(action: "insufficient_disk_space")
|
|
153
|
+
throw CustomError.insufficientDiskSpace
|
|
154
|
+
}
|
|
155
|
+
} catch let error as CustomError {
|
|
156
|
+
throw error
|
|
157
|
+
} catch {
|
|
158
|
+
logger.warn("Error checking disk space: \(error.localizedDescription)")
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
107
162
|
/**
|
|
108
163
|
* Check if a 429 (Too Many Requests) response was received and set the flag
|
|
109
164
|
*/
|
|
@@ -328,6 +383,56 @@ import UIKit
|
|
|
328
383
|
}
|
|
329
384
|
}
|
|
330
385
|
|
|
386
|
+
private func populateDeltaCacheAsync(for id: String) {
|
|
387
|
+
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
388
|
+
self?.populateDeltaCache(for: id)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private func populateDeltaCache(for id: String) {
|
|
393
|
+
let bundleDir = self.getBundleDirectory(id: id)
|
|
394
|
+
let fileManager = FileManager.default
|
|
395
|
+
|
|
396
|
+
guard fileManager.fileExists(atPath: bundleDir.path) else {
|
|
397
|
+
logger.debug("Skip delta cache population: bundle dir missing")
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
do {
|
|
402
|
+
try fileManager.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
|
|
403
|
+
} catch {
|
|
404
|
+
logger.debug("Skip delta cache population: failed to create cache dir")
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
guard let enumerator = fileManager.enumerator(at: bundleDir, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) else {
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
for case let fileURL as URL in enumerator {
|
|
413
|
+
let resourceValues = try? fileURL.resourceValues(forKeys: [.isDirectoryKey])
|
|
414
|
+
if resourceValues?.isDirectory == true {
|
|
415
|
+
continue
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
let checksum = CryptoCipher.calcChecksum(filePath: fileURL)
|
|
419
|
+
if checksum.isEmpty {
|
|
420
|
+
continue
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
let cacheFile = cacheFolder.appendingPathComponent("\(checksum)_\(fileURL.lastPathComponent)")
|
|
424
|
+
if fileManager.fileExists(atPath: cacheFile.path) {
|
|
425
|
+
continue
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
do {
|
|
429
|
+
try fileManager.copyItem(at: fileURL, to: cacheFile)
|
|
430
|
+
} catch {
|
|
431
|
+
logger.debug("Delta cache copy failed: \(fileURL.path)")
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
331
436
|
private func createInfoObject() -> InfoObject {
|
|
332
437
|
return InfoObject(
|
|
333
438
|
platform: "ios",
|
|
@@ -417,13 +522,15 @@ import UIKit
|
|
|
417
522
|
logger.info("Current bundle set to: \((bundle ).isEmpty ? BundleInfo.ID_BUILTIN : bundle)")
|
|
418
523
|
}
|
|
419
524
|
|
|
420
|
-
|
|
421
|
-
|
|
525
|
+
// Per-download temp file paths to prevent collisions when multiple downloads run concurrently
|
|
526
|
+
private func tempDataPath(for id: String) -> URL {
|
|
527
|
+
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("package_\(id).tmp")
|
|
422
528
|
}
|
|
423
529
|
|
|
424
|
-
private
|
|
425
|
-
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("
|
|
530
|
+
private func updateInfoPath(for id: String) -> URL {
|
|
531
|
+
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("update_\(id).dat")
|
|
426
532
|
}
|
|
533
|
+
|
|
427
534
|
private var tempData = Data()
|
|
428
535
|
|
|
429
536
|
private func verifyChecksum(file: URL, expectedHash: String) -> Bool {
|
|
@@ -437,6 +544,10 @@ import UIKit
|
|
|
437
544
|
let destFolder = self.getBundleDirectory(id: id)
|
|
438
545
|
let builtinFolder = Bundle.main.bundleURL.appendingPathComponent("public")
|
|
439
546
|
|
|
547
|
+
// Check disk space before starting manifest download (estimate 100KB per file, minimum 50MB)
|
|
548
|
+
let estimatedSize = Int64(max(manifest.count * 100 * 1024, 50 * 1024 * 1024))
|
|
549
|
+
try checkDiskSpace(estimatedSize: estimatedSize)
|
|
550
|
+
|
|
440
551
|
try FileManager.default.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
|
|
441
552
|
try FileManager.default.createDirectory(at: destFolder, withIntermediateDirectories: true, attributes: nil)
|
|
442
553
|
|
|
@@ -450,11 +561,19 @@ import UIKit
|
|
|
450
561
|
// Notify the start of the download process
|
|
451
562
|
self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
|
|
452
563
|
|
|
453
|
-
let
|
|
564
|
+
let totalFiles = manifest.count
|
|
565
|
+
|
|
566
|
+
// Configure concurrent operation count similar to Android: min(64, max(32, totalFiles))
|
|
567
|
+
manifestDownloadQueue.maxConcurrentOperationCount = min(64, max(32, totalFiles))
|
|
568
|
+
|
|
569
|
+
// Thread-safe counters for concurrent operations
|
|
570
|
+
let completedFiles = AtomicCounter()
|
|
571
|
+
let hasError = AtomicBool(initialValue: false)
|
|
454
572
|
var downloadError: Error?
|
|
573
|
+
let errorLock = NSLock()
|
|
455
574
|
|
|
456
|
-
|
|
457
|
-
var
|
|
575
|
+
// Create operations for each file
|
|
576
|
+
var operations: [Operation] = []
|
|
458
577
|
|
|
459
578
|
for entry in manifest {
|
|
460
579
|
guard let fileName = entry.file_name,
|
|
@@ -463,134 +582,90 @@ import UIKit
|
|
|
463
582
|
continue
|
|
464
583
|
}
|
|
465
584
|
|
|
585
|
+
// Decrypt checksum if needed (done before creating operation)
|
|
466
586
|
if !self.publicKey.isEmpty && !sessionKey.isEmpty {
|
|
467
587
|
do {
|
|
468
588
|
fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
|
|
469
589
|
} catch {
|
|
590
|
+
errorLock.lock()
|
|
470
591
|
downloadError = error
|
|
592
|
+
errorLock.unlock()
|
|
593
|
+
hasError.value = true
|
|
471
594
|
logger.error("Checksum decryption failed")
|
|
472
595
|
logger.debug("Bundle: \(id), File: \(fileName), Error: \(error)")
|
|
596
|
+
continue
|
|
473
597
|
}
|
|
474
598
|
}
|
|
475
599
|
|
|
476
|
-
|
|
600
|
+
let finalFileHash = fileHash
|
|
477
601
|
let fileNameWithoutPath = (fileName as NSString).lastPathComponent
|
|
478
|
-
let cacheFileName = "\(fileHash)_\(fileNameWithoutPath)"
|
|
479
|
-
let cacheFilePath = cacheFolder.appendingPathComponent(cacheFileName)
|
|
480
|
-
|
|
481
|
-
// Check if file is Brotli compressed and remove .br extension from destination
|
|
482
602
|
let isBrotli = fileName.hasSuffix(".br")
|
|
483
|
-
let
|
|
603
|
+
let cacheBaseName = isBrotli ? String(fileNameWithoutPath.dropLast(3)) : fileNameWithoutPath
|
|
604
|
+
let cacheFilePath = cacheFolder.appendingPathComponent("\(finalFileHash)_\(cacheBaseName)")
|
|
605
|
+
let legacyCacheFilePath: URL? = isBrotli ? cacheFolder.appendingPathComponent("\(finalFileHash)_\(fileNameWithoutPath)") : nil
|
|
484
606
|
|
|
607
|
+
let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
|
|
485
608
|
let destFilePath = destFolder.appendingPathComponent(destFileName)
|
|
486
609
|
let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
|
|
487
610
|
|
|
488
|
-
// Create
|
|
489
|
-
try FileManager.default.createDirectory(at: destFilePath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
|
|
611
|
+
// Create parent directories synchronously (before operations start)
|
|
612
|
+
try? FileManager.default.createDirectory(at: destFilePath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
|
|
490
613
|
|
|
491
|
-
|
|
614
|
+
let operation = BlockOperation { [weak self] in
|
|
615
|
+
guard let self = self else { return }
|
|
616
|
+
guard !hasError.value else { return } // Skip if error already occurred
|
|
492
617
|
|
|
493
|
-
if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
|
|
494
618
|
do {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
619
|
+
// Try builtin first
|
|
620
|
+
if FileManager.default.fileExists(atPath: builtinFilePath.path) && self.verifyChecksum(file: builtinFilePath, expectedHash: finalFileHash) {
|
|
621
|
+
try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
|
|
622
|
+
self.logger.info("downloadManifest \(fileName) using builtin file \(id)")
|
|
623
|
+
}
|
|
624
|
+
// Try cache
|
|
625
|
+
else if
|
|
626
|
+
self.tryCopyFromCache(from: cacheFilePath, to: destFilePath, expectedHash: finalFileHash) ||
|
|
627
|
+
(legacyCacheFilePath != nil && self.tryCopyFromCache(from: legacyCacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) {
|
|
628
|
+
self.logger.info("downloadManifest \(fileName) copy from cache \(id)")
|
|
629
|
+
}
|
|
630
|
+
// Download
|
|
631
|
+
else {
|
|
632
|
+
try self.downloadManifestFile(
|
|
633
|
+
downloadUrl: downloadUrl,
|
|
634
|
+
destFilePath: destFilePath,
|
|
635
|
+
cacheFilePath: cacheFilePath,
|
|
636
|
+
fileHash: finalFileHash,
|
|
637
|
+
fileName: fileName,
|
|
638
|
+
destFileName: destFileName,
|
|
639
|
+
isBrotli: isBrotli,
|
|
640
|
+
sessionKey: sessionKey,
|
|
641
|
+
version: version,
|
|
642
|
+
bundleId: id
|
|
643
|
+
)
|
|
644
|
+
}
|
|
515
645
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
throw NSError(domain: "StatusCodeError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid. Data: \(stringData) for file \(fileName) at url \(downloadUrl)"])
|
|
524
|
-
} else {
|
|
525
|
-
throw NSError(domain: "StatusCodeError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// Add decryption step if public key is set and sessionKey is provided
|
|
530
|
-
var finalData = data
|
|
531
|
-
if !self.publicKey.isEmpty && !sessionKey.isEmpty {
|
|
532
|
-
let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
|
|
533
|
-
try finalData.write(to: tempFile)
|
|
534
|
-
do {
|
|
535
|
-
try CryptoCipher.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
|
|
536
|
-
} catch {
|
|
537
|
-
self.sendStats(action: "decrypt_fail", versionName: version)
|
|
538
|
-
throw error
|
|
539
|
-
}
|
|
540
|
-
// TODO: try and do self.sendStats(action: "decrypt_fail", versionName: version) if fail
|
|
541
|
-
finalData = try Data(contentsOf: tempFile)
|
|
542
|
-
try FileManager.default.removeItem(at: tempFile)
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
// Use the isBrotli and destFilePath already computed above
|
|
546
|
-
if isBrotli {
|
|
547
|
-
// Decompress the Brotli data
|
|
548
|
-
guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
|
|
549
|
-
self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
|
|
550
|
-
throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
|
|
551
|
-
}
|
|
552
|
-
finalData = decompressedData
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
try finalData.write(to: destFilePath)
|
|
556
|
-
if !self.publicKey.isEmpty && !sessionKey.isEmpty {
|
|
557
|
-
// assume that calcChecksum != null
|
|
558
|
-
let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
|
|
559
|
-
CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
|
|
560
|
-
CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
|
|
561
|
-
if calculatedChecksum != fileHash {
|
|
562
|
-
// Delete the corrupt file before throwing error
|
|
563
|
-
try? FileManager.default.removeItem(at: destFilePath)
|
|
564
|
-
self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
|
|
565
|
-
throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// Save decrypted data to cache and destination
|
|
570
|
-
try finalData.write(to: cacheFilePath)
|
|
571
|
-
|
|
572
|
-
completedFiles += 1
|
|
573
|
-
self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
|
|
574
|
-
self.logger.info("Manifest file downloaded and cached")
|
|
575
|
-
self.logger.debug("Bundle: \(id), File: \(fileName), Brotli: \(isBrotli), Encrypted: \(!self.publicKey.isEmpty && !sessionKey.isEmpty)")
|
|
576
|
-
} catch {
|
|
577
|
-
downloadError = error
|
|
578
|
-
self.logger.error("Manifest file download failed")
|
|
579
|
-
self.logger.debug("Bundle: \(id), File: \(fileName), Error: \(error.localizedDescription)")
|
|
580
|
-
}
|
|
581
|
-
case .failure(let error):
|
|
646
|
+
let completed = completedFiles.increment()
|
|
647
|
+
let percent = self.calcTotalPercent(percent: Int((Double(completed) / Double(totalFiles)) * 100), min: 10, max: 70)
|
|
648
|
+
self.notifyDownload(id: id, percent: percent)
|
|
649
|
+
|
|
650
|
+
} catch {
|
|
651
|
+
errorLock.lock()
|
|
652
|
+
if downloadError == nil {
|
|
582
653
|
downloadError = error
|
|
583
|
-
self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
|
|
584
|
-
self.logger.error("Manifest file download network error")
|
|
585
|
-
self.logger.debug("Bundle: \(id), File: \(fileName), Error: \(error.localizedDescription), Response: \(response.debugDescription)")
|
|
586
654
|
}
|
|
655
|
+
errorLock.unlock()
|
|
656
|
+
hasError.value = true
|
|
657
|
+
self.logger.error("Manifest file download failed: \(fileName)")
|
|
658
|
+
self.logger.debug("Bundle: \(id), File: \(fileName), Error: \(error.localizedDescription)")
|
|
587
659
|
}
|
|
588
660
|
}
|
|
661
|
+
|
|
662
|
+
operations.append(operation)
|
|
589
663
|
}
|
|
590
664
|
|
|
591
|
-
|
|
665
|
+
// Execute all operations concurrently and wait for completion
|
|
666
|
+
manifestDownloadQueue.addOperations(operations, waitUntilFinished: true)
|
|
592
667
|
|
|
593
|
-
if let error = downloadError {
|
|
668
|
+
if hasError.value, let error = downloadError {
|
|
594
669
|
// Update bundle status to ERROR if download failed
|
|
595
670
|
let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.localizedString)
|
|
596
671
|
self.saveBundleInfo(id: id, bundle: errorBundle)
|
|
@@ -609,6 +684,105 @@ import UIKit
|
|
|
609
684
|
return updatedBundle
|
|
610
685
|
}
|
|
611
686
|
|
|
687
|
+
/// Downloads a single manifest file synchronously
|
|
688
|
+
/// Used by downloadManifest for concurrent file downloads
|
|
689
|
+
private func downloadManifestFile(
|
|
690
|
+
downloadUrl: String,
|
|
691
|
+
destFilePath: URL,
|
|
692
|
+
cacheFilePath: URL,
|
|
693
|
+
fileHash: String,
|
|
694
|
+
fileName: String,
|
|
695
|
+
destFileName: String,
|
|
696
|
+
isBrotli: Bool,
|
|
697
|
+
sessionKey: String,
|
|
698
|
+
version: String,
|
|
699
|
+
bundleId: String
|
|
700
|
+
) throws {
|
|
701
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
702
|
+
var downloadError: Error?
|
|
703
|
+
|
|
704
|
+
self.alamofireSession.download(downloadUrl).responseData { response in
|
|
705
|
+
defer { semaphore.signal() }
|
|
706
|
+
|
|
707
|
+
switch response.result {
|
|
708
|
+
case .success(let data):
|
|
709
|
+
do {
|
|
710
|
+
let statusCode = response.response?.statusCode ?? 200
|
|
711
|
+
if statusCode < 200 || statusCode >= 300 {
|
|
712
|
+
self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
|
|
713
|
+
if let stringData = String(data: data, encoding: .utf8) {
|
|
714
|
+
throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid. Data: \(stringData) for file \(fileName) at url \(downloadUrl)"])
|
|
715
|
+
} else {
|
|
716
|
+
throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Add decryption step if public key is set and sessionKey is provided
|
|
721
|
+
var finalData = data
|
|
722
|
+
if !self.publicKey.isEmpty && !sessionKey.isEmpty {
|
|
723
|
+
let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
|
|
724
|
+
try finalData.write(to: tempFile)
|
|
725
|
+
do {
|
|
726
|
+
try CryptoCipher.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
|
|
727
|
+
} catch {
|
|
728
|
+
self.sendStats(action: "decrypt_fail", versionName: version)
|
|
729
|
+
throw error
|
|
730
|
+
}
|
|
731
|
+
finalData = try Data(contentsOf: tempFile)
|
|
732
|
+
try FileManager.default.removeItem(at: tempFile)
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Decompress Brotli if needed
|
|
736
|
+
if isBrotli {
|
|
737
|
+
guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
|
|
738
|
+
self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
|
|
739
|
+
throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
|
|
740
|
+
}
|
|
741
|
+
finalData = decompressedData
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Write to destination
|
|
745
|
+
try finalData.write(to: destFilePath)
|
|
746
|
+
|
|
747
|
+
// Verify checksum if encryption is enabled
|
|
748
|
+
if !self.publicKey.isEmpty && !sessionKey.isEmpty {
|
|
749
|
+
let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
|
|
750
|
+
CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
|
|
751
|
+
CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
|
|
752
|
+
if calculatedChecksum != fileHash {
|
|
753
|
+
try? FileManager.default.removeItem(at: destFilePath)
|
|
754
|
+
self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
|
|
755
|
+
throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Save to cache
|
|
760
|
+
try finalData.write(to: cacheFilePath)
|
|
761
|
+
|
|
762
|
+
self.logger.info("Manifest file downloaded and cached")
|
|
763
|
+
self.logger.debug("Bundle: \(bundleId), File: \(fileName), Brotli: \(isBrotli), Encrypted: \(!self.publicKey.isEmpty && !sessionKey.isEmpty)")
|
|
764
|
+
|
|
765
|
+
} catch {
|
|
766
|
+
downloadError = error
|
|
767
|
+
self.logger.error("Manifest file download failed")
|
|
768
|
+
self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
case .failure(let error):
|
|
772
|
+
downloadError = error
|
|
773
|
+
self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
|
|
774
|
+
self.logger.error("Manifest file download network error")
|
|
775
|
+
self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription), Response: \(response.debugDescription)")
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
semaphore.wait()
|
|
780
|
+
|
|
781
|
+
if let error = downloadError {
|
|
782
|
+
throw error
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
612
786
|
/// Atomically try to copy a file from cache - returns true if successful, false if file doesn't exist or copy failed
|
|
613
787
|
/// This handles the race condition where OS can delete cache files between exists() check and copy
|
|
614
788
|
private func tryCopyFromCache(from source: URL, to destination: URL, expectedHash: String) -> Bool {
|
|
@@ -754,15 +928,20 @@ import UIKit
|
|
|
754
928
|
public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
|
|
755
929
|
let id: String = self.randomString(length: 10)
|
|
756
930
|
let semaphore = DispatchSemaphore(value: 0)
|
|
757
|
-
|
|
758
|
-
|
|
931
|
+
// Each download uses its own temp files keyed by bundle ID to prevent collisions
|
|
932
|
+
if version != getLocalUpdateVersion(for: id) {
|
|
933
|
+
cleanDownloadData(for: id)
|
|
759
934
|
}
|
|
760
|
-
ensureResumableFilesExist()
|
|
761
|
-
saveDownloadInfo(version)
|
|
935
|
+
ensureResumableFilesExist(for: id)
|
|
936
|
+
saveDownloadInfo(version, for: id)
|
|
937
|
+
|
|
938
|
+
// Check disk space before starting download (matches Android behavior)
|
|
939
|
+
try checkDiskSpace()
|
|
940
|
+
|
|
762
941
|
var checksum = ""
|
|
763
942
|
var targetSize = -1
|
|
764
943
|
var lastSentProgress = 0
|
|
765
|
-
var totalReceivedBytes: Int64 = loadDownloadProgress() // Retrieving the amount of already downloaded data if exist, defined at 0 otherwise
|
|
944
|
+
var totalReceivedBytes: Int64 = loadDownloadProgress(for: id) // Retrieving the amount of already downloaded data if exist, defined at 0 otherwise
|
|
766
945
|
let requestHeaders: HTTPHeaders = ["Range": "bytes=\(totalReceivedBytes)-"]
|
|
767
946
|
|
|
768
947
|
// Send stats for zip download start
|
|
@@ -795,7 +974,7 @@ import UIKit
|
|
|
795
974
|
if case .success(let data) = result {
|
|
796
975
|
self.tempData.append(data)
|
|
797
976
|
|
|
798
|
-
self.savePartialData(startingAt: UInt64(totalReceivedBytes)) // Saving the received data in the
|
|
977
|
+
self.savePartialData(startingAt: UInt64(totalReceivedBytes), for: id) // Saving the received data in the package_<id>.tmp file
|
|
799
978
|
totalReceivedBytes += Int64(data.count)
|
|
800
979
|
|
|
801
980
|
let percent = max(10, Int((Double(totalReceivedBytes) / Double(targetSize)) * 70.0))
|
|
@@ -841,15 +1020,16 @@ import UIKit
|
|
|
841
1020
|
throw mainError!
|
|
842
1021
|
}
|
|
843
1022
|
|
|
844
|
-
let
|
|
1023
|
+
let tempPath = tempDataPath(for: id)
|
|
1024
|
+
let finalPath = tempPath.deletingLastPathComponent().appendingPathComponent("\(id)")
|
|
845
1025
|
do {
|
|
846
|
-
try CryptoCipher.decryptFile(filePath:
|
|
847
|
-
try FileManager.default.moveItem(at:
|
|
1026
|
+
try CryptoCipher.decryptFile(filePath: tempPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
|
|
1027
|
+
try FileManager.default.moveItem(at: tempPath, to: finalPath)
|
|
848
1028
|
} catch {
|
|
849
1029
|
logger.error("Failed to decrypt file")
|
|
850
1030
|
logger.debug("Error: \(error)")
|
|
851
1031
|
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
|
|
852
|
-
cleanDownloadData()
|
|
1032
|
+
cleanDownloadData(for: id)
|
|
853
1033
|
throw error
|
|
854
1034
|
}
|
|
855
1035
|
|
|
@@ -858,6 +1038,7 @@ import UIKit
|
|
|
858
1038
|
CryptoCipher.logChecksumInfo(label: "Calculated bundle checksum", hexChecksum: checksum)
|
|
859
1039
|
logger.info("Downloading: 80% (unzipping)")
|
|
860
1040
|
try self.saveDownloaded(sourceZip: finalPath, id: id, base: self.libraryDir.appendingPathComponent(self.bundleDirectory), notify: true)
|
|
1041
|
+
self.populateDeltaCacheAsync(for: id)
|
|
861
1042
|
|
|
862
1043
|
} catch {
|
|
863
1044
|
logger.error("Failed to unzip file")
|
|
@@ -872,7 +1053,7 @@ import UIKit
|
|
|
872
1053
|
logger.error("Could not delete failed zip")
|
|
873
1054
|
logger.debug("Path: \(finalPath.path), Error: \(error)")
|
|
874
1055
|
}
|
|
875
|
-
cleanDownloadData()
|
|
1056
|
+
cleanDownloadData(for: id)
|
|
876
1057
|
throw error
|
|
877
1058
|
}
|
|
878
1059
|
|
|
@@ -880,7 +1061,7 @@ import UIKit
|
|
|
880
1061
|
logger.info("Downloading: 90% (wrapping up)")
|
|
881
1062
|
let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum, link: link, comment: comment)
|
|
882
1063
|
self.saveBundleInfo(id: id, bundle: info)
|
|
883
|
-
self.cleanDownloadData()
|
|
1064
|
+
self.cleanDownloadData(for: id)
|
|
884
1065
|
|
|
885
1066
|
// Send stats for zip download complete
|
|
886
1067
|
self.sendStats(action: "download_zip_complete", versionName: version)
|
|
@@ -889,54 +1070,59 @@ import UIKit
|
|
|
889
1070
|
logger.info("Downloading: 100% (complete)")
|
|
890
1071
|
return info
|
|
891
1072
|
}
|
|
892
|
-
private func ensureResumableFilesExist() {
|
|
1073
|
+
private func ensureResumableFilesExist(for id: String) {
|
|
893
1074
|
let fileManager = FileManager.default
|
|
894
|
-
|
|
895
|
-
|
|
1075
|
+
let tempPath = tempDataPath(for: id)
|
|
1076
|
+
let infoPath = updateInfoPath(for: id)
|
|
1077
|
+
if !fileManager.fileExists(atPath: tempPath.path) {
|
|
1078
|
+
if !fileManager.createFile(atPath: tempPath.path, contents: Data()) {
|
|
896
1079
|
logger.error("Cannot ensure temp data file exists")
|
|
897
|
-
logger.debug("Path: \(
|
|
1080
|
+
logger.debug("Path: \(tempPath.path)")
|
|
898
1081
|
}
|
|
899
1082
|
}
|
|
900
1083
|
|
|
901
|
-
if !fileManager.fileExists(atPath:
|
|
902
|
-
if !fileManager.createFile(atPath:
|
|
1084
|
+
if !fileManager.fileExists(atPath: infoPath.path) {
|
|
1085
|
+
if !fileManager.createFile(atPath: infoPath.path, contents: Data()) {
|
|
903
1086
|
logger.error("Cannot ensure update info file exists")
|
|
904
|
-
logger.debug("Path: \(
|
|
1087
|
+
logger.debug("Path: \(infoPath.path)")
|
|
905
1088
|
}
|
|
906
1089
|
}
|
|
907
1090
|
}
|
|
908
1091
|
|
|
909
|
-
private func cleanDownloadData() {
|
|
910
|
-
// Deleting package.tmp
|
|
1092
|
+
private func cleanDownloadData(for id: String) {
|
|
911
1093
|
let fileManager = FileManager.default
|
|
912
|
-
|
|
1094
|
+
let tempPath = tempDataPath(for: id)
|
|
1095
|
+
let infoPath = updateInfoPath(for: id)
|
|
1096
|
+
// Deleting package_<id>.tmp
|
|
1097
|
+
if fileManager.fileExists(atPath: tempPath.path) {
|
|
913
1098
|
do {
|
|
914
|
-
try fileManager.removeItem(at:
|
|
1099
|
+
try fileManager.removeItem(at: tempPath)
|
|
915
1100
|
} catch {
|
|
916
1101
|
logger.error("Could not delete temp data file")
|
|
917
|
-
logger.debug("Path: \(
|
|
1102
|
+
logger.debug("Path: \(tempPath), Error: \(error)")
|
|
918
1103
|
}
|
|
919
1104
|
}
|
|
920
|
-
// Deleting
|
|
921
|
-
if fileManager.fileExists(atPath:
|
|
1105
|
+
// Deleting update_<id>.dat
|
|
1106
|
+
if fileManager.fileExists(atPath: infoPath.path) {
|
|
922
1107
|
do {
|
|
923
|
-
try fileManager.removeItem(at:
|
|
1108
|
+
try fileManager.removeItem(at: infoPath)
|
|
924
1109
|
} catch {
|
|
925
1110
|
logger.error("Could not delete update info file")
|
|
926
|
-
logger.debug("Path: \(
|
|
1111
|
+
logger.debug("Path: \(infoPath), Error: \(error)")
|
|
927
1112
|
}
|
|
928
1113
|
}
|
|
929
1114
|
}
|
|
930
1115
|
|
|
931
|
-
private func savePartialData(startingAt byteOffset: UInt64) {
|
|
1116
|
+
private func savePartialData(startingAt byteOffset: UInt64, for id: String) {
|
|
932
1117
|
let fileManager = FileManager.default
|
|
1118
|
+
let tempPath = tempDataPath(for: id)
|
|
933
1119
|
do {
|
|
934
|
-
// Check if
|
|
935
|
-
if !fileManager.fileExists(atPath:
|
|
936
|
-
try self.tempData.write(to:
|
|
1120
|
+
// Check if package_<id>.tmp exist
|
|
1121
|
+
if !fileManager.fileExists(atPath: tempPath.path) {
|
|
1122
|
+
try self.tempData.write(to: tempPath, options: .atomicWrite)
|
|
937
1123
|
} else {
|
|
938
1124
|
// If yes, it start writing on it
|
|
939
|
-
let fileHandle = try FileHandle(forWritingTo:
|
|
1125
|
+
let fileHandle = try FileHandle(forWritingTo: tempPath)
|
|
940
1126
|
fileHandle.seek(toFileOffset: byteOffset) // Moving at the specified position to start writing
|
|
941
1127
|
fileHandle.write(self.tempData)
|
|
942
1128
|
fileHandle.closeFile()
|
|
@@ -948,29 +1134,33 @@ import UIKit
|
|
|
948
1134
|
self.tempData.removeAll() // Clearing tempData to avoid writing the same data multiple times
|
|
949
1135
|
}
|
|
950
1136
|
|
|
951
|
-
private func saveDownloadInfo(_ version: String) {
|
|
1137
|
+
private func saveDownloadInfo(_ version: String, for id: String) {
|
|
1138
|
+
let infoPath = updateInfoPath(for: id)
|
|
952
1139
|
do {
|
|
953
|
-
try "\(version)".write(to:
|
|
1140
|
+
try "\(version)".write(to: infoPath, atomically: true, encoding: .utf8)
|
|
954
1141
|
} catch {
|
|
955
1142
|
logger.error("Failed to save download progress")
|
|
956
1143
|
logger.debug("Error: \(error)")
|
|
957
1144
|
}
|
|
958
1145
|
}
|
|
959
|
-
|
|
960
|
-
|
|
1146
|
+
|
|
1147
|
+
private func getLocalUpdateVersion(for id: String) -> String { // Return the version that was tried to be downloaded on last download attempt
|
|
1148
|
+
let infoPath = updateInfoPath(for: id)
|
|
1149
|
+
if !FileManager.default.fileExists(atPath: infoPath.path) {
|
|
961
1150
|
return "nil"
|
|
962
1151
|
}
|
|
963
|
-
guard let versionString = try? String(contentsOf:
|
|
1152
|
+
guard let versionString = try? String(contentsOf: infoPath),
|
|
964
1153
|
let version = Optional(versionString) else {
|
|
965
1154
|
return "nil"
|
|
966
1155
|
}
|
|
967
1156
|
return version
|
|
968
1157
|
}
|
|
969
|
-
private func loadDownloadProgress() -> Int64 {
|
|
970
1158
|
|
|
1159
|
+
private func loadDownloadProgress(for id: String) -> Int64 {
|
|
971
1160
|
let fileManager = FileManager.default
|
|
1161
|
+
let tempPath = tempDataPath(for: id)
|
|
972
1162
|
do {
|
|
973
|
-
let attributes = try fileManager.attributesOfItem(atPath:
|
|
1163
|
+
let attributes = try fileManager.attributesOfItem(atPath: tempPath.path)
|
|
974
1164
|
if let fileSize = attributes[.size] as? NSNumber {
|
|
975
1165
|
return fileSize.int64Value
|
|
976
1166
|
}
|
|
@@ -1174,6 +1364,44 @@ import UIKit
|
|
|
1174
1364
|
logger.error("Failed to enumerate library directory for temp folder cleanup")
|
|
1175
1365
|
logger.debug("Error: \(error.localizedDescription)")
|
|
1176
1366
|
}
|
|
1367
|
+
|
|
1368
|
+
// Also cleanup old download temp files (package_*.tmp and update_*.dat)
|
|
1369
|
+
cleanupOldDownloadTempFiles()
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
private func cleanupOldDownloadTempFiles() {
|
|
1373
|
+
let fileManager = FileManager.default
|
|
1374
|
+
guard let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
|
1375
|
+
return
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
do {
|
|
1379
|
+
let contents = try fileManager.contentsOfDirectory(at: documentsDir, includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles])
|
|
1380
|
+
let oneHourAgo = Date().addingTimeInterval(-3600)
|
|
1381
|
+
|
|
1382
|
+
for url in contents {
|
|
1383
|
+
let fileName = url.lastPathComponent
|
|
1384
|
+
// Only cleanup package_*.tmp and update_*.dat files
|
|
1385
|
+
let isDownloadTemp = (fileName.hasPrefix("package_") && fileName.hasSuffix(".tmp")) ||
|
|
1386
|
+
(fileName.hasPrefix("update_") && fileName.hasSuffix(".dat"))
|
|
1387
|
+
if !isDownloadTemp {
|
|
1388
|
+
continue
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Only delete files older than 1 hour
|
|
1392
|
+
if let modDate = try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate,
|
|
1393
|
+
modDate < oneHourAgo {
|
|
1394
|
+
do {
|
|
1395
|
+
try fileManager.removeItem(at: url)
|
|
1396
|
+
logger.debug("Deleted old download temp file: \(fileName)")
|
|
1397
|
+
} catch {
|
|
1398
|
+
logger.debug("Failed to delete old download temp file: \(fileName), Error: \(error.localizedDescription)")
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
} catch {
|
|
1403
|
+
logger.debug("Failed to enumerate documents directory for temp file cleanup: \(error.localizedDescription)")
|
|
1404
|
+
}
|
|
1177
1405
|
}
|
|
1178
1406
|
|
|
1179
1407
|
public func getBundleDirectory(id: String) -> URL {
|
|
@@ -1499,6 +1727,13 @@ import UIKit
|
|
|
1499
1727
|
|
|
1500
1728
|
private let operationQueue = OperationQueue()
|
|
1501
1729
|
|
|
1730
|
+
private let manifestDownloadQueue: OperationQueue = {
|
|
1731
|
+
let queue = OperationQueue()
|
|
1732
|
+
queue.name = "com.capgo.manifestDownload"
|
|
1733
|
+
queue.qualityOfService = .userInitiated
|
|
1734
|
+
return queue
|
|
1735
|
+
}()
|
|
1736
|
+
|
|
1502
1737
|
func sendStats(action: String, versionName: String? = nil, oldVersionName: String? = "") {
|
|
1503
1738
|
// Check if rate limit was exceeded
|
|
1504
1739
|
if CapgoUpdater.rateLimitExceeded {
|
|
@@ -1509,21 +1744,70 @@ import UIKit
|
|
|
1509
1744
|
guard !statsUrl.isEmpty else {
|
|
1510
1745
|
return
|
|
1511
1746
|
}
|
|
1512
|
-
operationQueue.maxConcurrentOperationCount = 1
|
|
1513
1747
|
|
|
1514
|
-
let
|
|
1748
|
+
let resolvedVersionName = versionName ?? getCurrentBundle().getVersionName()
|
|
1749
|
+
let info = createInfoObject()
|
|
1750
|
+
|
|
1751
|
+
let event = StatsEvent(
|
|
1752
|
+
platform: info.platform,
|
|
1753
|
+
device_id: info.device_id,
|
|
1754
|
+
app_id: info.app_id,
|
|
1755
|
+
custom_id: info.custom_id,
|
|
1756
|
+
version_build: info.version_build,
|
|
1757
|
+
version_code: info.version_code,
|
|
1758
|
+
version_os: info.version_os,
|
|
1759
|
+
version_name: resolvedVersionName,
|
|
1760
|
+
old_version_name: oldVersionName ?? "",
|
|
1761
|
+
plugin_version: info.plugin_version,
|
|
1762
|
+
is_emulator: info.is_emulator,
|
|
1763
|
+
is_prod: info.is_prod,
|
|
1764
|
+
action: action,
|
|
1765
|
+
channel: info.channel,
|
|
1766
|
+
defaultChannel: info.defaultChannel,
|
|
1767
|
+
key_id: info.key_id,
|
|
1768
|
+
timestamp: Int64(Date().timeIntervalSince1970 * 1000)
|
|
1769
|
+
)
|
|
1770
|
+
|
|
1771
|
+
statsQueueLock.lock()
|
|
1772
|
+
statsQueue.append(event)
|
|
1773
|
+
statsQueueLock.unlock()
|
|
1515
1774
|
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1775
|
+
ensureStatsTimerStarted()
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
private func ensureStatsTimerStarted() {
|
|
1779
|
+
DispatchQueue.main.async { [weak self] in
|
|
1780
|
+
guard let self = self else { return }
|
|
1781
|
+
if self.statsFlushTimer == nil || !self.statsFlushTimer!.isValid {
|
|
1782
|
+
// Use closure-based timer to avoid strong reference cycle
|
|
1783
|
+
self.statsFlushTimer = Timer.scheduledTimer(
|
|
1784
|
+
withTimeInterval: CapgoUpdater.statsFlushInterval,
|
|
1785
|
+
repeats: true
|
|
1786
|
+
) { [weak self] _ in
|
|
1787
|
+
self?.flushStatsQueue()
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
private func flushStatsQueue() {
|
|
1794
|
+
statsQueueLock.lock()
|
|
1795
|
+
guard !statsQueue.isEmpty else {
|
|
1796
|
+
statsQueueLock.unlock()
|
|
1797
|
+
return
|
|
1798
|
+
}
|
|
1799
|
+
let eventsToSend = statsQueue
|
|
1800
|
+
statsQueue.removeAll()
|
|
1801
|
+
statsQueueLock.unlock()
|
|
1802
|
+
|
|
1803
|
+
operationQueue.maxConcurrentOperationCount = 1
|
|
1520
1804
|
|
|
1521
1805
|
let operation = BlockOperation {
|
|
1522
1806
|
let semaphore = DispatchSemaphore(value: 0)
|
|
1523
1807
|
self.alamofireSession.request(
|
|
1524
1808
|
self.statsUrl,
|
|
1525
1809
|
method: .post,
|
|
1526
|
-
parameters:
|
|
1810
|
+
parameters: eventsToSend,
|
|
1527
1811
|
encoder: JSONParameterEncoder.default,
|
|
1528
1812
|
requestModifier: { $0.timeoutInterval = self.timeout }
|
|
1529
1813
|
).responseData { response in
|
|
@@ -1535,10 +1819,10 @@ import UIKit
|
|
|
1535
1819
|
|
|
1536
1820
|
switch response.result {
|
|
1537
1821
|
case .success:
|
|
1538
|
-
self.logger.info("Stats sent successfully")
|
|
1539
|
-
self.logger.debug("
|
|
1822
|
+
self.logger.info("Stats batch sent successfully")
|
|
1823
|
+
self.logger.debug("Sent \(eventsToSend.count) events")
|
|
1540
1824
|
case let .failure(error):
|
|
1541
|
-
self.logger.error("Error sending stats")
|
|
1825
|
+
self.logger.error("Error sending stats batch")
|
|
1542
1826
|
self.logger.debug("Response: \(response.value?.debugDescription ?? "nil"), Error: \(error.localizedDescription)")
|
|
1543
1827
|
}
|
|
1544
1828
|
semaphore.signal()
|
|
@@ -1546,7 +1830,6 @@ import UIKit
|
|
|
1546
1830
|
semaphore.wait()
|
|
1547
1831
|
}
|
|
1548
1832
|
operationQueue.addOperation(operation)
|
|
1549
|
-
|
|
1550
1833
|
}
|
|
1551
1834
|
|
|
1552
1835
|
public func getBundleInfo(id: String?) -> BundleInfo {
|