@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.
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +1 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +6 -4
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +12 -9
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +304 -146
- package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +6 -4
- package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +48 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
|
361
|
-
* Returns
|
|
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
|
|
375
|
-
return cleanedKey.length() >=
|
|
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
|
-
|
|
392
|
-
File
|
|
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, "
|
|
549
|
-
File infoFile = new File(docDir,
|
|
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(
|
|
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.
|
|
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
|
-
|
|
421
|
-
|
|
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
|
|
425
|
-
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("
|
|
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
|
|
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
|
-
|
|
457
|
-
var
|
|
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
|
-
|
|
527
|
+
let finalFileHash = fileHash
|
|
477
528
|
let fileNameWithoutPath = (fileName as NSString).lastPathComponent
|
|
478
|
-
let cacheFileName = "\(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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):
|
|
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
|
-
|
|
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
|
-
|
|
758
|
-
|
|
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
|
|
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
|
|
947
|
+
let tempPath = tempDataPath(for: id)
|
|
948
|
+
let finalPath = tempPath.deletingLastPathComponent().appendingPathComponent("\(id)")
|
|
845
949
|
do {
|
|
846
|
-
try CryptoCipher.decryptFile(filePath:
|
|
847
|
-
try FileManager.default.moveItem(at:
|
|
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
|
-
|
|
895
|
-
|
|
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: \(
|
|
1003
|
+
logger.debug("Path: \(tempPath.path)")
|
|
898
1004
|
}
|
|
899
1005
|
}
|
|
900
1006
|
|
|
901
|
-
if !fileManager.fileExists(atPath:
|
|
902
|
-
if !fileManager.createFile(atPath:
|
|
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: \(
|
|
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
|
-
|
|
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:
|
|
1022
|
+
try fileManager.removeItem(at: tempPath)
|
|
915
1023
|
} catch {
|
|
916
1024
|
logger.error("Could not delete temp data file")
|
|
917
|
-
logger.debug("Path: \(
|
|
1025
|
+
logger.debug("Path: \(tempPath), Error: \(error)")
|
|
918
1026
|
}
|
|
919
1027
|
}
|
|
920
|
-
// Deleting
|
|
921
|
-
if fileManager.fileExists(atPath:
|
|
1028
|
+
// Deleting update_<id>.dat
|
|
1029
|
+
if fileManager.fileExists(atPath: infoPath.path) {
|
|
922
1030
|
do {
|
|
923
|
-
try fileManager.removeItem(at:
|
|
1031
|
+
try fileManager.removeItem(at: infoPath)
|
|
924
1032
|
} catch {
|
|
925
1033
|
logger.error("Could not delete update info file")
|
|
926
|
-
logger.debug("Path: \(
|
|
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
|
|
935
|
-
if !fileManager.fileExists(atPath:
|
|
936
|
-
try self.tempData.write(to:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
960
|
-
|
|
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:
|
|
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:
|
|
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
|
|
282
|
-
/// Returns
|
|
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
|
|
297
|
-
return String(cleanedKey.prefix(
|
|
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
|
}
|