@capgo/capacitor-updater 8.40.7 → 8.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -85,7 +85,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
85
85
  private static final String[] BREAKING_EVENT_NAMES = { "breakingAvailable", "majorAvailable" };
86
86
  private static final String LAST_FAILED_BUNDLE_PREF_KEY = "CapacitorUpdater.lastFailedBundle";
87
87
 
88
- private final String pluginVersion = "8.40.7";
88
+ private final String pluginVersion = "8.41.0";
89
89
  private static final String DELAY_CONDITION_PREFERENCES = "";
90
90
 
91
91
  private SharedPreferences.Editor editor;
@@ -357,8 +357,10 @@ public class CryptoCipher {
357
357
  }
358
358
 
359
359
  /**
360
- * Get first 4 characters of the public key for identification.
361
- * Returns 4-character string or empty string if key is invalid/empty.
360
+ * Get first 20 characters of the public key for identification.
361
+ * Returns 20-character string or empty string if key is invalid/empty.
362
+ * The first 12 chars are always "MIIBCgKCAQEA" for RSA 2048-bit keys,
363
+ * so the unique part starts at character 13.
362
364
  */
363
365
  public static String calcKeyId(String publicKey) {
364
366
  if (publicKey == null || publicKey.isEmpty()) {
@@ -371,7 +373,7 @@ public class CryptoCipher {
371
373
  .replace("-----BEGINRSAPUBLICKEY-----", "")
372
374
  .replace("-----ENDRSAPUBLICKEY-----", "");
373
375
 
374
- // Return first 4 characters of the base64-encoded key
375
- return cleanedKey.length() >= 4 ? cleanedKey.substring(0, 4) : cleanedKey;
376
+ // Return first 20 characters of the base64-encoded key
377
+ return cleanedKey.length() >= 20 ? cleanedKey.substring(0, 20) : cleanedKey;
376
378
  }
377
379
  }
@@ -388,8 +388,9 @@ public class DownloadService extends Worker {
388
388
  sendStatsAsync("download_zip_start", version);
389
389
 
390
390
  File target = new File(documentsDir, dest);
391
- File infoFile = new File(documentsDir, UPDATE_FILE);
392
- File tempFile = new File(documentsDir, "temp" + ".tmp");
391
+ // Use bundle ID in temp file names to prevent collisions when multiple downloads run concurrently
392
+ File infoFile = new File(documentsDir, "update_" + id + ".dat");
393
+ File tempFile = new File(documentsDir, "temp_" + id + ".tmp");
393
394
 
394
395
  // Check available disk space before starting
395
396
  long availableSpace = target.getParentFile().getUsableSpace();
@@ -420,7 +421,7 @@ public class DownloadService extends Worker {
420
421
  reader = new BufferedReader(new FileReader(infoFile));
421
422
  String updateVersion = reader.readLine();
422
423
  if (updateVersion != null && !updateVersion.equals(version)) {
423
- clearDownloadData(documentsDir);
424
+ clearDownloadData(documentsDir, id);
424
425
  } else {
425
426
  downloadedBytes = tempFile.length();
426
427
  }
@@ -432,7 +433,7 @@ public class DownloadService extends Worker {
432
433
  }
433
434
  }
434
435
  } else {
435
- clearDownloadData(documentsDir);
436
+ clearDownloadData(documentsDir, id);
436
437
  }
437
438
 
438
439
  if (downloadedBytes > 0) {
@@ -544,9 +545,9 @@ public class DownloadService extends Worker {
544
545
  }
545
546
  }
546
547
 
547
- private void clearDownloadData(String docDir) {
548
- File tempFile = new File(docDir, "temp" + ".tmp");
549
- File infoFile = new File(docDir, UPDATE_FILE);
548
+ private void clearDownloadData(String docDir, String id) {
549
+ File tempFile = new File(docDir, "temp_" + id + ".tmp");
550
+ File infoFile = new File(docDir, "update_" + id + ".dat");
550
551
  try {
551
552
  tempFile.delete();
552
553
  infoFile.delete();
@@ -824,12 +825,14 @@ public class DownloadService extends Worker {
824
825
  }
825
826
 
826
827
  /**
827
- * Clean up old temporary files
828
+ * Clean up old temporary files (both .tmp and update_*.dat files)
828
829
  */
829
830
  private void cleanupOldTempFiles(File directory) {
830
831
  if (directory == null || !directory.exists()) return;
831
832
 
832
- File[] tempFiles = directory.listFiles((dir, name) -> name.endsWith(".tmp"));
833
+ File[] tempFiles = directory.listFiles(
834
+ (dir, name) -> name.endsWith(".tmp") || (name.startsWith("update_") && name.endsWith(".dat"))
835
+ );
833
836
  if (tempFiles != null) {
834
837
  long oneHourAgo = System.currentTimeMillis() - 3600000;
835
838
  for (File tempFile : tempFiles) {
@@ -60,7 +60,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
60
60
  CAPPluginMethod(name: "completeFlexibleUpdate", returnType: CAPPluginReturnPromise)
61
61
  ]
62
62
  public var implementation = CapgoUpdater()
63
- private let pluginVersion: String = "8.40.7"
63
+ private let pluginVersion: String = "8.41.0"
64
64
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
65
65
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
66
66
  static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
@@ -104,6 +104,38 @@ import UIKit
104
104
  return !self.isDevEnvironment && !self.isAppStoreReceiptSandbox() && !self.hasEmbeddedMobileProvision()
105
105
  }
106
106
 
107
+ /**
108
+ * Checks if there is sufficient disk space for a download.
109
+ * Matches Android behavior: 2x safety margin, throws "insufficient_disk_space"
110
+ * - Parameter estimatedSize: The estimated size of the download in bytes. Defaults to 50MB.
111
+ */
112
+ private func checkDiskSpace(estimatedSize: Int64 = 50 * 1024 * 1024) throws {
113
+ let fileManager = FileManager.default
114
+ guard let documentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
115
+ return
116
+ }
117
+
118
+ do {
119
+ let attributes = try fileManager.attributesOfFileSystem(forPath: documentDirectory.path)
120
+ guard let freeSpace = attributes[.systemFreeSize] as? Int64 else {
121
+ logger.warn("Could not determine free disk space, proceeding with download")
122
+ return
123
+ }
124
+
125
+ let requiredSpace = estimatedSize * 2 // 2x safety margin like Android
126
+
127
+ if freeSpace < requiredSpace {
128
+ logger.error("Insufficient disk space. Available: \(freeSpace), Required: \(requiredSpace)")
129
+ self.sendStats(action: "insufficient_disk_space")
130
+ throw CustomError.insufficientDiskSpace
131
+ }
132
+ } catch let error as CustomError {
133
+ throw error
134
+ } catch {
135
+ logger.warn("Error checking disk space: \(error.localizedDescription)")
136
+ }
137
+ }
138
+
107
139
  /**
108
140
  * Check if a 429 (Too Many Requests) response was received and set the flag
109
141
  */
@@ -417,13 +449,15 @@ import UIKit
417
449
  logger.info("Current bundle set to: \((bundle ).isEmpty ? BundleInfo.ID_BUILTIN : bundle)")
418
450
  }
419
451
 
420
- private var tempDataPath: URL {
421
- return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("package.tmp")
452
+ // Per-download temp file paths to prevent collisions when multiple downloads run concurrently
453
+ private func tempDataPath(for id: String) -> URL {
454
+ return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("package_\(id).tmp")
422
455
  }
423
456
 
424
- private var updateInfo: URL {
425
- return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("update.dat")
457
+ private func updateInfoPath(for id: String) -> URL {
458
+ return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("update_\(id).dat")
426
459
  }
460
+
427
461
  private var tempData = Data()
428
462
 
429
463
  private func verifyChecksum(file: URL, expectedHash: String) -> Bool {
@@ -437,6 +471,10 @@ import UIKit
437
471
  let destFolder = self.getBundleDirectory(id: id)
438
472
  let builtinFolder = Bundle.main.bundleURL.appendingPathComponent("public")
439
473
 
474
+ // Check disk space before starting manifest download (estimate 100KB per file, minimum 50MB)
475
+ let estimatedSize = Int64(max(manifest.count * 100 * 1024, 50 * 1024 * 1024))
476
+ try checkDiskSpace(estimatedSize: estimatedSize)
477
+
440
478
  try FileManager.default.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
441
479
  try FileManager.default.createDirectory(at: destFolder, withIntermediateDirectories: true, attributes: nil)
442
480
 
@@ -450,11 +488,19 @@ import UIKit
450
488
  // Notify the start of the download process
451
489
  self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
452
490
 
453
- let dispatchGroup = DispatchGroup()
491
+ let totalFiles = manifest.count
492
+
493
+ // Configure concurrent operation count similar to Android: min(64, max(32, totalFiles))
494
+ manifestDownloadQueue.maxConcurrentOperationCount = min(64, max(32, totalFiles))
495
+
496
+ // Thread-safe counters for concurrent operations
497
+ let completedFiles = AtomicCounter()
498
+ let hasError = AtomicBool(initialValue: false)
454
499
  var downloadError: Error?
500
+ let errorLock = NSLock()
455
501
 
456
- let totalFiles = manifest.count
457
- var completedFiles = 0
502
+ // Create operations for each file
503
+ var operations: [Operation] = []
458
504
 
459
505
  for entry in manifest {
460
506
  guard let fileName = entry.file_name,
@@ -463,134 +509,87 @@ import UIKit
463
509
  continue
464
510
  }
465
511
 
512
+ // Decrypt checksum if needed (done before creating operation)
466
513
  if !self.publicKey.isEmpty && !sessionKey.isEmpty {
467
514
  do {
468
515
  fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
469
516
  } catch {
517
+ errorLock.lock()
470
518
  downloadError = error
519
+ errorLock.unlock()
520
+ hasError.value = true
471
521
  logger.error("Checksum decryption failed")
472
522
  logger.debug("Bundle: \(id), File: \(fileName), Error: \(error)")
523
+ continue
473
524
  }
474
525
  }
475
526
 
476
- // Check if file has .br extension for Brotli decompression
527
+ let finalFileHash = fileHash
477
528
  let fileNameWithoutPath = (fileName as NSString).lastPathComponent
478
- let cacheFileName = "\(fileHash)_\(fileNameWithoutPath)"
529
+ let cacheFileName = "\(finalFileHash)_\(fileNameWithoutPath)"
479
530
  let cacheFilePath = cacheFolder.appendingPathComponent(cacheFileName)
480
531
 
481
- // Check if file is Brotli compressed and remove .br extension from destination
482
532
  let isBrotli = fileName.hasSuffix(".br")
483
533
  let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
484
-
485
534
  let destFilePath = destFolder.appendingPathComponent(destFileName)
486
535
  let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
487
536
 
488
- // Create necessary subdirectories in the destination folder
489
- try FileManager.default.createDirectory(at: destFilePath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
537
+ // Create parent directories synchronously (before operations start)
538
+ try? FileManager.default.createDirectory(at: destFilePath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
490
539
 
491
- dispatchGroup.enter()
540
+ let operation = BlockOperation { [weak self] in
541
+ guard let self = self else { return }
542
+ guard !hasError.value else { return } // Skip if error already occurred
492
543
 
493
- if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
494
544
  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() }
545
+ // Try builtin first
546
+ if FileManager.default.fileExists(atPath: builtinFilePath.path) && self.verifyChecksum(file: builtinFilePath, expectedHash: finalFileHash) {
547
+ try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
548
+ self.logger.info("downloadManifest \(fileName) using builtin file \(id)")
549
+ }
550
+ // Try cache
551
+ else if self.tryCopyFromCache(from: cacheFilePath, to: destFilePath, expectedHash: finalFileHash) {
552
+ self.logger.info("downloadManifest \(fileName) copy from cache \(id)")
553
+ }
554
+ // Download
555
+ else {
556
+ try self.downloadManifestFile(
557
+ downloadUrl: downloadUrl,
558
+ destFilePath: destFilePath,
559
+ cacheFilePath: cacheFilePath,
560
+ fileHash: finalFileHash,
561
+ fileName: fileName,
562
+ destFileName: destFileName,
563
+ isBrotli: isBrotli,
564
+ sessionKey: sessionKey,
565
+ version: version,
566
+ bundleId: id
567
+ )
568
+ }
515
569
 
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):
570
+ let completed = completedFiles.increment()
571
+ let percent = self.calcTotalPercent(percent: Int((Double(completed) / Double(totalFiles)) * 100), min: 10, max: 70)
572
+ self.notifyDownload(id: id, percent: percent)
573
+
574
+ } catch {
575
+ errorLock.lock()
576
+ if downloadError == nil {
582
577
  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
578
  }
579
+ errorLock.unlock()
580
+ hasError.value = true
581
+ self.logger.error("Manifest file download failed: \(fileName)")
582
+ self.logger.debug("Bundle: \(id), File: \(fileName), Error: \(error.localizedDescription)")
587
583
  }
588
584
  }
585
+
586
+ operations.append(operation)
589
587
  }
590
588
 
591
- dispatchGroup.wait()
589
+ // Execute all operations concurrently and wait for completion
590
+ manifestDownloadQueue.addOperations(operations, waitUntilFinished: true)
592
591
 
593
- if let error = downloadError {
592
+ if hasError.value, let error = downloadError {
594
593
  // Update bundle status to ERROR if download failed
595
594
  let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.localizedString)
596
595
  self.saveBundleInfo(id: id, bundle: errorBundle)
@@ -609,6 +608,105 @@ import UIKit
609
608
  return updatedBundle
610
609
  }
611
610
 
611
+ /// Downloads a single manifest file synchronously
612
+ /// Used by downloadManifest for concurrent file downloads
613
+ private func downloadManifestFile(
614
+ downloadUrl: String,
615
+ destFilePath: URL,
616
+ cacheFilePath: URL,
617
+ fileHash: String,
618
+ fileName: String,
619
+ destFileName: String,
620
+ isBrotli: Bool,
621
+ sessionKey: String,
622
+ version: String,
623
+ bundleId: String
624
+ ) throws {
625
+ let semaphore = DispatchSemaphore(value: 0)
626
+ var downloadError: Error?
627
+
628
+ self.alamofireSession.download(downloadUrl).responseData { response in
629
+ defer { semaphore.signal() }
630
+
631
+ switch response.result {
632
+ case .success(let data):
633
+ do {
634
+ let statusCode = response.response?.statusCode ?? 200
635
+ if statusCode < 200 || statusCode >= 300 {
636
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
637
+ if let stringData = String(data: data, encoding: .utf8) {
638
+ throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid. Data: \(stringData) for file \(fileName) at url \(downloadUrl)"])
639
+ } else {
640
+ throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
641
+ }
642
+ }
643
+
644
+ // Add decryption step if public key is set and sessionKey is provided
645
+ var finalData = data
646
+ if !self.publicKey.isEmpty && !sessionKey.isEmpty {
647
+ let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
648
+ try finalData.write(to: tempFile)
649
+ do {
650
+ try CryptoCipher.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
651
+ } catch {
652
+ self.sendStats(action: "decrypt_fail", versionName: version)
653
+ throw error
654
+ }
655
+ finalData = try Data(contentsOf: tempFile)
656
+ try FileManager.default.removeItem(at: tempFile)
657
+ }
658
+
659
+ // Decompress Brotli if needed
660
+ if isBrotli {
661
+ guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
662
+ self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
663
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
664
+ }
665
+ finalData = decompressedData
666
+ }
667
+
668
+ // Write to destination
669
+ try finalData.write(to: destFilePath)
670
+
671
+ // Verify checksum if encryption is enabled
672
+ if !self.publicKey.isEmpty && !sessionKey.isEmpty {
673
+ let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
674
+ CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
675
+ CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
676
+ if calculatedChecksum != fileHash {
677
+ try? FileManager.default.removeItem(at: destFilePath)
678
+ self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
679
+ throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
680
+ }
681
+ }
682
+
683
+ // Save to cache
684
+ try finalData.write(to: cacheFilePath)
685
+
686
+ self.logger.info("Manifest file downloaded and cached")
687
+ self.logger.debug("Bundle: \(bundleId), File: \(fileName), Brotli: \(isBrotli), Encrypted: \(!self.publicKey.isEmpty && !sessionKey.isEmpty)")
688
+
689
+ } catch {
690
+ downloadError = error
691
+ self.logger.error("Manifest file download failed")
692
+ self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
693
+ }
694
+
695
+ case .failure(let error):
696
+ downloadError = error
697
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
698
+ self.logger.error("Manifest file download network error")
699
+ self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription), Response: \(response.debugDescription)")
700
+ }
701
+ }
702
+
703
+ semaphore.wait()
704
+
705
+ if let error = downloadError {
706
+ throw error
707
+ }
708
+ }
709
+
612
710
  /// Atomically try to copy a file from cache - returns true if successful, false if file doesn't exist or copy failed
613
711
  /// This handles the race condition where OS can delete cache files between exists() check and copy
614
712
  private func tryCopyFromCache(from source: URL, to destination: URL, expectedHash: String) -> Bool {
@@ -754,15 +852,20 @@ import UIKit
754
852
  public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
755
853
  let id: String = self.randomString(length: 10)
756
854
  let semaphore = DispatchSemaphore(value: 0)
757
- if version != getLocalUpdateVersion() {
758
- cleanDownloadData()
855
+ // Each download uses its own temp files keyed by bundle ID to prevent collisions
856
+ if version != getLocalUpdateVersion(for: id) {
857
+ cleanDownloadData(for: id)
759
858
  }
760
- ensureResumableFilesExist()
761
- saveDownloadInfo(version)
859
+ ensureResumableFilesExist(for: id)
860
+ saveDownloadInfo(version, for: id)
861
+
862
+ // Check disk space before starting download (matches Android behavior)
863
+ try checkDiskSpace()
864
+
762
865
  var checksum = ""
763
866
  var targetSize = -1
764
867
  var lastSentProgress = 0
765
- var totalReceivedBytes: Int64 = loadDownloadProgress() // Retrieving the amount of already downloaded data if exist, defined at 0 otherwise
868
+ var totalReceivedBytes: Int64 = loadDownloadProgress(for: id) // Retrieving the amount of already downloaded data if exist, defined at 0 otherwise
766
869
  let requestHeaders: HTTPHeaders = ["Range": "bytes=\(totalReceivedBytes)-"]
767
870
 
768
871
  // Send stats for zip download start
@@ -795,7 +898,7 @@ import UIKit
795
898
  if case .success(let data) = result {
796
899
  self.tempData.append(data)
797
900
 
798
- self.savePartialData(startingAt: UInt64(totalReceivedBytes)) // Saving the received data in the package.tmp file
901
+ self.savePartialData(startingAt: UInt64(totalReceivedBytes), for: id) // Saving the received data in the package_<id>.tmp file
799
902
  totalReceivedBytes += Int64(data.count)
800
903
 
801
904
  let percent = max(10, Int((Double(totalReceivedBytes) / Double(targetSize)) * 70.0))
@@ -841,15 +944,16 @@ import UIKit
841
944
  throw mainError!
842
945
  }
843
946
 
844
- let finalPath = tempDataPath.deletingLastPathComponent().appendingPathComponent("\(id)")
947
+ let tempPath = tempDataPath(for: id)
948
+ let finalPath = tempPath.deletingLastPathComponent().appendingPathComponent("\(id)")
845
949
  do {
846
- try CryptoCipher.decryptFile(filePath: tempDataPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
847
- try FileManager.default.moveItem(at: tempDataPath, to: finalPath)
950
+ try CryptoCipher.decryptFile(filePath: tempPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
951
+ try FileManager.default.moveItem(at: tempPath, to: finalPath)
848
952
  } catch {
849
953
  logger.error("Failed to decrypt file")
850
954
  logger.debug("Error: \(error)")
851
955
  self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
852
- cleanDownloadData()
956
+ cleanDownloadData(for: id)
853
957
  throw error
854
958
  }
855
959
 
@@ -872,7 +976,7 @@ import UIKit
872
976
  logger.error("Could not delete failed zip")
873
977
  logger.debug("Path: \(finalPath.path), Error: \(error)")
874
978
  }
875
- cleanDownloadData()
979
+ cleanDownloadData(for: id)
876
980
  throw error
877
981
  }
878
982
 
@@ -880,7 +984,7 @@ import UIKit
880
984
  logger.info("Downloading: 90% (wrapping up)")
881
985
  let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum, link: link, comment: comment)
882
986
  self.saveBundleInfo(id: id, bundle: info)
883
- self.cleanDownloadData()
987
+ self.cleanDownloadData(for: id)
884
988
 
885
989
  // Send stats for zip download complete
886
990
  self.sendStats(action: "download_zip_complete", versionName: version)
@@ -889,54 +993,59 @@ import UIKit
889
993
  logger.info("Downloading: 100% (complete)")
890
994
  return info
891
995
  }
892
- private func ensureResumableFilesExist() {
996
+ private func ensureResumableFilesExist(for id: String) {
893
997
  let fileManager = FileManager.default
894
- if !fileManager.fileExists(atPath: tempDataPath.path) {
895
- if !fileManager.createFile(atPath: tempDataPath.path, contents: Data()) {
998
+ let tempPath = tempDataPath(for: id)
999
+ let infoPath = updateInfoPath(for: id)
1000
+ if !fileManager.fileExists(atPath: tempPath.path) {
1001
+ if !fileManager.createFile(atPath: tempPath.path, contents: Data()) {
896
1002
  logger.error("Cannot ensure temp data file exists")
897
- logger.debug("Path: \(tempDataPath.path)")
1003
+ logger.debug("Path: \(tempPath.path)")
898
1004
  }
899
1005
  }
900
1006
 
901
- if !fileManager.fileExists(atPath: updateInfo.path) {
902
- if !fileManager.createFile(atPath: updateInfo.path, contents: Data()) {
1007
+ if !fileManager.fileExists(atPath: infoPath.path) {
1008
+ if !fileManager.createFile(atPath: infoPath.path, contents: Data()) {
903
1009
  logger.error("Cannot ensure update info file exists")
904
- logger.debug("Path: \(updateInfo.path)")
1010
+ logger.debug("Path: \(infoPath.path)")
905
1011
  }
906
1012
  }
907
1013
  }
908
1014
 
909
- private func cleanDownloadData() {
910
- // Deleting package.tmp
1015
+ private func cleanDownloadData(for id: String) {
911
1016
  let fileManager = FileManager.default
912
- if fileManager.fileExists(atPath: tempDataPath.path) {
1017
+ let tempPath = tempDataPath(for: id)
1018
+ let infoPath = updateInfoPath(for: id)
1019
+ // Deleting package_<id>.tmp
1020
+ if fileManager.fileExists(atPath: tempPath.path) {
913
1021
  do {
914
- try fileManager.removeItem(at: tempDataPath)
1022
+ try fileManager.removeItem(at: tempPath)
915
1023
  } catch {
916
1024
  logger.error("Could not delete temp data file")
917
- logger.debug("Path: \(tempDataPath), Error: \(error)")
1025
+ logger.debug("Path: \(tempPath), Error: \(error)")
918
1026
  }
919
1027
  }
920
- // Deleting update.dat
921
- if fileManager.fileExists(atPath: updateInfo.path) {
1028
+ // Deleting update_<id>.dat
1029
+ if fileManager.fileExists(atPath: infoPath.path) {
922
1030
  do {
923
- try fileManager.removeItem(at: updateInfo)
1031
+ try fileManager.removeItem(at: infoPath)
924
1032
  } catch {
925
1033
  logger.error("Could not delete update info file")
926
- logger.debug("Path: \(updateInfo), Error: \(error)")
1034
+ logger.debug("Path: \(infoPath), Error: \(error)")
927
1035
  }
928
1036
  }
929
1037
  }
930
1038
 
931
- private func savePartialData(startingAt byteOffset: UInt64) {
1039
+ private func savePartialData(startingAt byteOffset: UInt64, for id: String) {
932
1040
  let fileManager = FileManager.default
1041
+ let tempPath = tempDataPath(for: id)
933
1042
  do {
934
- // Check if package.tmp exist
935
- if !fileManager.fileExists(atPath: tempDataPath.path) {
936
- try self.tempData.write(to: tempDataPath, options: .atomicWrite)
1043
+ // Check if package_<id>.tmp exist
1044
+ if !fileManager.fileExists(atPath: tempPath.path) {
1045
+ try self.tempData.write(to: tempPath, options: .atomicWrite)
937
1046
  } else {
938
1047
  // If yes, it start writing on it
939
- let fileHandle = try FileHandle(forWritingTo: tempDataPath)
1048
+ let fileHandle = try FileHandle(forWritingTo: tempPath)
940
1049
  fileHandle.seek(toFileOffset: byteOffset) // Moving at the specified position to start writing
941
1050
  fileHandle.write(self.tempData)
942
1051
  fileHandle.closeFile()
@@ -948,29 +1057,33 @@ import UIKit
948
1057
  self.tempData.removeAll() // Clearing tempData to avoid writing the same data multiple times
949
1058
  }
950
1059
 
951
- private func saveDownloadInfo(_ version: String) {
1060
+ private func saveDownloadInfo(_ version: String, for id: String) {
1061
+ let infoPath = updateInfoPath(for: id)
952
1062
  do {
953
- try "\(version)".write(to: updateInfo, atomically: true, encoding: .utf8)
1063
+ try "\(version)".write(to: infoPath, atomically: true, encoding: .utf8)
954
1064
  } catch {
955
1065
  logger.error("Failed to save download progress")
956
1066
  logger.debug("Error: \(error)")
957
1067
  }
958
1068
  }
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) {
1069
+
1070
+ private func getLocalUpdateVersion(for id: String) -> String { // Return the version that was tried to be downloaded on last download attempt
1071
+ let infoPath = updateInfoPath(for: id)
1072
+ if !FileManager.default.fileExists(atPath: infoPath.path) {
961
1073
  return "nil"
962
1074
  }
963
- guard let versionString = try? String(contentsOf: updateInfo),
1075
+ guard let versionString = try? String(contentsOf: infoPath),
964
1076
  let version = Optional(versionString) else {
965
1077
  return "nil"
966
1078
  }
967
1079
  return version
968
1080
  }
969
- private func loadDownloadProgress() -> Int64 {
970
1081
 
1082
+ private func loadDownloadProgress(for id: String) -> Int64 {
971
1083
  let fileManager = FileManager.default
1084
+ let tempPath = tempDataPath(for: id)
972
1085
  do {
973
- let attributes = try fileManager.attributesOfItem(atPath: tempDataPath.path)
1086
+ let attributes = try fileManager.attributesOfItem(atPath: tempPath.path)
974
1087
  if let fileSize = attributes[.size] as? NSNumber {
975
1088
  return fileSize.int64Value
976
1089
  }
@@ -1174,6 +1287,44 @@ import UIKit
1174
1287
  logger.error("Failed to enumerate library directory for temp folder cleanup")
1175
1288
  logger.debug("Error: \(error.localizedDescription)")
1176
1289
  }
1290
+
1291
+ // Also cleanup old download temp files (package_*.tmp and update_*.dat)
1292
+ cleanupOldDownloadTempFiles()
1293
+ }
1294
+
1295
+ private func cleanupOldDownloadTempFiles() {
1296
+ let fileManager = FileManager.default
1297
+ guard let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
1298
+ return
1299
+ }
1300
+
1301
+ do {
1302
+ let contents = try fileManager.contentsOfDirectory(at: documentsDir, includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles])
1303
+ let oneHourAgo = Date().addingTimeInterval(-3600)
1304
+
1305
+ for url in contents {
1306
+ let fileName = url.lastPathComponent
1307
+ // Only cleanup package_*.tmp and update_*.dat files
1308
+ let isDownloadTemp = (fileName.hasPrefix("package_") && fileName.hasSuffix(".tmp")) ||
1309
+ (fileName.hasPrefix("update_") && fileName.hasSuffix(".dat"))
1310
+ if !isDownloadTemp {
1311
+ continue
1312
+ }
1313
+
1314
+ // Only delete files older than 1 hour
1315
+ if let modDate = try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate,
1316
+ modDate < oneHourAgo {
1317
+ do {
1318
+ try fileManager.removeItem(at: url)
1319
+ logger.debug("Deleted old download temp file: \(fileName)")
1320
+ } catch {
1321
+ logger.debug("Failed to delete old download temp file: \(fileName), Error: \(error.localizedDescription)")
1322
+ }
1323
+ }
1324
+ }
1325
+ } catch {
1326
+ logger.debug("Failed to enumerate documents directory for temp file cleanup: \(error.localizedDescription)")
1327
+ }
1177
1328
  }
1178
1329
 
1179
1330
  public func getBundleDirectory(id: String) -> URL {
@@ -1499,6 +1650,13 @@ import UIKit
1499
1650
 
1500
1651
  private let operationQueue = OperationQueue()
1501
1652
 
1653
+ private let manifestDownloadQueue: OperationQueue = {
1654
+ let queue = OperationQueue()
1655
+ queue.name = "com.capgo.manifestDownload"
1656
+ queue.qualityOfService = .userInitiated
1657
+ return queue
1658
+ }()
1659
+
1502
1660
  func sendStats(action: String, versionName: String? = nil, oldVersionName: String? = "") {
1503
1661
  // Check if rate limit was exceeded
1504
1662
  if CapgoUpdater.rateLimitExceeded {
@@ -278,8 +278,10 @@ public struct CryptoCipher {
278
278
  }
279
279
  }
280
280
 
281
- /// Get first 4 characters of the public key for identification
282
- /// Returns 4-character string or empty string if key is invalid/empty
281
+ /// Get first 20 characters of the public key for identification
282
+ /// Returns 20-character string or empty string if key is invalid/empty
283
+ /// The first 12 chars are always "MIIBCgKCAQEA" for RSA 2048-bit keys,
284
+ /// so the unique part starts at character 13
283
285
  public static func calcKeyId(publicKey: String) -> String {
284
286
  if publicKey.isEmpty {
285
287
  return ""
@@ -293,7 +295,7 @@ public struct CryptoCipher {
293
295
  .replacingOccurrences(of: "\r", with: "")
294
296
  .replacingOccurrences(of: " ", with: "")
295
297
 
296
- // Return first 4 characters of the base64-encoded key
297
- return String(cleanedKey.prefix(4))
298
+ // Return first 20 characters of the base64-encoded key
299
+ return String(cleanedKey.prefix(20))
298
300
  }
299
301
  }
@@ -263,6 +263,7 @@ enum CustomError: Error {
263
263
  case cannotDeleteDirectory
264
264
  case cannotDecryptSessionKey
265
265
  case invalidBase64
266
+ case insufficientDiskSpace
266
267
 
267
268
  // Throw in all other cases
268
269
  case unexpected(code: Int)
@@ -316,6 +317,53 @@ extension CustomError: LocalizedError {
316
317
  "Decrypting the base64 failed",
317
318
  comment: "Invalid checksum key"
318
319
  )
320
+ case .insufficientDiskSpace:
321
+ return NSLocalizedString(
322
+ "Insufficient disk space for download",
323
+ comment: "Not enough storage"
324
+ )
325
+ }
326
+ }
327
+ }
328
+
329
+ /// Thread-safe atomic counter for concurrent operations
330
+ final class AtomicCounter {
331
+ private var value: Int = 0
332
+ private let lock = NSLock()
333
+
334
+ func increment() -> Int {
335
+ lock.lock()
336
+ defer { lock.unlock() }
337
+ value += 1
338
+ return value
339
+ }
340
+
341
+ var current: Int {
342
+ lock.lock()
343
+ defer { lock.unlock() }
344
+ return value
345
+ }
346
+ }
347
+
348
+ /// Thread-safe atomic boolean for concurrent operations
349
+ final class AtomicBool {
350
+ private var _value: Bool
351
+ private let lock = NSLock()
352
+
353
+ init(initialValue: Bool = false) {
354
+ _value = initialValue
355
+ }
356
+
357
+ var value: Bool {
358
+ get {
359
+ lock.lock()
360
+ defer { lock.unlock() }
361
+ return _value
362
+ }
363
+ set {
364
+ lock.lock()
365
+ defer { lock.unlock() }
366
+ _value = newValue
319
367
  }
320
368
  }
321
369
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "8.40.7",
3
+ "version": "8.41.0",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",