@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.
@@ -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
- self.publicKey = publicKey
84
- if !publicKey.isEmpty {
85
- self.cachedKeyId = CryptoCipher.calcKeyId(publicKey: publicKey)
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
- private var tempDataPath: URL {
421
- return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("package.tmp")
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 var updateInfo: URL {
425
- return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("update.dat")
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 dispatchGroup = DispatchGroup()
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
- let totalFiles = manifest.count
457
- var completedFiles = 0
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
- // Check if file has .br extension for Brotli decompression
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 destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
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 necessary subdirectories in the destination folder
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
- dispatchGroup.enter()
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
- try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
496
- logger.info("downloadManifest \(fileName) using builtin file \(id)")
497
- completedFiles += 1
498
- self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
499
- } catch {
500
- downloadError = error
501
- logger.error("Failed to copy builtin file")
502
- logger.debug("File: \(fileName), Error: \(error.localizedDescription)")
503
- }
504
- dispatchGroup.leave()
505
- } else if self.tryCopyFromCache(from: cacheFilePath, to: destFilePath, expectedHash: fileHash) {
506
- // Successfully copied from cache
507
- logger.info("downloadManifest \(fileName) copy from cache \(id)")
508
- completedFiles += 1
509
- self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
510
- dispatchGroup.leave()
511
- } else {
512
- // File not in cache, download, decompress, and save to both cache and destination
513
- self.alamofireSession.download(downloadUrl).responseData { response in
514
- defer { dispatchGroup.leave() }
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
- switch response.result {
517
- case .success(let data):
518
- do {
519
- let statusCode = response.response?.statusCode ?? 200
520
- if statusCode < 200 || statusCode >= 300 {
521
- self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
522
- if let stringData = String(data: data, encoding: .utf8) {
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
- dispatchGroup.wait()
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
- if version != getLocalUpdateVersion() {
758
- cleanDownloadData()
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 package.tmp file
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 finalPath = tempDataPath.deletingLastPathComponent().appendingPathComponent("\(id)")
1023
+ let tempPath = tempDataPath(for: id)
1024
+ let finalPath = tempPath.deletingLastPathComponent().appendingPathComponent("\(id)")
845
1025
  do {
846
- try CryptoCipher.decryptFile(filePath: tempDataPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
847
- try FileManager.default.moveItem(at: tempDataPath, to: finalPath)
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
- if !fileManager.fileExists(atPath: tempDataPath.path) {
895
- if !fileManager.createFile(atPath: tempDataPath.path, contents: Data()) {
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: \(tempDataPath.path)")
1080
+ logger.debug("Path: \(tempPath.path)")
898
1081
  }
899
1082
  }
900
1083
 
901
- if !fileManager.fileExists(atPath: updateInfo.path) {
902
- if !fileManager.createFile(atPath: updateInfo.path, contents: Data()) {
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: \(updateInfo.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
- if fileManager.fileExists(atPath: tempDataPath.path) {
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: tempDataPath)
1099
+ try fileManager.removeItem(at: tempPath)
915
1100
  } catch {
916
1101
  logger.error("Could not delete temp data file")
917
- logger.debug("Path: \(tempDataPath), Error: \(error)")
1102
+ logger.debug("Path: \(tempPath), Error: \(error)")
918
1103
  }
919
1104
  }
920
- // Deleting update.dat
921
- if fileManager.fileExists(atPath: updateInfo.path) {
1105
+ // Deleting update_<id>.dat
1106
+ if fileManager.fileExists(atPath: infoPath.path) {
922
1107
  do {
923
- try fileManager.removeItem(at: updateInfo)
1108
+ try fileManager.removeItem(at: infoPath)
924
1109
  } catch {
925
1110
  logger.error("Could not delete update info file")
926
- logger.debug("Path: \(updateInfo), Error: \(error)")
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 package.tmp exist
935
- if !fileManager.fileExists(atPath: tempDataPath.path) {
936
- try self.tempData.write(to: tempDataPath, options: .atomicWrite)
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: tempDataPath)
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: updateInfo, atomically: true, encoding: .utf8)
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
- private func getLocalUpdateVersion() -> String { // Return the version that was tried to be downloaded on last download attempt
960
- if !FileManager.default.fileExists(atPath: updateInfo.path) {
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: updateInfo),
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: tempDataPath.path)
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 versionName = versionName ?? getCurrentBundle().getVersionName()
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
- var parameters = createInfoObject()
1517
- parameters.action = action
1518
- parameters.version_name = versionName
1519
- parameters.old_version_name = oldVersionName ?? ""
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: 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("Action: \(action), Version: \(versionName)")
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 {