@capgo/capacitor-updater 7.50.2 → 7.51.16

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.
Files changed (32) hide show
  1. package/CapgoCapacitorUpdater.podspec +0 -2
  2. package/Package.swift +4 -5
  3. package/README.md +53 -48
  4. package/android/build.gradle +6 -6
  5. package/android/src/main/java/ee/forgr/capacitor_updater/AppLifecycleObserver.java +29 -2
  6. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +7 -3
  7. package/android/src/main/java/ee/forgr/capacitor_updater/BundleStatus.java +1 -0
  8. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +427 -116
  9. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1108 -182
  10. package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +102 -31
  11. package/android/src/main/java/ee/forgr/capacitor_updater/DataManager.java +23 -7
  12. package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +2 -2
  13. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +11 -0
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +513 -201
  15. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +103 -3
  16. package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +1 -1
  17. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +115 -133
  18. package/dist/docs.json +32 -8
  19. package/dist/esm/definitions.d.ts +41 -17
  20. package/dist/esm/definitions.js.map +1 -1
  21. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +124 -0
  22. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +9 -1
  23. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +3 -0
  24. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +806 -92
  25. package/ios/Sources/CapacitorUpdaterPlugin/CapgoRawRsa.swift +36 -0
  26. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1020 -270
  27. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +49 -31
  28. package/ios/Sources/CapacitorUpdaterPlugin/RSA.swift +73 -251
  29. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +44 -20
  30. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +28 -0
  31. package/package.json +13 -8
  32. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +0 -39
@@ -22,8 +22,18 @@ import UIKit
22
22
  private let FALLBACK_VERSION: String = "pastVersion"
23
23
  private let NEXT_VERSION: String = "nextVersion"
24
24
  private let PREVIEW_FALLBACK_VERSION: String = "previewFallbackVersion"
25
+ private let PENDING_DELETE_IDS: String = "pendingDeleteIds"
25
26
  private var unzipPercent = 0
26
27
  private let TEMP_UNZIP_PREFIX: String = "capgo_unzip_"
28
+ /// HTTP + decode share one pool. Cap by CPU: 8 on 4 cores, 16 on 8 cores, 64 max.
29
+ static let manifestMaxConcurrentFiles = clampedManifestConcurrency(processorCount: ProcessInfo.processInfo.processorCount)
30
+
31
+ static func clampedManifestConcurrency(processorCount: Int) -> Int {
32
+ min(64, max(8, max(1, processorCount) * 2))
33
+ }
34
+ private static let emptySha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
35
+ private let deletePaceSeconds: TimeInterval = 0.075
36
+ private let deleteLock = NSLock()
27
37
 
28
38
  // Add this line to declare cacheFolder
29
39
  private let cacheFolder: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("capgo_downloads")
@@ -34,6 +44,8 @@ import UIKit
34
44
  public var pluginVersion: String = ""
35
45
  public var timeout: Double = 20
36
46
  public var statsUrl: String = ""
47
+ /// Optional gate run before any download touches disk (e.g. wait for launch cleanup).
48
+ public var beforeDownload: (() throws -> Void)?
37
49
  public var channelUrl: String = ""
38
50
  public var defaultChannel: String = ""
39
51
  public var appId: String = ""
@@ -44,17 +56,30 @@ import UIKit
44
56
  // Cached key ID calculated once from publicKey
45
57
  private var cachedKeyId: String?
46
58
 
47
- // Flag to track if we received a 429 response - stops requests until app restart
48
- private static var rateLimitExceeded = false
59
+ // Temporary 429 block until this epoch ms (Retry-After / rateLimitResetAt). No sticky latch.
60
+ // Guarded by rateLimitStateLock so concurrent 429s cannot shorten the window or mix metadata.
61
+ private static let rateLimitStateLock = NSLock()
62
+ private static var rateLimitBlockedUntilMs: Double = 0
63
+ private static var rateLimitBlockedError: String = "too_many_requests"
64
+ private static var rateLimitBlockedMessage: String = "Too many requests"
49
65
 
50
- // Flag to track if we've already sent the rate limit statistic - prevents infinite loop
66
+ // Flag to track if we've already sent the rate limit statistic - prevents infinite loop.
67
+ // Released again when the send fails, so a later 429 can retry it.
51
68
  private static var rateLimitStatisticSent = false
52
69
 
70
+ // Upper bound for a client-side 429 block, so a bogus Retry-After cannot block the app for days.
71
+ private static let maxRateLimitWindowMs: Double = 24 * 60 * 60 * 1000
72
+
53
73
  // Stats batching - queue events and send max once per second
54
74
  private var statsQueue: [QueuedStatsEvent] = []
75
+ private var statsInFlight: [QueuedStatsEvent] = []
55
76
  private let statsQueueLock = NSLock()
77
+ private let statsPersistLock = NSLock()
56
78
  private var statsFlushTimer: Timer?
79
+ private var statsStopped = false
57
80
  private static let statsFlushInterval: TimeInterval = 1.0
81
+ private static let maxPendingStats = 200
82
+ private let pendingStatsFileName = "capgo_pending_stats.json"
58
83
 
59
84
  private struct QueuedStatsEvent {
60
85
  let event: StatsEvent
@@ -154,6 +179,7 @@ import UIKit
154
179
  configuration.httpShouldSetCookies = false
155
180
  configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
156
181
  configuration.urlCache = nil
182
+ configuration.httpMaximumConnectionsPerHost = Self.manifestMaxConcurrentFiles
157
183
  return Session(configuration: configuration)
158
184
  }()
159
185
  private let networkResponseQueue = DispatchQueue(label: "ee.forgr.capacitor-updater.network-response", qos: .utility)
@@ -279,14 +305,71 @@ import UIKit
279
305
  return fileManager.fileExists(atPath: fallback.path) ? fallback : nil
280
306
  }
281
307
 
282
- private func storeDownloadedFile(_ downloadedFileURL: URL, at tempPath: URL, existingBytes: Int64, response: HTTPURLResponse?) throws {
308
+ static func shouldAppendHttpBody(statusCode: Int, existingBytes: Int64) -> Bool {
309
+ existingBytes > 0 && statusCode == 206
310
+ }
311
+
312
+ static func safePartialToken(_ fileName: String) -> String {
313
+ CryptoCipher.shortPathKey(fileName)
314
+ }
315
+
316
+ static func manifestPartialURL(cacheFolder: URL, hash: String, fileName: String) -> URL {
317
+ let token = safePartialToken(fileName)
318
+ if isSafeCacheHash(hash) && hash.count == 64 {
319
+ return cacheFolder.appendingPathComponent("partial_\(hash)_\(token).tmp")
320
+ }
321
+ let digest = CryptoCipher.shortPathKey("\(hash)|\(fileName)")
322
+ return cacheFolder.appendingPathComponent("partial_\(digest)_\(token).tmp")
323
+ }
324
+
325
+ private func cleanupOldManifestPartials() {
326
+ let cutoff = Date().addingTimeInterval(-3600)
327
+ guard let files = try? FileManager.default.contentsOfDirectory(
328
+ at: cacheFolder,
329
+ includingPropertiesForKeys: [.contentModificationDateKey],
330
+ options: [.skipsHiddenFiles]
331
+ ) else {
332
+ return
333
+ }
334
+ for url in files {
335
+ let name = url.lastPathComponent
336
+ guard name.hasPrefix("partial_") && name.hasSuffix(".tmp") else {
337
+ continue
338
+ }
339
+ let modified = (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
340
+ if modified < cutoff {
341
+ try? FileManager.default.removeItem(at: url)
342
+ }
343
+ }
344
+ }
345
+
346
+ func storeDownloadedFile(_ downloadedFileURL: URL, at tempPath: URL, existingBytes: Int64, response: HTTPURLResponse?) throws {
283
347
  let fileManager = FileManager.default
284
- if existingBytes > 0 && (response?.statusCode == 206 || response == nil) {
285
- let resumedData = try Data(contentsOf: downloadedFileURL)
348
+ if Self.shouldAppendHttpBody(statusCode: response?.statusCode ?? 0, existingBytes: existingBytes) ||
349
+ (existingBytes > 0 && response == nil) {
286
350
  let fileHandle = try FileHandle(forWritingTo: tempPath)
351
+ defer {
352
+ try? fileHandle.close()
353
+ }
287
354
  fileHandle.seek(toFileOffset: UInt64(existingBytes))
288
- fileHandle.write(resumedData)
289
- try fileHandle.close()
355
+ let input = try FileHandle(forReadingFrom: downloadedFileURL)
356
+ defer {
357
+ try? input.close()
358
+ }
359
+ let chunkSize = CryptoCipher.copyBufferBytes()
360
+ while true {
361
+ let done: Bool = try autoreleasepool {
362
+ let chunk = try input.read(upToCount: chunkSize) ?? Data()
363
+ if chunk.isEmpty {
364
+ return true
365
+ }
366
+ fileHandle.write(chunk)
367
+ return false
368
+ }
369
+ if done {
370
+ break
371
+ }
372
+ }
290
373
  try? fileManager.removeItem(at: downloadedFileURL)
291
374
  return
292
375
  }
@@ -318,12 +401,16 @@ import UIKit
318
401
  }
319
402
 
320
403
  deinit {
321
- // Invalidate the stats timer to prevent memory leaks
404
+ shutdown()
405
+ }
406
+
407
+ public func shutdown() {
408
+ statsPersistLock.lock()
409
+ statsStopped = true
410
+ statsPersistLock.unlock()
322
411
  statsFlushTimer?.invalidate()
323
412
  statsFlushTimer = nil
324
-
325
- // Flush any remaining stats before deallocation
326
- flushStatsQueue()
413
+ persistStatsQueue(force: true)
327
414
  }
328
415
 
329
416
  private func calcTotalPercent(percent: Int, min: Int, max: Int) -> Int {
@@ -416,26 +503,149 @@ import UIKit
416
503
  }
417
504
  }
418
505
 
506
+ private struct RemoteBlockResult {
507
+ let blocked: Bool
508
+ let error: String
509
+ let message: String
510
+ }
511
+
419
512
  /**
420
- * Check if a 429 (Too Many Requests) response was received and set the flag
513
+ * Handle HTTP 429 responses by honouring Retry-After / rateLimitResetAt.
514
+ * All 429s use the same temporary client block — no sticky latch until restart.
421
515
  */
422
- private func checkAndHandleRateLimitResponse(statusCode: Int?) -> Bool {
423
- if statusCode == 429 {
424
- // Send a statistic about the rate limit BEFORE setting the flag
425
- // Only send once to prevent infinite loop if the stat request itself gets rate limited
426
- if !previewSession && !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
427
- CapgoUpdater.rateLimitStatisticSent = true
428
-
429
- // Dispatch to background queue to avoid blocking the main thread
430
- DispatchQueue.global(qos: .utility).async {
431
- self.sendRateLimitStatistic()
432
- }
516
+ private func checkAndHandleRateLimitResponse(
517
+ statusCode: Int?,
518
+ data: Data? = nil,
519
+ response: HTTPURLResponse? = nil
520
+ ) -> RemoteBlockResult {
521
+ guard statusCode == 429 else {
522
+ return RemoteBlockResult(blocked: false, error: "", message: "")
523
+ }
524
+
525
+ let parsed = parseRemoteError(from: data)
526
+ let errorCode = parsed.error.isEmpty ? "too_many_requests" : parsed.error
527
+ let message = parsed.message.isEmpty ? "Too many requests" : parsed.message
528
+
529
+ let retryUntilMs = resolveRateLimitBlockedUntilMs(data: data, response: response)
530
+ CapgoUpdater.recordRateLimitBlock(untilMs: retryUntilMs, error: errorCode, message: message)
531
+
532
+ // Claim last, and only when there is somewhere to send it, so a 429 burst with no
533
+ // stats URL does not claim and release the latch once per response.
534
+ if errorCode == "too_many_requests" && !previewSession && !statsUrl.isEmpty && CapgoUpdater.claimRateLimitStatistic() {
535
+ DispatchQueue.global(qos: .utility).async {
536
+ self.sendRateLimitStatistic()
433
537
  }
434
- CapgoUpdater.rateLimitExceeded = true
435
- logger.warn("Rate limit exceeded (429). Stopping all stats and channel requests until app restart.")
436
- return true
437
538
  }
438
- return false
539
+
540
+ let nowMs = Date().timeIntervalSince1970 * 1000
541
+ let retryAfter = CapgoUpdater.retryAfterSecondsForLog(untilMs: retryUntilMs, nowMs: nowMs)
542
+ logger.warn("Received 429 (\(errorCode)). Honouring Retry-After: \(retryAfter)s.")
543
+ return RemoteBlockResult(blocked: true, error: errorCode, message: message)
544
+ }
545
+
546
+ /// Stores the block deadline and its metadata together, keeping the longest deadline
547
+ /// so a concurrent 429 with a shorter window cannot cut the block short.
548
+ private static func recordRateLimitBlock(untilMs: Double, error: String, message: String) {
549
+ rateLimitStateLock.lock()
550
+ defer { rateLimitStateLock.unlock() }
551
+ if untilMs > rateLimitBlockedUntilMs {
552
+ rateLimitBlockedUntilMs = untilMs
553
+ rateLimitBlockedError = error
554
+ rateLimitBlockedMessage = message
555
+ } else if rateLimitBlockedUntilMs <= 0 {
556
+ rateLimitBlockedError = error
557
+ rateLimitBlockedMessage = message
558
+ }
559
+ }
560
+
561
+ /// Seconds left in the block, clamped and finite so the Int conversion can never trap.
562
+ private static func retryAfterSecondsForLog(untilMs: Double, nowMs: Double) -> Int {
563
+ let seconds = ((untilMs - nowMs) / 1000).rounded(.up)
564
+ guard seconds.isFinite, seconds > 0 else {
565
+ return 0
566
+ }
567
+ return Int(min(seconds, maxRateLimitWindowMs / 1000))
568
+ }
569
+
570
+ /// Returns true for the first 429 only, so the rate-limit statistic is sent once.
571
+ private static func claimRateLimitStatistic() -> Bool {
572
+ rateLimitStateLock.lock()
573
+ defer { rateLimitStateLock.unlock() }
574
+ if rateLimitStatisticSent {
575
+ return false
576
+ }
577
+ rateLimitStatisticSent = true
578
+ return true
579
+ }
580
+
581
+ /// Gives the claim back when the statistic never made it out, so a later 429 can retry it.
582
+ private static func releaseRateLimitStatisticClaim() {
583
+ rateLimitStateLock.lock()
584
+ defer { rateLimitStateLock.unlock() }
585
+ rateLimitStatisticSent = false
586
+ }
587
+
588
+ private func parseRemoteError(from data: Data?) -> (error: String, message: String) {
589
+ guard let data = data,
590
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
591
+ return ("", "")
592
+ }
593
+ let error = json["error"] as? String ?? ""
594
+ let message = json["message"] as? String ?? ""
595
+ return (error, message)
596
+ }
597
+
598
+ private func resolveRateLimitBlockedUntilMs(data: Data?, response: HTTPURLResponse?) -> Double {
599
+ let nowMs = Date().timeIntervalSince1970 * 1000
600
+ let candidate = rawRateLimitDeadlineMs(data: data, response: response, nowMs: nowMs)
601
+ // NaN and past deadlines mean "no client-side block"; anything further out is capped.
602
+ guard candidate > nowMs else {
603
+ return 0
604
+ }
605
+ return min(candidate, nowMs + CapgoUpdater.maxRateLimitWindowMs)
606
+ }
607
+
608
+ private func rawRateLimitDeadlineMs(data: Data?, response: HTTPURLResponse?, nowMs: Double) -> Double {
609
+ if let header = response?.value(forHTTPHeaderField: "Retry-After")?.trimmingCharacters(in: .whitespacesAndNewlines),
610
+ let seconds = Double(header), seconds >= 0 {
611
+ return nowMs + seconds * 1000
612
+ }
613
+
614
+ if let data = data,
615
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
616
+ let moreInfo = json["moreInfo"] as? [String: Any]
617
+ if let retryAfter = (moreInfo?["retryAfterSeconds"] as? NSNumber)?.doubleValue
618
+ ?? (json["retryAfterSeconds"] as? NSNumber)?.doubleValue,
619
+ retryAfter >= 0 {
620
+ return nowMs + retryAfter * 1000
621
+ }
622
+ if let resetAt = (moreInfo?["rateLimitResetAt"] as? NSNumber)?.doubleValue
623
+ ?? (json["rateLimitResetAt"] as? NSNumber)?.doubleValue {
624
+ return resetAt
625
+ }
626
+ }
627
+
628
+ // No retry hint — do not hold a client-side block; allow immediate retry to the worker
629
+ return 0
630
+ }
631
+
632
+ private func isRemoteBlocked() -> Bool {
633
+ CapgoUpdater.rateLimitStateLock.lock()
634
+ defer { CapgoUpdater.rateLimitStateLock.unlock() }
635
+ if CapgoUpdater.rateLimitBlockedUntilMs <= 0 {
636
+ return false
637
+ }
638
+ if Date().timeIntervalSince1970 * 1000 >= CapgoUpdater.rateLimitBlockedUntilMs {
639
+ CapgoUpdater.rateLimitBlockedUntilMs = 0
640
+ return false
641
+ }
642
+ return true
643
+ }
644
+
645
+ private func remoteBlockedClientError() -> (error: String, message: String) {
646
+ CapgoUpdater.rateLimitStateLock.lock()
647
+ defer { CapgoUpdater.rateLimitStateLock.unlock() }
648
+ return (CapgoUpdater.rateLimitBlockedError, CapgoUpdater.rateLimitBlockedMessage)
439
649
  }
440
650
 
441
651
  /**
@@ -445,6 +655,8 @@ import UIKit
445
655
  */
446
656
  private func sendRateLimitStatistic() {
447
657
  guard !statsUrl.isEmpty else {
658
+ // The URL was cleared after the claim was taken; nothing went out, so hand it back.
659
+ CapgoUpdater.releaseRateLimitStatisticClaim()
448
660
  return
449
661
  }
450
662
 
@@ -463,10 +675,16 @@ import UIKit
463
675
  encoding: JSONEncoding.default,
464
676
  requestModifier: { $0.timeoutInterval = self.timeout }
465
677
  ).responseData { response in
678
+ let statusCode = response.response?.statusCode
466
679
  switch response.result {
467
- case .success:
680
+ case .success where (200...299).contains(statusCode ?? 0):
468
681
  self.logger.info("Rate limit statistic sent")
682
+ case .success:
683
+ CapgoUpdater.releaseRateLimitStatisticClaim()
684
+ self.logger.error("Error sending rate limit statistic")
685
+ self.logger.debug("Response code: \(statusCode.map(String.init) ?? "nil")")
469
686
  case let .failure(error):
687
+ CapgoUpdater.releaseRateLimitStatisticClaim()
470
688
  self.logger.error("Error sending rate limit statistic")
471
689
  self.logger.debug("Error: \(error.localizedDescription)")
472
690
  }
@@ -563,7 +781,7 @@ import UIKit
563
781
  }
564
782
  }
565
783
 
566
- private func extractZipEntry(_ archive: Archive, entry: Entry, to destPath: URL) throws {
784
+ private func extractZipEntry(_ archive: Archive, entry: Entry, to destPath: URL, bufferSize: Int = CryptoCipher.ioBufferBytes()) throws {
567
785
  let fileManager = FileManager.default
568
786
 
569
787
  switch entry.type {
@@ -586,14 +804,14 @@ import UIKit
586
804
  fileHandle.closeFile()
587
805
  }
588
806
 
589
- _ = try archive.extract(entry, bufferSize: 16 * 1024, skipCRC32: true) { data in
807
+ _ = try archive.extract(entry, bufferSize: bufferSize, skipCRC32: true) { data in
590
808
  if !data.isEmpty {
591
809
  fileHandle.write(data)
592
810
  }
593
811
  }
594
812
  case .symlink:
595
813
  var linkData = Data()
596
- _ = try archive.extract(entry, bufferSize: 16 * 1024, skipCRC32: true) { data in
814
+ _ = try archive.extract(entry, bufferSize: bufferSize, skipCRC32: true) { data in
597
815
  linkData.append(data)
598
816
  }
599
817
 
@@ -622,7 +840,7 @@ import UIKit
622
840
  }
623
841
  }
624
842
 
625
- private func saveDownloaded(sourceZip: URL, id: String, base: URL, notify: Bool) throws {
843
+ func saveDownloaded(sourceZip: URL, id: String, base: URL, notify: Bool, bufferSize: Int = CryptoCipher.ioBufferBytes()) throws {
626
844
  try prepareFolder(source: base)
627
845
  let destPersist: URL = base.appendingPathComponent(id)
628
846
  let destUnZip: URL = libraryDir.appendingPathComponent(TEMP_UNZIP_PREFIX + randomString(length: 10))
@@ -669,7 +887,7 @@ import UIKit
669
887
  try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)
670
888
  }
671
889
 
672
- try self.extractZipEntry(archive, entry: entry, to: destPath)
890
+ try self.extractZipEntry(archive, entry: entry, to: destPath, bufferSize: bufferSize)
673
891
 
674
892
  // Update progress
675
893
  processedEntries += 1
@@ -702,13 +920,39 @@ import UIKit
702
920
  }
703
921
  }
704
922
 
705
- private func populateDeltaCacheAsync(for id: String) {
923
+ private func populateDeltaCacheAsync(for id: String, manifest: [ManifestEntry]? = nil, sessionKey: String = "") {
706
924
  DispatchQueue.global(qos: .utility).async { [weak self] in
707
- self?.populateDeltaCache(for: id)
925
+ self?.populateDeltaCache(for: id, manifest: manifest, sessionKey: sessionKey)
926
+ }
927
+ }
928
+
929
+ struct ManifestLookupEntry {
930
+ let hash: String
931
+ /// The manifest's own file name, `.br` suffix included when present.
932
+ let originalFileName: String
933
+ }
934
+
935
+ /// Keys are stripped of the `.br` suffix so they match the extracted file names on
936
+ /// disk, not the manifest's (possibly brotli-compressed) original file names.
937
+ func manifestHashLookup(manifest: [ManifestEntry]?, sessionKey: String) -> [String: ManifestLookupEntry] {
938
+ guard let manifest else {
939
+ return [:]
940
+ }
941
+ var lookup: [String: ManifestLookupEntry] = [:]
942
+ for entry in manifest {
943
+ guard let fileName = entry.file_name,
944
+ let hash = resolveManifestFileHash(entry: entry, sessionKey: sessionKey) else {
945
+ continue
946
+ }
947
+ let destFileName = fileName.hasSuffix(".br") ? String(fileName.dropLast(3)) : fileName
948
+ lookup[destFileName] = ManifestLookupEntry(hash: hash, originalFileName: fileName)
708
949
  }
950
+ return lookup
709
951
  }
710
952
 
711
- private func populateDeltaCache(for id: String) {
953
+ /// `manifest` must only contain entries the caller already checksum-verified
954
+ /// (as `downloadManifest` does) — the hashes are trusted as-is, not re-checked.
955
+ func populateDeltaCache(for id: String, manifest: [ManifestEntry]? = nil, sessionKey: String = "") {
712
956
  let bundleDir = self.getBundleDirectory(id: id)
713
957
  let fileManager = FileManager.default
714
958
 
@@ -728,24 +972,44 @@ import UIKit
728
972
  return
729
973
  }
730
974
 
975
+ let knownEntries = manifestHashLookup(manifest: manifest, sessionKey: sessionKey)
976
+ let builtinFolder = self.builtinFolderURL()
977
+
731
978
  for case let fileURL as URL in enumerator {
732
979
  let resourceValues = try? fileURL.resourceValues(forKeys: [.isDirectoryKey])
733
980
  if resourceValues?.isDirectory == true {
734
981
  continue
735
982
  }
736
983
 
737
- let checksum = CryptoCipher.calcChecksum(filePath: fileURL)
984
+ let relativePath = String(fileURL.path.dropFirst(bundleDir.path.count + 1))
985
+ let knownEntry = knownEntries[relativePath]
986
+
987
+ let checksum = knownEntry?.hash ?? CryptoCipher.calcChecksum(filePath: fileURL)
738
988
  if checksum.isEmpty {
739
989
  continue
740
990
  }
741
991
 
992
+ // Builtin is already a permanent reuse source (see isManifestEntryAvailableLocally),
993
+ // so there's no need to also duplicate this file into the delta cache
994
+ let builtinLookupName = knownEntry?.originalFileName ?? relativePath
995
+ let isBuiltinOrigin: Bool
996
+ if let builtinFilePath = try? Self.resolveManifestTargetPath(baseDirectory: builtinFolder, fileName: builtinLookupName) {
997
+ isBuiltinOrigin = fileManager.fileExists(atPath: builtinFilePath.path) &&
998
+ verifyChecksum(file: builtinFilePath, expectedHash: checksum)
999
+ } else {
1000
+ isBuiltinOrigin = false
1001
+ }
1002
+ if isBuiltinOrigin {
1003
+ continue
1004
+ }
1005
+
742
1006
  let cacheFile = cacheFolder.appendingPathComponent("\(checksum)_\(fileURL.lastPathComponent)")
743
1007
  if fileManager.fileExists(atPath: cacheFile.path) {
744
1008
  continue
745
1009
  }
746
1010
 
747
1011
  do {
748
- try fileManager.copyItem(at: fileURL, to: cacheFile)
1012
+ try copyItemAtomically(from: fileURL, to: cacheFile)
749
1013
  } catch {
750
1014
  logger.debug("Delta cache copy failed: \(fileURL.path)")
751
1015
  }
@@ -775,6 +1039,14 @@ import UIKit
775
1039
 
776
1040
  public func getLatest(url: URL, channel: String?, appIdOverride: String? = nil) -> AppVersion {
777
1041
  let latest: AppVersion = AppVersion()
1042
+ if isRemoteBlocked() {
1043
+ let blocked = remoteBlockedClientError()
1044
+ logger.debug("Skipping getLatest due to remote block (\(blocked.error)).")
1045
+ latest.message = blocked.message
1046
+ latest.error = blocked.error
1047
+ latest.kind = "failed"
1048
+ return latest
1049
+ }
778
1050
  func applyLatestResponse(_ value: AppVersionDec?) {
779
1051
  if let url = value?.url {
780
1052
  latest.url = url
@@ -855,9 +1127,14 @@ import UIKit
855
1127
  return latest
856
1128
  }
857
1129
 
858
- if self.checkAndHandleRateLimitResponse(statusCode: latest.statusCode) {
859
- latest.message = "Rate limit exceeded"
860
- latest.error = "rate_limit_exceeded"
1130
+ let rateLimit = self.checkAndHandleRateLimitResponse(
1131
+ statusCode: latest.statusCode,
1132
+ data: data,
1133
+ response: result.response
1134
+ )
1135
+ if rateLimit.blocked {
1136
+ latest.message = rateLimit.message
1137
+ latest.error = rateLimit.error
861
1138
  latest.kind = "failed"
862
1139
  return latest
863
1140
  }
@@ -926,6 +1203,12 @@ import UIKit
926
1203
  return actualHash == expectedHash
927
1204
  }
928
1205
 
1206
+ /// Overridable so tests can point it at a writable directory instead of the
1207
+ /// real (read-only) app bundle.
1208
+ func builtinFolderURL() -> URL {
1209
+ Bundle.main.bundleURL.appendingPathComponent("public")
1210
+ }
1211
+
929
1212
  private func resolveManifestFileHash(entry: ManifestEntry, sessionKey: String) -> String? {
930
1213
  guard var fileHash = entry.file_hash, !fileHash.isEmpty else {
931
1214
  return nil
@@ -948,8 +1231,11 @@ import UIKit
948
1231
  return false
949
1232
  }
950
1233
 
951
- let builtinFolder = Bundle.main.bundleURL.appendingPathComponent("public")
952
- let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
1234
+ let builtinFolder = self.builtinFolderURL()
1235
+ // The .br suffix describes transport; builtin assets are uncompressed.
1236
+ guard let builtinFilePath = try? Self.resolveManifestTargetPath(baseDirectory: builtinFolder, fileName: fileName) else {
1237
+ return false
1238
+ }
953
1239
  if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
954
1240
  return true
955
1241
  }
@@ -957,21 +1243,52 @@ import UIKit
957
1243
  let fileNameWithoutPath = (fileName as NSString).lastPathComponent
958
1244
  let isBrotli = fileName.hasSuffix(".br")
959
1245
  let cacheBaseName = isBrotli ? String(fileNameWithoutPath.dropLast(3)) : fileNameWithoutPath
960
- let cacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(cacheBaseName)")
961
- if FileManager.default.fileExists(atPath: cacheFilePath.path) && verifyChecksum(file: cacheFilePath, expectedHash: fileHash) {
962
- return true
963
- }
964
-
965
- if isBrotli {
966
- let legacyCacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(fileNameWithoutPath)")
967
- if FileManager.default.fileExists(atPath: legacyCacheFilePath.path) && verifyChecksum(file: legacyCacheFilePath, expectedHash: fileHash) {
1246
+ if Self.isSafeCacheHash(fileHash) {
1247
+ let cacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(cacheBaseName)")
1248
+ // Cache files are named `{hash}_{filename}` and were checksum-verified
1249
+ // when written. Re-hashing every hit re-reads the whole bundle and
1250
+ // OOMs/janks low-RAM devices during getMissing / delta apply.
1251
+ if isReusableCacheFile(cacheFilePath, expectedHash: fileHash) {
968
1252
  return true
969
1253
  }
1254
+
1255
+ if isBrotli {
1256
+ let legacyCacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(fileNameWithoutPath)")
1257
+ if isReusableCacheFile(legacyCacheFilePath, expectedHash: fileHash) {
1258
+ return true
1259
+ }
1260
+ }
970
1261
  }
971
1262
 
972
1263
  return false
973
1264
  }
974
1265
 
1266
+ /// SHA-256 hash-named cache files were verified when written. Existence is
1267
+ /// enough for non-empty files; empty files are reused only for the empty SHA-256.
1268
+ /// CRC32 (8 hex) is too collision-prone to trust without a re-read.
1269
+ private func isReusableCacheFile(_ url: URL, expectedHash: String) -> Bool {
1270
+ guard Self.isSafeCacheHash(expectedHash), expectedHash.count == 64 else {
1271
+ return false
1272
+ }
1273
+ let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? -1
1274
+ if size > 0 {
1275
+ return true
1276
+ }
1277
+ return size == 0 && expectedHash.lowercased() == Self.emptySha256
1278
+ }
1279
+
1280
+ static func isSafeCacheHash(_ hash: String) -> Bool {
1281
+ let count = hash.count
1282
+ guard count == 64 || count == 8 else {
1283
+ return false
1284
+ }
1285
+ return hash.unicodeScalars.allSatisfy { scalar in
1286
+ (0x30...0x39).contains(scalar.value) ||
1287
+ (0x41...0x46).contains(scalar.value) ||
1288
+ (0x61...0x66).contains(scalar.value)
1289
+ }
1290
+ }
1291
+
975
1292
  public func getMissingBundleFiles(manifest: [ManifestEntry], sessionKey: String) -> [ManifestEntry] {
976
1293
  return manifest.filter { entry in
977
1294
  !isManifestEntryAvailableLocally(entry: entry, sessionKey: sessionKey)
@@ -1052,17 +1369,23 @@ import UIKit
1052
1369
  return json
1053
1370
  }
1054
1371
 
1372
+ private func runBeforeDownload() throws {
1373
+ try beforeDownload?()
1374
+ }
1375
+
1055
1376
  public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
1377
+ try self.runBeforeDownload()
1056
1378
  let id = self.randomString(length: 10)
1057
1379
  logger.info("downloadManifest start \(id)")
1058
1380
  let destFolder = self.getBundleDirectory(id: id)
1059
- let builtinFolder = Bundle.main.bundleURL.appendingPathComponent("public")
1381
+ let builtinFolder = self.builtinFolderURL()
1060
1382
 
1061
1383
  // Check disk space before starting manifest download (estimate 100KB per file, minimum 50MB)
1062
1384
  let estimatedSize = Int64(max(manifest.count * 100 * 1024, 50 * 1024 * 1024))
1063
1385
  try checkDiskSpace(estimatedSize: estimatedSize)
1064
1386
 
1065
1387
  try FileManager.default.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
1388
+ cleanupOldManifestPartials()
1066
1389
  try FileManager.default.createDirectory(at: destFolder, withIntermediateDirectories: true, attributes: nil)
1067
1390
 
1068
1391
  // Create and save BundleInfo before starting the download process
@@ -1077,9 +1400,6 @@ import UIKit
1077
1400
 
1078
1401
  let totalFiles = manifest.count
1079
1402
 
1080
- // Keep this bounded because each manifest operation waits on a URLSession callback.
1081
- manifestDownloadQueue.maxConcurrentOperationCount = min(8, max(1, totalFiles))
1082
-
1083
1403
  // Thread-safe counters for concurrent operations
1084
1404
  let completedFiles = AtomicCounter()
1085
1405
  let hasError = AtomicBool(initialValue: false)
@@ -1146,15 +1466,19 @@ import UIKit
1146
1466
  let fileNameWithoutPath = (fileName as NSString).lastPathComponent
1147
1467
  let isBrotli = fileName.hasSuffix(".br")
1148
1468
  let cacheBaseName = isBrotli ? String(fileNameWithoutPath.dropLast(3)) : fileNameWithoutPath
1149
- let cacheFilePath = cacheFolder.appendingPathComponent("\(finalFileHash)_\(cacheBaseName)")
1150
- let legacyCacheFilePath: URL? = isBrotli ? cacheFolder.appendingPathComponent("\(finalFileHash)_\(fileNameWithoutPath)") : nil
1469
+ let cacheFilePath: URL? = Self.isSafeCacheHash(finalFileHash)
1470
+ ? cacheFolder.appendingPathComponent("\(finalFileHash)_\(cacheBaseName)")
1471
+ : nil
1472
+ let legacyCacheFilePath: URL? = isBrotli && cacheFilePath != nil
1473
+ ? cacheFolder.appendingPathComponent("\(finalFileHash)_\(fileNameWithoutPath)")
1474
+ : nil
1151
1475
 
1152
1476
  let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
1153
1477
  let destFilePath: URL
1154
1478
  let builtinFilePath: URL
1155
1479
  do {
1156
1480
  destFilePath = try Self.resolveManifestTargetPath(baseDirectory: destFolder, fileName: fileName)
1157
- builtinFilePath = try Self.resolvePathInsideDirectory(baseDirectory: builtinFolder, relativePath: fileName)
1481
+ builtinFilePath = try Self.resolveManifestTargetPath(baseDirectory: builtinFolder, fileName: fileName)
1158
1482
  } catch {
1159
1483
  logger.error("Invalid manifest file path: \(fileName)")
1160
1484
  self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
@@ -1182,7 +1506,7 @@ import UIKit
1182
1506
  }
1183
1507
  // Try cache
1184
1508
  else if
1185
- self.tryCopyFromCache(from: cacheFilePath, to: destFilePath, expectedHash: finalFileHash) ||
1509
+ (cacheFilePath != nil && self.tryCopyFromCache(from: cacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) ||
1186
1510
  (legacyCacheFilePath != nil && self.tryCopyFromCache(from: legacyCacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) {
1187
1511
  self.logger.info("downloadManifest \(fileName) copy from cache \(id)")
1188
1512
  }
@@ -1253,7 +1577,7 @@ import UIKit
1253
1577
  private func downloadManifestFile(
1254
1578
  downloadUrl: String,
1255
1579
  destFilePath: URL,
1256
- cacheFilePath: URL,
1580
+ cacheFilePath: URL?,
1257
1581
  fileHash: String,
1258
1582
  fileName: String,
1259
1583
  destFileName: String,
@@ -1270,7 +1594,7 @@ import UIKit
1270
1594
  )
1271
1595
  }
1272
1596
 
1273
- guard let request = createRequest(url: url, method: "GET") else {
1597
+ guard var request = createRequest(url: url, method: "GET") else {
1274
1598
  throw NSError(
1275
1599
  domain: "ManifestDownloadError",
1276
1600
  code: 2,
@@ -1278,9 +1602,27 @@ import UIKit
1278
1602
  )
1279
1603
  }
1280
1604
 
1281
- let result = performRequest(request, label: "downloadManifestFile \(fileName)")
1605
+ try FileManager.default.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
1606
+ let partialURL = Self.manifestPartialURL(cacheFolder: cacheFolder, hash: fileHash, fileName: fileName)
1607
+ let existingBytes: Int64
1608
+ if FileManager.default.fileExists(atPath: partialURL.path) {
1609
+ existingBytes = Int64((try FileManager.default.attributesOfItem(atPath: partialURL.path)[.size] as? NSNumber)?.int64Value ?? 0)
1610
+ } else {
1611
+ existingBytes = 0
1612
+ }
1613
+ if existingBytes > 0 {
1614
+ request.setValue("bytes=\(existingBytes)-", forHTTPHeaderField: "Range")
1615
+ }
1616
+
1617
+ let result = performDownloadRequest(request, label: "downloadManifestFile \(fileName)")
1618
+ defer {
1619
+ if let fileURL = result.fileURL {
1620
+ try? FileManager.default.removeItem(at: fileURL)
1621
+ }
1622
+ }
1282
1623
 
1283
1624
  if result.timedOut {
1625
+ persistPartialDownload(result, id: bundleId, tempPath: partialURL, existingBytes: existingBytes)
1284
1626
  self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1285
1627
  throw NSError(
1286
1628
  domain: NSURLErrorDomain,
@@ -1290,13 +1632,32 @@ import UIKit
1290
1632
  }
1291
1633
 
1292
1634
  if let error = result.error {
1635
+ persistPartialDownload(result, id: bundleId, tempPath: partialURL, existingBytes: existingBytes)
1293
1636
  self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1294
1637
  self.logger.error("Manifest file download network error")
1295
1638
  self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
1296
1639
  throw error
1297
1640
  }
1298
1641
 
1299
- guard let data = result.data else {
1642
+ let statusCode = result.response?.statusCode ?? 200
1643
+ if statusCode == 416 && existingBytes > 0 {
1644
+ logger.debug("Range not satisfiable, using existing partial \(partialURL.lastPathComponent)")
1645
+ } else if statusCode < 200 || statusCode >= 300 {
1646
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1647
+ throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
1648
+ } else {
1649
+ guard let downloadedFileURL = result.fileURL, FileManager.default.fileExists(atPath: downloadedFileURL.path) else {
1650
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1651
+ throw NSError(
1652
+ domain: "ManifestDownloadError",
1653
+ code: 3,
1654
+ userInfo: [NSLocalizedDescriptionKey: "Manifest file response was empty for \(fileName) at url \(downloadUrl)"]
1655
+ )
1656
+ }
1657
+ try storeDownloadedFile(downloadedFileURL, at: partialURL, existingBytes: existingBytes, response: result.response)
1658
+ }
1659
+
1660
+ guard FileManager.default.fileExists(atPath: partialURL.path) else {
1300
1661
  self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1301
1662
  throw NSError(
1302
1663
  domain: "ManifestDownloadError",
@@ -1305,56 +1666,60 @@ import UIKit
1305
1666
  )
1306
1667
  }
1307
1668
 
1308
- let statusCode = result.response?.statusCode ?? 200
1309
- if statusCode < 200 || statusCode >= 300 {
1310
- self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1311
- if let stringData = String(data: data, encoding: .utf8) {
1312
- throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid. Data: \(stringData) for file \(fileName) at url \(downloadUrl)"])
1313
- } else {
1314
- throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
1669
+ var workURL: URL?
1670
+ defer {
1671
+ if let workURL {
1672
+ try? FileManager.default.removeItem(at: workURL)
1315
1673
  }
1316
1674
  }
1317
1675
 
1318
1676
  do {
1319
- // Add decryption step if public key is set and sessionKey is provided
1320
- var finalData = data
1677
+ var source = partialURL
1321
1678
  if !self.publicKey.isEmpty && !sessionKey.isEmpty {
1322
- let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
1323
- try finalData.write(to: tempFile)
1679
+ let work = cacheFolder.appendingPathComponent("work_\(UUID().uuidString)_\((fileName as NSString).lastPathComponent)")
1680
+ try FileManager.default.copyItem(at: partialURL, to: work)
1681
+ workURL = work
1324
1682
  do {
1325
- try CryptoCipher.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
1683
+ try CryptoCipher.decryptFile(filePath: work, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
1326
1684
  } catch {
1685
+ try? FileManager.default.removeItem(at: partialURL)
1327
1686
  self.sendStats(action: "decrypt_fail", versionName: version)
1328
1687
  throw error
1329
1688
  }
1330
- finalData = try Data(contentsOf: tempFile)
1331
- try FileManager.default.removeItem(at: tempFile)
1689
+ source = work
1332
1690
  }
1333
1691
 
1334
- // Decompress Brotli if needed
1692
+ let calculatedChecksum: String
1335
1693
  if isBrotli {
1336
- guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
1694
+ do {
1695
+ calculatedChecksum = try decompressBrotli(from: source, to: destFilePath, fileName: fileName)
1696
+ } catch {
1697
+ try? FileManager.default.removeItem(at: partialURL)
1337
1698
  self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
1338
- throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
1699
+ throw error
1700
+ }
1701
+ } else {
1702
+ let handle = try FileHandle(forReadingFrom: source)
1703
+ defer {
1704
+ try? handle.close()
1339
1705
  }
1340
- finalData = decompressedData
1706
+ let length = (try FileManager.default.attributesOfItem(atPath: source.path)[.size] as? NSNumber)?.uint64Value ?? 0
1707
+ calculatedChecksum = try streamCopy(from: handle, count: length, to: destFilePath)
1341
1708
  }
1342
1709
 
1343
- // Write to destination (replace if leftover from a previous failed download)
1344
- try writeDataAtomically(finalData, to: destFilePath)
1345
-
1346
- // Always verify checksum when file_hash is present
1347
- let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
1348
1710
  CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
1349
1711
  CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
1350
1712
  if calculatedChecksum != fileHash {
1351
1713
  try? FileManager.default.removeItem(at: destFilePath)
1714
+ try? FileManager.default.removeItem(at: partialURL)
1352
1715
  self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
1353
1716
  throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
1354
1717
  }
1355
1718
 
1356
- // Save to cache (replace stale cache entries from partial or concurrent downloads)
1357
- try writeDataAtomically(finalData, to: cacheFilePath)
1719
+ if let cacheFilePath {
1720
+ try copyItemAtomically(from: destFilePath, to: cacheFilePath)
1721
+ }
1722
+ try? FileManager.default.removeItem(at: partialURL)
1358
1723
 
1359
1724
  self.logger.info("Manifest file downloaded and cached")
1360
1725
  self.logger.debug("Bundle: \(bundleId), File: \(fileName), Brotli: \(isBrotli), Encrypted: \(!self.publicKey.isEmpty && !sessionKey.isEmpty)")
@@ -1365,49 +1730,87 @@ import UIKit
1365
1730
  }
1366
1731
  }
1367
1732
 
1368
- /// Atomically write data to a file, replacing any existing file at the destination.
1369
- private func writeDataAtomically(_ data: Data, to destination: URL) throws {
1733
+ /// Copy a file to the destination, replacing any existing file.
1734
+ private func copyItemReplacing(from source: URL, to destination: URL) throws {
1370
1735
  let fileManager = FileManager.default
1736
+ try fileManager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
1737
+ if fileManager.fileExists(atPath: destination.path) {
1738
+ try fileManager.removeItem(at: destination)
1739
+ }
1740
+ try fileManager.copyItem(at: source, to: destination)
1741
+ }
1742
+
1743
+ func copyMatchingBuiltinFilesForTests(files: [(source: URL, dest: URL, hash: String)]) throws {
1744
+ let queue = OperationQueue()
1745
+ queue.maxConcurrentOperationCount = CapgoUpdater.manifestMaxConcurrentFiles
1746
+ let lock = NSLock()
1747
+ var firstError: Error?
1748
+ for file in files {
1749
+ queue.addOperation { [weak self] in
1750
+ guard let self else { return }
1751
+ do {
1752
+ guard self.verifyChecksum(file: file.source, expectedHash: file.hash) else {
1753
+ throw NSError(
1754
+ domain: "CapgoInstallPerf",
1755
+ code: 1,
1756
+ userInfo: [NSLocalizedDescriptionKey: "builtin checksum mismatch"]
1757
+ )
1758
+ }
1759
+ try self.copyItemReplacing(from: file.source, to: file.dest)
1760
+ } catch {
1761
+ lock.lock()
1762
+ if firstError == nil {
1763
+ firstError = error
1764
+ }
1765
+ lock.unlock()
1766
+ }
1767
+ }
1768
+ }
1769
+ queue.waitUntilAllOperationsAreFinished()
1770
+ if let firstError {
1771
+ throw firstError
1772
+ }
1773
+ }
1774
+
1775
+ /// Copy via a unique temp name then rename, so a crash cannot leave a
1776
+ /// non-empty partial file that `isReusableCacheFile` would trust.
1777
+ private func copyItemAtomically(from source: URL, to destination: URL) throws {
1778
+ let fileManager = FileManager.default
1779
+ try fileManager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
1371
1780
  let tempURL = destination.deletingLastPathComponent().appendingPathComponent("\(destination.lastPathComponent).\(UUID().uuidString).tmp")
1372
1781
  defer {
1373
1782
  try? fileManager.removeItem(at: tempURL)
1374
1783
  }
1375
-
1376
- try data.write(to: tempURL, options: .atomic)
1377
- if fileManager.fileExists(atPath: destination.path) {
1378
- try fileManager.removeItem(at: destination)
1379
- }
1380
- try fileManager.moveItem(at: tempURL, to: destination)
1784
+ try fileManager.copyItem(at: source, to: tempURL)
1785
+ try replaceItemAtomically(at: destination, withItemAt: tempURL)
1381
1786
  }
1382
1787
 
1383
- /// Copy a file to the destination, replacing any existing file.
1384
- private func copyItemReplacing(from source: URL, to destination: URL) throws {
1788
+ /// One-step replace when dest exists, move when it does not. Avoids the
1789
+ /// fileExists/removeItem race that can fail a verified install.
1790
+ private func replaceItemAtomically(at destination: URL, withItemAt tempURL: URL) throws {
1385
1791
  let fileManager = FileManager.default
1386
- if fileManager.fileExists(atPath: destination.path) {
1387
- try fileManager.removeItem(at: destination)
1792
+ do {
1793
+ _ = try fileManager.replaceItemAt(destination, withItemAt: tempURL)
1794
+ } catch {
1795
+ if fileManager.fileExists(atPath: destination.path) {
1796
+ throw error
1797
+ }
1798
+ try fileManager.moveItem(at: tempURL, to: destination)
1388
1799
  }
1389
- try fileManager.copyItem(at: source, to: destination)
1390
1800
  }
1391
1801
 
1392
1802
  /// Atomically try to copy a file from cache - returns true if successful, false if file doesn't exist or copy failed
1393
1803
  /// This handles the race condition where OS can delete cache files between exists() check and copy
1394
1804
  private func tryCopyFromCache(from source: URL, to destination: URL, expectedHash: String) -> Bool {
1395
- let fileManager = FileManager.default
1396
-
1397
- // First quick check - if file doesn't exist, don't bother
1398
- guard fileManager.fileExists(atPath: source.path) else {
1399
- return false
1400
- }
1401
-
1402
- // Verify checksum before copy; remove stale cache entries that would block re-download
1403
- guard verifyChecksum(file: source, expectedHash: expectedHash) else {
1404
- try? fileManager.removeItem(at: source)
1805
+ // First quick check - if file doesn't exist or was truncated, don't bother
1806
+ guard isReusableCacheFile(source, expectedHash: expectedHash) else {
1405
1807
  return false
1406
1808
  }
1407
1809
 
1408
- // Try to copy - if it fails (file deleted by OS between check and copy), return false
1810
+ // Hash is in the cache file name and was verified when written.
1811
+ // Re-hashing here would re-read every reused file on low-RAM devices.
1409
1812
  do {
1410
- try copyItemReplacing(from: source, to: destination)
1813
+ try copyItemAtomically(from: source, to: destination)
1411
1814
  return true
1412
1815
  } catch {
1413
1816
  // File was deleted between check and copy, or other IO error - caller should download instead
@@ -1416,125 +1819,165 @@ import UIKit
1416
1819
  }
1417
1820
  }
1418
1821
 
1419
- private func decompressBrotli(data: Data, fileName: String) -> Data? {
1420
- // Handle empty files
1421
- if data.count == 0 {
1422
- return data
1822
+ /// Stream Brotli from disk to disk. Peek only the 3-byte header and last byte
1823
+ /// for the empty/wrapper special cases; never load the whole file.
1824
+ func decompressBrotli(from source: URL, to dest: URL, fileName: String) throws -> String {
1825
+ let fileManager = FileManager.default
1826
+ try fileManager.createDirectory(at: dest.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
1827
+ let length = (try fileManager.attributesOfItem(atPath: source.path)[.size] as? NSNumber)?.uint64Value ?? 0
1828
+ if length == 0 {
1829
+ try Data().write(to: dest, options: .atomic)
1830
+ return CryptoCipher.RunningChecksum().hex()
1831
+ }
1832
+
1833
+ let handle = try FileHandle(forReadingFrom: source)
1834
+ defer {
1835
+ try? handle.close()
1836
+ }
1837
+
1838
+ let head = try handle.read(upToCount: 3) ?? Data()
1839
+ var last: UInt8 = 0
1840
+ if length >= 1 {
1841
+ try handle.seek(toOffset: length - 1)
1842
+ last = try handle.read(upToCount: 1)?.first ?? 0
1423
1843
  }
1424
1844
 
1425
- // Handle the special EMPTY_BROTLI_STREAM case
1426
- if data.count == 3 && data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 {
1427
- return Data()
1845
+ if length == 3 && head.count == 3 && head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06 {
1846
+ try Data().write(to: dest, options: .atomic)
1847
+ return CryptoCipher.RunningChecksum().hex()
1428
1848
  }
1429
1849
 
1430
- // For small files, check if it's a minimal Brotli wrapper
1431
- if data.count > 3 {
1432
- let maxBytes = min(32, data.count)
1433
- let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
1434
- // Handle our minimal wrapper pattern
1435
- if data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 && data.last == 0x03 {
1436
- let range = data.index(data.startIndex, offsetBy: 3)..<data.index(data.endIndex, offsetBy: -1)
1437
- return data[range]
1850
+ if length > 3 && head.count == 3 && last == 0x03 {
1851
+ let isEmptyWrapper = head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06
1852
+ let isQualityZeroWrapper = head[0] == 0x0b && head[1] == 0x02 && head[2] == 0x80
1853
+ if isEmptyWrapper || isQualityZeroWrapper {
1854
+ try handle.seek(toOffset: 3)
1855
+ return try streamCopy(from: handle, count: length - 4, to: dest)
1438
1856
  }
1857
+ }
1858
+
1859
+ try handle.seek(toOffset: 0)
1860
+ return try streamBrotliDecode(from: handle, to: dest, fileName: fileName)
1861
+ }
1439
1862
 
1440
- // Handle brotli.compress minimal wrapper (quality 0)
1441
- if data[0] == 0x0b && data[1] == 0x02 && data[2] == 0x80 && data.last == 0x03 {
1442
- let range = data.index(data.startIndex, offsetBy: 3)..<data.index(data.endIndex, offsetBy: -1)
1443
- return data[range]
1863
+ func streamCopy(from handle: FileHandle, count: UInt64, to dest: URL) throws -> String {
1864
+ let fileManager = FileManager.default
1865
+ let tempURL = dest.deletingLastPathComponent().appendingPathComponent("capgo-br-\(UUID().uuidString).tmp")
1866
+ fileManager.createFile(atPath: tempURL.path, contents: nil)
1867
+ let output = try FileHandle(forWritingTo: tempURL)
1868
+ defer {
1869
+ try? output.close()
1870
+ try? fileManager.removeItem(at: tempURL)
1871
+ }
1872
+
1873
+ let hasher = CryptoCipher.RunningChecksum()
1874
+ var remaining = count
1875
+ let chunkSize = CryptoCipher.ioBufferBytes()
1876
+ while remaining > 0 {
1877
+ let readCount: Int = try autoreleasepool {
1878
+ let toRead = Int(min(UInt64(chunkSize), remaining))
1879
+ let chunk = try handle.read(upToCount: toRead) ?? Data()
1880
+ if !chunk.isEmpty {
1881
+ hasher.update(chunk)
1882
+ try output.write(contentsOf: chunk)
1883
+ }
1884
+ return chunk.count
1885
+ }
1886
+ if readCount == 0 {
1887
+ break
1444
1888
  }
1889
+ remaining -= UInt64(readCount)
1445
1890
  }
1891
+ try output.close()
1892
+ try replaceItemAtomically(at: dest, withItemAt: tempURL)
1893
+ return hasher.hex()
1894
+ }
1446
1895
 
1447
- // For all other cases, try standard decompression
1448
- let outputBufferSize = 65536
1449
- var outputBuffer = [UInt8](repeating: 0, count: outputBufferSize)
1450
- var decompressedData = Data()
1896
+ private func streamBrotliDecode(from handle: FileHandle, to dest: URL, fileName: String) throws -> String {
1897
+ let fileManager = FileManager.default
1898
+ let tempURL = dest.deletingLastPathComponent().appendingPathComponent("capgo-br-\(UUID().uuidString).tmp")
1899
+ fileManager.createFile(atPath: tempURL.path, contents: nil)
1900
+ let output = try FileHandle(forWritingTo: tempURL)
1901
+ defer {
1902
+ try? output.close()
1903
+ try? fileManager.removeItem(at: tempURL)
1904
+ }
1905
+ let hasher = CryptoCipher.RunningChecksum()
1906
+
1907
+ let chunkSize = max(CryptoCipher.ioBufferBytes(), 65536)
1908
+ var inputBuffer = [UInt8](repeating: 0, count: chunkSize)
1909
+ var outputBuffer = [UInt8](repeating: 0, count: chunkSize)
1451
1910
 
1452
1911
  let streamPointer = UnsafeMutablePointer<compression_stream>.allocate(capacity: 1)
1453
1912
  var status = compression_stream_init(streamPointer, COMPRESSION_STREAM_DECODE, COMPRESSION_BROTLI)
1454
-
1455
1913
  guard status != COMPRESSION_STATUS_ERROR else {
1456
1914
  logger.error("Failed to initialize Brotli stream")
1457
1915
  logger.debug("File: \(fileName), Status: \(status)")
1458
- return nil
1916
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to initialize Brotli stream for \(fileName)"])
1459
1917
  }
1460
-
1461
1918
  defer {
1462
1919
  compression_stream_destroy(streamPointer)
1463
1920
  streamPointer.deallocate()
1464
1921
  }
1465
1922
 
1466
- streamPointer.pointee.src_size = 0
1467
- streamPointer.pointee.dst_ptr = UnsafeMutablePointer<UInt8>(&outputBuffer)
1468
- streamPointer.pointee.dst_size = outputBufferSize
1469
-
1470
- let input = data
1471
-
1472
- while true {
1473
- if streamPointer.pointee.src_size == 0 {
1474
- streamPointer.pointee.src_size = input.count
1475
- input.withUnsafeBytes { rawBufferPointer in
1476
- if let baseAddress = rawBufferPointer.baseAddress {
1477
- streamPointer.pointee.src_ptr = baseAddress.assumingMemoryBound(to: UInt8.self)
1478
- } else {
1479
- logger.error("Failed to get base address for Brotli decompression")
1480
- logger.debug("File: \(fileName)")
1481
- status = COMPRESSION_STATUS_ERROR
1482
- return
1483
- }
1923
+ try inputBuffer.withUnsafeMutableBufferPointer { inBuf in
1924
+ try outputBuffer.withUnsafeMutableBufferPointer { outBuf in
1925
+ guard let inBase = inBuf.baseAddress, let outBase = outBuf.baseAddress else {
1926
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to get buffer address for \(fileName)"])
1484
1927
  }
1485
- }
1486
-
1487
- if status == COMPRESSION_STATUS_ERROR {
1488
- let maxBytes = min(32, data.count)
1489
- let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
1490
- logger.error("Brotli decompression failed")
1491
- logger.debug("File: \(fileName), First \(maxBytes) bytes: \(hexDump)")
1492
- break
1493
- }
1494
-
1495
- status = compression_stream_process(streamPointer, 0)
1928
+ streamPointer.pointee.src_size = 0
1929
+ streamPointer.pointee.dst_ptr = outBase
1930
+ streamPointer.pointee.dst_size = chunkSize
1931
+
1932
+ var flags: Int32 = 0
1933
+ var inputExhausted = false
1934
+ while true {
1935
+ if streamPointer.pointee.src_size == 0 && !inputExhausted {
1936
+ let chunk = try handle.read(upToCount: chunkSize) ?? Data()
1937
+ if chunk.isEmpty {
1938
+ inputExhausted = true
1939
+ flags = Int32(bitPattern: COMPRESSION_STREAM_FINALIZE.rawValue)
1940
+ } else {
1941
+ chunk.copyBytes(to: inBase, count: chunk.count)
1942
+ streamPointer.pointee.src_ptr = UnsafePointer(inBase)
1943
+ streamPointer.pointee.src_size = chunk.count
1944
+ }
1945
+ }
1496
1946
 
1497
- let have = outputBufferSize - streamPointer.pointee.dst_size
1498
- if have > 0 {
1499
- decompressedData.append(outputBuffer, count: have)
1500
- }
1947
+ status = compression_stream_process(streamPointer, flags)
1948
+ let have = chunkSize - streamPointer.pointee.dst_size
1949
+ if have > 0 {
1950
+ let decoded = Data(bytes: outBase, count: have)
1951
+ hasher.update(decoded)
1952
+ try output.write(contentsOf: decoded)
1953
+ }
1954
+ streamPointer.pointee.dst_ptr = outBase
1955
+ streamPointer.pointee.dst_size = chunkSize
1501
1956
 
1502
- if status == COMPRESSION_STATUS_END {
1503
- break
1504
- } else if status == COMPRESSION_STATUS_ERROR {
1505
- logger.error("Brotli process failed")
1506
- logger.debug("File: \(fileName), Status: \(status)")
1507
- if let text = String(data: data, encoding: .utf8) {
1508
- let asciiCount = text.unicodeScalars.filter { $0.isASCII }.count
1509
- let totalCount = text.unicodeScalars.count
1510
- if totalCount > 0 && Double(asciiCount) / Double(totalCount) >= 0.8 {
1511
- logger.debug("Input appears to be plain text: \(text)")
1957
+ if status == COMPRESSION_STATUS_END {
1958
+ break
1959
+ }
1960
+ if status == COMPRESSION_STATUS_ERROR {
1961
+ logger.error("Brotli process failed")
1962
+ logger.debug("File: \(fileName), Status: \(status)")
1963
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName)"])
1964
+ }
1965
+ if inputExhausted && streamPointer.pointee.src_size == 0 && have == 0 {
1966
+ logger.error("Brotli decompression stalled")
1967
+ logger.debug("File: \(fileName)")
1968
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName)"])
1512
1969
  }
1513
1970
  }
1514
-
1515
- let maxBytes = min(32, data.count)
1516
- let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
1517
- logger.debug("Raw data: \(hexDump)")
1518
-
1519
- return nil
1520
- }
1521
-
1522
- if streamPointer.pointee.dst_size == 0 {
1523
- streamPointer.pointee.dst_ptr = UnsafeMutablePointer<UInt8>(&outputBuffer)
1524
- streamPointer.pointee.dst_size = outputBufferSize
1525
- }
1526
-
1527
- if input.count == 0 {
1528
- logger.error("Zero input size for Brotli decompression")
1529
- logger.debug("File: \(fileName)")
1530
- break
1531
1971
  }
1532
1972
  }
1533
1973
 
1534
- return status == COMPRESSION_STATUS_END ? decompressedData : nil
1974
+ try output.close()
1975
+ try replaceItemAtomically(at: dest, withItemAt: tempURL)
1976
+ return hasher.hex()
1535
1977
  }
1536
1978
 
1537
1979
  public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
1980
+ try self.runBeforeDownload()
1538
1981
  let id: String = self.randomString(length: 10)
1539
1982
  // Each download uses its own temp files keyed by bundle ID to prevent collisions
1540
1983
  if version != getLocalUpdateVersion(for: id) {
@@ -1811,6 +2254,9 @@ import UIKit
1811
2254
  }
1812
2255
 
1813
2256
  public func delete(id: String, removeInfo: Bool) -> Bool {
2257
+ self.deleteLock.lock()
2258
+ defer { self.deleteLock.unlock() }
2259
+
1814
2260
  let deleted: BundleInfo = self.getBundleInfo(id: id)
1815
2261
  if deleted.isBuiltin() || self.getCurrentBundleId() == id {
1816
2262
  logger.info("Cannot delete current or builtin bundle")
@@ -1821,6 +2267,7 @@ import UIKit
1821
2267
  if let previewFallback = self.getPreviewFallbackBundle(),
1822
2268
  !previewFallback.isDeleted(),
1823
2269
  !previewFallback.isErrorStatus(),
2270
+ !previewFallback.isDeleting(),
1824
2271
  previewFallback.getId() == id {
1825
2272
  logger.info("Cannot delete the preview fallback bundle")
1826
2273
  logger.debug("Bundle ID: \(id)")
@@ -1831,6 +2278,7 @@ import UIKit
1831
2278
  if let next = self.getNextBundle(),
1832
2279
  !next.isDeleted() &&
1833
2280
  !next.isErrorStatus() &&
2281
+ !next.isDeleting() &&
1834
2282
  next.getId() == id {
1835
2283
  logger.info("Cannot delete the next bundle")
1836
2284
  logger.debug("Bundle ID: \(id)")
@@ -1838,24 +2286,56 @@ import UIKit
1838
2286
  }
1839
2287
 
1840
2288
  let destPersist: URL = libraryDir.appendingPathComponent(bundleDirectory).appendingPathComponent(id)
1841
- do {
1842
- try FileManager.default.removeItem(atPath: destPersist.path)
1843
- } catch {
1844
- logger.error("Bundle folder not removed")
1845
- logger.debug("Path: \(destPersist.path)")
1846
- // even if, we don;t care. Android doesn't care
1847
- if removeInfo {
1848
- self.removeBundleInfo(id: id)
2289
+ let hadRegistry = self.hasStoredBundleInfo(id: id)
2290
+ let hadFolder = FileManager.default.fileExists(atPath: destPersist.path)
2291
+ if !hadRegistry && !hadFolder {
2292
+ logger.error("Cannot delete unknown bundle")
2293
+ logger.debug("Bundle ID: \(id)")
2294
+ self.dequeuePendingDelete(id: id)
2295
+ return false
2296
+ }
2297
+
2298
+ // Persist DELETING before touching disk so kill/OOM can resume on next launch.
2299
+ if !deleted.isDeleting() {
2300
+ if !self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETING.storedValue)) {
2301
+ logger.error("Failed to persist DELETING marker, aborting disk delete")
2302
+ logger.debug("Bundle ID: \(id)")
2303
+ return false
1849
2304
  }
1850
- self.sendStats(action: "delete", versionName: deleted.getVersionName())
2305
+ UserDefaults.standard.synchronize()
2306
+ }
2307
+
2308
+ if FileManager.default.fileExists(atPath: destPersist.path) {
2309
+ do {
2310
+ try FileManager.default.removeItem(atPath: destPersist.path)
2311
+ } catch {
2312
+ logger.error("Bundle folder not removed, will retry later")
2313
+ logger.debug("Path: \(destPersist.path), Error: \(error.localizedDescription)")
2314
+ return false
2315
+ }
2316
+ }
2317
+
2318
+ // Only drop registry after the folder is confirmed gone.
2319
+ if FileManager.default.fileExists(atPath: destPersist.path) {
2320
+ logger.error("Bundle folder still present after delete, will retry later")
2321
+ logger.debug("Bundle ID: \(id)")
1851
2322
  return false
1852
2323
  }
2324
+
2325
+ let finalized: Bool
1853
2326
  if removeInfo {
1854
- self.removeBundleInfo(id: id)
2327
+ finalized = self.saveBundleInfo(id: id, bundle: nil)
1855
2328
  } else {
1856
- self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.storedValue))
2329
+ finalized = self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.storedValue))
2330
+ }
2331
+ guard finalized else {
2332
+ logger.error("Failed to finalize delete registry update, will retry later")
2333
+ logger.debug("Bundle ID: \(id)")
2334
+ return false
1857
2335
  }
1858
- logger.info("Bundle deleted successfully")
2336
+ UserDefaults.standard.synchronize()
2337
+ self.dequeuePendingDelete(id: id)
2338
+ logger.info("Bundle deleted and confirmed gone")
1859
2339
  logger.debug("Version: \(deleted.getVersionName())")
1860
2340
  self.sendStats(action: "delete", versionName: deleted.getVersionName())
1861
2341
  return true
@@ -1865,6 +2345,51 @@ import UIKit
1865
2345
  return self.delete(id: id, removeInfo: true)
1866
2346
  }
1867
2347
 
2348
+ /// Resume incomplete deletes one-by-one. Safe across app kill / OOM because
2349
+ /// delete() marks DELETING before disk work and only clears registry after confirm.
2350
+ public func drainPendingDeletes() {
2351
+ var pendingIds = Set(self.list(raw: true).filter { $0.isDeleting() }.map { $0.getId() }.filter { !$0.isEmpty })
2352
+ pendingIds.formUnion(self.getPendingDeleteIds())
2353
+ for id in pendingIds {
2354
+ if Thread.current.isCancelled {
2355
+ logger.warn("drainPendingDeletes was cancelled")
2356
+ return
2357
+ }
2358
+ logger.info("Resuming pending delete for bundle: \(id)")
2359
+ if self.delete(id: id, removeInfo: true) {
2360
+ self.dequeuePendingDelete(id: id)
2361
+ }
2362
+ Thread.sleep(forTimeInterval: self.deletePaceSeconds)
2363
+ }
2364
+ }
2365
+
2366
+ private func getPendingDeleteIds() -> Set<String> {
2367
+ guard let raw = UserDefaults.standard.string(forKey: self.PENDING_DELETE_IDS), !raw.isEmpty else {
2368
+ return []
2369
+ }
2370
+ return Set(raw.split(separator: ",").map { String($0) }.filter { !$0.isEmpty })
2371
+ }
2372
+
2373
+ private func enqueuePendingDelete(id: String) {
2374
+ guard !id.isEmpty else { return }
2375
+ var ids = self.getPendingDeleteIds()
2376
+ guard ids.insert(id).inserted else { return }
2377
+ UserDefaults.standard.set(ids.sorted().joined(separator: ","), forKey: self.PENDING_DELETE_IDS)
2378
+ UserDefaults.standard.synchronize()
2379
+ }
2380
+
2381
+ private func dequeuePendingDelete(id: String) {
2382
+ guard !id.isEmpty else { return }
2383
+ var ids = self.getPendingDeleteIds()
2384
+ guard ids.remove(id) != nil else { return }
2385
+ if ids.isEmpty {
2386
+ UserDefaults.standard.removeObject(forKey: self.PENDING_DELETE_IDS)
2387
+ } else {
2388
+ UserDefaults.standard.set(ids.sorted().joined(separator: ","), forKey: self.PENDING_DELETE_IDS)
2389
+ }
2390
+ UserDefaults.standard.synchronize()
2391
+ }
2392
+
1868
2393
  public func cleanupDeltaCache() {
1869
2394
  cleanupDeltaCache(threadToCheck: nil)
1870
2395
  }
@@ -1924,6 +2449,11 @@ import UIKit
1924
2449
 
1925
2450
  do {
1926
2451
  try fileManager.removeItem(at: url)
2452
+ if fileManager.fileExists(atPath: url.path) {
2453
+ logger.error("Orphan bundle directory still present after delete")
2454
+ logger.debug("Bundle ID: \(id)")
2455
+ continue
2456
+ }
1927
2457
  self.removeBundleInfo(id: id)
1928
2458
  logger.info("Deleted orphan bundle directory")
1929
2459
  logger.debug("Bundle ID: \(id)")
@@ -1938,6 +2468,40 @@ import UIKit
1938
2468
  }
1939
2469
  }
1940
2470
 
2471
+ public func allowedBundleIdsForCleanup() -> Set<String> {
2472
+ var allowedIds = Set(self.list(raw: true).compactMap { info -> String? in
2473
+ let id = info.getId()
2474
+ // DELETED tombstones must not protect leftover folders.
2475
+ // DELETING stays protected so drainPendingDeletes owns the removal.
2476
+ if id.isEmpty || info.isDeleted() {
2477
+ return nil
2478
+ }
2479
+ return id
2480
+ })
2481
+ let currentId = self.getCurrentBundleId()
2482
+ if !currentId.isEmpty {
2483
+ allowedIds.insert(currentId)
2484
+ }
2485
+ let fallback = self.getFallbackBundle()
2486
+ let fallbackId = fallback.getId()
2487
+ if !fallbackId.isEmpty && !fallback.isDeleting() {
2488
+ allowedIds.insert(fallbackId)
2489
+ }
2490
+ if let next = self.getNextBundle() {
2491
+ let nextId = next.getId()
2492
+ if !nextId.isEmpty && !next.isDeleting() {
2493
+ allowedIds.insert(nextId)
2494
+ }
2495
+ }
2496
+ if let previewFallback = self.getPreviewFallbackBundle() {
2497
+ let previewId = previewFallback.getId()
2498
+ if !previewId.isEmpty && !previewFallback.isDeleting() {
2499
+ allowedIds.insert(previewId)
2500
+ }
2501
+ }
2502
+ return allowedIds
2503
+ }
2504
+
1941
2505
  public func cleanupOrphanedTempFolders(threadToCheck: Thread?) {
1942
2506
  let fileManager = FileManager.default
1943
2507
 
@@ -1977,11 +2541,16 @@ import UIKit
1977
2541
  logger.debug("Error: \(error.localizedDescription)")
1978
2542
  }
1979
2543
 
2544
+ if let thread = threadToCheck, thread.isCancelled {
2545
+ logger.warn("cleanupOrphanedTempFolders was cancelled")
2546
+ return
2547
+ }
2548
+
1980
2549
  // Also cleanup old download temp files (package_*.tmp and update_*.dat)
1981
- cleanupOldDownloadTempFiles()
2550
+ cleanupOldDownloadTempFiles(threadToCheck: threadToCheck)
1982
2551
  }
1983
2552
 
1984
- private func cleanupOldDownloadTempFiles() {
2553
+ private func cleanupOldDownloadTempFiles(threadToCheck: Thread? = nil) {
1985
2554
  let fileManager = FileManager.default
1986
2555
  guard let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
1987
2556
  return
@@ -1992,6 +2561,10 @@ import UIKit
1992
2561
  let oneHourAgo = Date().addingTimeInterval(-3600)
1993
2562
 
1994
2563
  for url in contents {
2564
+ if let thread = threadToCheck, thread.isCancelled {
2565
+ logger.warn("cleanupOldDownloadTempFiles was cancelled")
2566
+ return
2567
+ }
1995
2568
  let fileName = url.lastPathComponent
1996
2569
  // Only cleanup package_*.tmp and update_*.dat files
1997
2570
  let isDownloadTemp = (fileName.hasPrefix("package_") && fileName.hasSuffix(".tmp")) ||
@@ -2079,7 +2652,8 @@ import UIKit
2079
2652
  destPersist.isDirectory &&
2080
2653
  !indexPersist.isDirectory &&
2081
2654
  indexPersist.exist &&
2082
- !bundleIndo.isDeleted() {
2655
+ !bundleIndo.isDeleted() &&
2656
+ !bundleIndo.isDeleting() {
2083
2657
  return true
2084
2658
  }
2085
2659
  return false
@@ -2169,17 +2743,39 @@ import UIKit
2169
2743
  let fallbackIsPreviewFallback = previewFallback?.getId() == fallback.getId()
2170
2744
  logger.info("Fallback bundle is: \(fallback.toString())")
2171
2745
  logger.info("Version successfully loaded: \(bundle.toString())")
2172
- if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() && !fallbackIsPreviewFallback {
2173
- let res = self.delete(id: fallback.getId())
2174
- if res {
2175
- logger.info("Deleted previous bundle")
2176
- logger.debug("Bundle: \(fallback.toString())")
2177
- } else {
2178
- logger.error("Failed to delete previous bundle")
2179
- logger.debug("Bundle: \(fallback.toString())")
2746
+ let previousFallbackId = fallback.getId()
2747
+ let nextBundle = self.getNextBundle()
2748
+ let previousIsNext = nextBundle?.getId() == previousFallbackId &&
2749
+ !(nextBundle?.isDeleted() ?? true) &&
2750
+ !(nextBundle?.isErrorStatus() ?? true) &&
2751
+ !(nextBundle?.isDeleting() ?? true)
2752
+ let shouldDeletePrevious = autoDeletePrevious &&
2753
+ !fallback.isBuiltin() &&
2754
+ previousFallbackId != bundle.getId() &&
2755
+ !fallbackIsPreviewFallback &&
2756
+ !previousIsNext
2757
+ if shouldDeletePrevious {
2758
+ // Mark durable intent before fallback switch so a kill mid-flight still retries.
2759
+ if !self.saveBundleInfo(id: previousFallbackId, bundle: fallback.setStatus(status: BundleStatus.DELETING.storedValue)) {
2760
+ self.logger.error("Failed to persist DELETING for previous bundle; queueing durable retry")
2761
+ self.logger.debug("Bundle ID: \(previousFallbackId)")
2762
+ self.enqueuePendingDelete(id: previousFallbackId)
2180
2763
  }
2764
+ UserDefaults.standard.synchronize()
2181
2765
  }
2182
2766
  self.setFallbackBundle(fallback: bundle)
2767
+ if shouldDeletePrevious {
2768
+ DispatchQueue.global(qos: .utility).async {
2769
+ let res = self.delete(id: previousFallbackId)
2770
+ if res {
2771
+ self.logger.info("Deleted previous bundle")
2772
+ self.logger.debug("Bundle ID: \(previousFallbackId)")
2773
+ } else {
2774
+ self.logger.info("Previous bundle delete incomplete, will retry")
2775
+ self.logger.debug("Bundle ID: \(previousFallbackId)")
2776
+ }
2777
+ }
2778
+ }
2183
2779
  }
2184
2780
 
2185
2781
  public func setError(bundle: BundleInfo) {
@@ -2216,11 +2812,11 @@ import UIKit
2216
2812
  return setChannel
2217
2813
  }
2218
2814
 
2219
- // Check if rate limit was exceeded
2220
- if CapgoUpdater.rateLimitExceeded {
2221
- logger.debug("Skipping setChannel due to rate limit (429). Requests will resume after app restart.")
2222
- setChannel.message = "Rate limit exceeded"
2223
- setChannel.error = "rate_limit_exceeded"
2815
+ if isRemoteBlocked() {
2816
+ let blocked = remoteBlockedClientError()
2817
+ logger.debug("Skipping setChannel due to remote block (\(blocked.error)).")
2818
+ setChannel.message = blocked.message
2819
+ setChannel.error = blocked.error
2224
2820
  return setChannel
2225
2821
  }
2226
2822
 
@@ -2245,9 +2841,14 @@ import UIKit
2245
2841
 
2246
2842
  let result = performRequest(request, label: "setChannel")
2247
2843
 
2248
- if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
2249
- setChannel.message = "Rate limit exceeded"
2250
- setChannel.error = "rate_limit_exceeded"
2844
+ let rateLimit = self.checkAndHandleRateLimitResponse(
2845
+ statusCode: result.response?.statusCode,
2846
+ data: result.data,
2847
+ response: result.response
2848
+ )
2849
+ if rateLimit.blocked {
2850
+ setChannel.message = rateLimit.message
2851
+ setChannel.error = rateLimit.error
2251
2852
  return setChannel
2252
2853
  }
2253
2854
 
@@ -2289,7 +2890,8 @@ import UIKit
2289
2890
  self.logger.info("Public channel requested, channel override removed")
2290
2891
 
2291
2892
  setChannel.status = responseValue.status ?? "ok"
2292
- setChannel.message = responseValue.message ?? "Public channel requested, channel override removed. Device will use public channel automatically."
2893
+ setChannel.message = responseValue.message
2894
+ ?? "Public channel requested, channel override removed. Device will use public channel automatically."
2293
2895
  } else {
2294
2896
  self.defaultChannel = channel
2295
2897
  UserDefaults.standard.set(channel, forKey: defaultChannelKey)
@@ -2304,12 +2906,12 @@ import UIKit
2304
2906
 
2305
2907
  func getChannel(defaultChannelKey: String? = nil) -> GetChannel {
2306
2908
  let getChannel: GetChannel = GetChannel()
2307
-
2308
2909
  // Check if rate limit was exceeded
2309
- if CapgoUpdater.rateLimitExceeded {
2310
- logger.debug("Skipping getChannel due to rate limit (429). Requests will resume after app restart.")
2311
- getChannel.message = "Rate limit exceeded"
2312
- getChannel.error = "rate_limit_exceeded"
2910
+ if isRemoteBlocked() {
2911
+ let blocked = remoteBlockedClientError()
2912
+ logger.debug("Skipping getChannel due to remote block (\(blocked.error)).")
2913
+ getChannel.message = blocked.message
2914
+ getChannel.error = blocked.error
2313
2915
  return getChannel
2314
2916
  }
2315
2917
 
@@ -2333,9 +2935,14 @@ import UIKit
2333
2935
 
2334
2936
  let result = performRequest(request, label: "getChannel")
2335
2937
 
2336
- if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
2337
- getChannel.message = "Rate limit exceeded"
2338
- getChannel.error = "rate_limit_exceeded"
2938
+ let rateLimit = self.checkAndHandleRateLimitResponse(
2939
+ statusCode: result.response?.statusCode,
2940
+ data: result.data,
2941
+ response: result.response
2942
+ )
2943
+ if rateLimit.blocked {
2944
+ getChannel.message = rateLimit.message
2945
+ getChannel.error = rateLimit.error
2339
2946
  return getChannel
2340
2947
  }
2341
2948
 
@@ -2413,9 +3020,10 @@ import UIKit
2413
3020
  let listChannels: ListChannels = ListChannels()
2414
3021
 
2415
3022
  // Check if rate limit was exceeded
2416
- if CapgoUpdater.rateLimitExceeded {
2417
- logger.debug("Skipping listChannels due to rate limit (429). Requests will resume after app restart.")
2418
- listChannels.error = "rate_limit_exceeded"
3023
+ if isRemoteBlocked() {
3024
+ let blocked = remoteBlockedClientError()
3025
+ logger.debug("Skipping listChannels due to remote block (\(blocked.error)).")
3026
+ listChannels.error = blocked.error
2419
3027
  return listChannels
2420
3028
  }
2421
3029
 
@@ -2449,8 +3057,13 @@ import UIKit
2449
3057
 
2450
3058
  let result = performRequest(request, label: "listChannels")
2451
3059
 
2452
- if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
2453
- listChannels.error = "rate_limit_exceeded"
3060
+ let rateLimit = self.checkAndHandleRateLimitResponse(
3061
+ statusCode: result.response?.statusCode,
3062
+ data: result.data,
3063
+ response: result.response
3064
+ )
3065
+ if rateLimit.blocked {
3066
+ listChannels.error = rateLimit.error
2454
3067
  return listChannels
2455
3068
  }
2456
3069
 
@@ -2507,6 +3120,7 @@ import UIKit
2507
3120
  let queue = OperationQueue()
2508
3121
  queue.name = "com.capgo.manifestDownload"
2509
3122
  queue.qualityOfService = .userInitiated
3123
+ queue.maxConcurrentOperationCount = CapgoUpdater.manifestMaxConcurrentFiles
2510
3124
  return queue
2511
3125
  }()
2512
3126
 
@@ -2529,14 +3143,12 @@ import UIKit
2529
3143
  metadata: [String: String]?,
2530
3144
  onSent: (() -> Void)?
2531
3145
  ) {
2532
- if previewSession {
2533
- logger.debug("Skipping sendStats during preview session.")
3146
+ if statsStopped {
2534
3147
  return
2535
3148
  }
2536
3149
 
2537
- // Check if rate limit was exceeded
2538
- if CapgoUpdater.rateLimitExceeded {
2539
- logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.")
3150
+ if previewSession {
3151
+ logger.debug("Skipping sendStats during preview session.")
2540
3152
  return
2541
3153
  }
2542
3154
 
@@ -2570,15 +3182,90 @@ import UIKit
2570
3182
  )
2571
3183
 
2572
3184
  statsQueueLock.lock()
3185
+ if statsStopped {
3186
+ statsQueueLock.unlock()
3187
+ return
3188
+ }
3189
+ if statsQueue.count >= CapgoUpdater.maxPendingStats {
3190
+ statsQueue.removeFirst(statsQueue.count - CapgoUpdater.maxPendingStats + 1)
3191
+ }
2573
3192
  statsQueue.append(QueuedStatsEvent(event: event, onSent: onSent))
2574
3193
  statsQueueLock.unlock()
2575
3194
 
2576
3195
  ensureStatsTimerStarted()
2577
3196
  }
2578
3197
 
3198
+ func restorePendingStats() {
3199
+ let fileURL = pendingStatsFileURL()
3200
+ guard FileManager.default.fileExists(atPath: fileURL.path),
3201
+ let data = try? Data(contentsOf: fileURL),
3202
+ let events = try? JSONDecoder().decode([StatsEvent].self, from: data) else {
3203
+ return
3204
+ }
3205
+
3206
+ statsQueueLock.lock()
3207
+ for event in events {
3208
+ if statsQueue.count >= CapgoUpdater.maxPendingStats {
3209
+ break
3210
+ }
3211
+ statsQueue.append(QueuedStatsEvent(event: event, onSent: nil))
3212
+ }
3213
+ let restoredCount = statsQueue.count
3214
+ statsQueueLock.unlock()
3215
+
3216
+ if restoredCount > 0 {
3217
+ logger.info("Restored \(restoredCount) pending stats events")
3218
+ ensureStatsTimerStarted()
3219
+ }
3220
+ }
3221
+
3222
+ func persistPendingStats() {
3223
+ persistStatsQueue()
3224
+ }
3225
+
3226
+ private func pendingStatsFileURL() -> URL {
3227
+ libraryDir.appendingPathComponent(pendingStatsFileName)
3228
+ }
3229
+
3230
+ private func persistStatsQueue(force: Bool = false) {
3231
+ statsPersistLock.lock()
3232
+ defer { statsPersistLock.unlock() }
3233
+ if statsStopped && !force {
3234
+ return
3235
+ }
3236
+
3237
+ statsQueueLock.lock()
3238
+ var events = statsInFlight.map(\.event) + statsQueue.map(\.event)
3239
+ statsQueueLock.unlock()
3240
+ if events.count > CapgoUpdater.maxPendingStats {
3241
+ events = Array(events.suffix(CapgoUpdater.maxPendingStats))
3242
+ }
3243
+
3244
+ let fileURL = pendingStatsFileURL()
3245
+ if events.isEmpty {
3246
+ try? FileManager.default.removeItem(at: fileURL)
3247
+ return
3248
+ }
3249
+
3250
+ do {
3251
+ let data = try JSONEncoder().encode(events)
3252
+ try data.write(to: fileURL, options: .atomic)
3253
+ var resourceURL = fileURL
3254
+ var values = URLResourceValues()
3255
+ values.isExcludedFromBackup = true
3256
+ try resourceURL.setResourceValues(values)
3257
+ } catch {
3258
+ logger.error("Failed to persist stats queue")
3259
+ logger.debug("Error: \(error.localizedDescription)")
3260
+ }
3261
+ }
3262
+
2579
3263
  private func ensureStatsTimerStarted() {
3264
+ if statsStopped {
3265
+ return
3266
+ }
2580
3267
  DispatchQueue.main.async { [weak self] in
2581
- guard let self = self else { return }
3268
+ guard let self = self, !self.statsStopped else { return }
2582
3269
  if self.statsFlushTimer == nil || !self.statsFlushTimer!.isValid {
2583
3270
  // Use closure-based timer to avoid strong reference cycle
2584
3271
  self.statsFlushTimer = Timer.scheduledTimer(
@@ -2592,17 +3279,27 @@ import UIKit
2592
3279
  }
2593
3280
 
2594
3281
  private func flushStatsQueue() {
3282
+ if statsStopped {
3283
+ return
3284
+ }
3285
+ // While Retry-After is active, keep stats queued and skip the network call.
3286
+ if isRemoteBlocked() {
3287
+ logger.debug("Deferring stats flush until Retry-After expires.")
3288
+ return
3289
+ }
3290
+
2595
3291
  statsQueueLock.lock()
2596
- guard !statsQueue.isEmpty else {
3292
+ guard statsInFlight.isEmpty, !statsQueue.isEmpty else {
2597
3293
  statsQueueLock.unlock()
2598
3294
  return
2599
3295
  }
2600
3296
  let queuedEvents = statsQueue
2601
3297
  statsQueue.removeAll()
3298
+ statsInFlight = queuedEvents
2602
3299
  statsQueueLock.unlock()
3300
+ persistStatsQueue()
2603
3301
 
2604
3302
  let eventsToSend = queuedEvents.map(\.event)
2605
- let onSentCallbacks = queuedEvents.compactMap(\.onSent)
2606
3303
 
2607
3304
  operationQueue.maxConcurrentOperationCount = 1
2608
3305
 
@@ -2615,35 +3312,85 @@ import UIKit
2615
3312
  encoder: JSONParameterEncoder.default,
2616
3313
  requestModifier: { $0.timeoutInterval = self.timeout }
2617
3314
  ).responseData { response in
2618
- // Check for 429 rate limit
2619
- if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
3315
+ if self.abandonStoppedStatsFlush() {
3316
+ semaphore.signal()
3317
+ return
3318
+ }
3319
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode, data: response.data, response: response.response).blocked {
3320
+ self.requeueStatsEvents(queuedEvents)
2620
3321
  semaphore.signal()
2621
3322
  return
2622
3323
  }
2623
3324
 
2624
3325
  if let statusCode = response.response?.statusCode, !(200...299).contains(statusCode) {
2625
- self.logger.error("Error sending stats batch")
2626
- self.logger.debug("Response code: \(statusCode)")
3326
+ if CapgoUpdater.isTransientStatsFailure(statusCode) {
3327
+ self.requeueStatsEvents(queuedEvents)
3328
+ self.logger.error("Error sending stats batch")
3329
+ self.logger.debug("Retrying later, response code: \(statusCode)")
3330
+ } else {
3331
+ self.clearStatsInFlight()
3332
+ self.logger.error("Dropping stats batch after permanent error")
3333
+ self.logger.debug("Response code: \(statusCode)")
3334
+ }
2627
3335
  semaphore.signal()
2628
3336
  return
2629
3337
  }
2630
3338
 
2631
3339
  switch response.result {
2632
3340
  case .success:
3341
+ self.clearStatsInFlight()
2633
3342
  self.logger.info("Stats batch sent successfully")
2634
3343
  self.logger.debug("Sent \(eventsToSend.count) events")
2635
- onSentCallbacks.forEach { $0() }
3344
+ self.runStatsCallbacks(queuedEvents)
2636
3345
  case let .failure(error):
3346
+ self.requeueStatsEvents(queuedEvents)
2637
3347
  self.logger.error("Error sending stats batch")
2638
3348
  self.logger.debug("Response: \(response.value?.debugDescription ?? "nil"), Error: \(error.localizedDescription)")
2639
3349
  }
2640
3350
  semaphore.signal()
2641
3351
  }
2642
3352
  semaphore.wait()
3353
+ if !self.statsStopped {
3354
+ self.persistStatsQueue()
3355
+ }
2643
3356
  }
2644
3357
  operationQueue.addOperation(operation)
2645
3358
  }
2646
3359
 
3360
+ private func abandonStoppedStatsFlush() -> Bool {
3361
+ statsStopped
3362
+ }
3363
+
3364
+ /// Only 429, request timeout and 5xx are worth retrying; other 4xx are permanent rejections.
3365
+ private static func isTransientStatsFailure(_ statusCode: Int) -> Bool {
3366
+ return statusCode == 429 || statusCode == 408 || statusCode >= 500
3367
+ }
3368
+
3369
+ private func runStatsCallbacks(_ sentEvents: [QueuedStatsEvent]) {
3370
+ for sentEvent in sentEvents {
3371
+ sentEvent.onSent?()
3372
+ }
3373
+ }
3374
+
3375
+ private func requeueStatsEvents(_ events: [QueuedStatsEvent]) {
3376
+ guard !statsStopped, !events.isEmpty else { return }
3377
+ statsQueueLock.lock()
3378
+ statsInFlight.removeAll()
3379
+ statsQueue.insert(contentsOf: events, at: 0)
3380
+ if statsQueue.count > CapgoUpdater.maxPendingStats {
3381
+ statsQueue.removeFirst(statsQueue.count - CapgoUpdater.maxPendingStats)
3382
+ }
3383
+ statsQueueLock.unlock()
3384
+ persistStatsQueue()
3385
+ ensureStatsTimerStarted()
3386
+ }
3387
+
3388
+ private func clearStatsInFlight() {
3389
+ statsQueueLock.lock()
3390
+ statsInFlight.removeAll()
3391
+ statsQueueLock.unlock()
3392
+ }
3393
+
2647
3394
  public func getBundleInfo(id: String?) -> BundleInfo {
2648
3395
  var trueId = BundleInfo.VERSION_UNKNOWN
2649
3396
  if id != nil {
@@ -2680,23 +3427,26 @@ import UIKit
2680
3427
  self.saveBundleInfo(id: id, bundle: nil)
2681
3428
  }
2682
3429
 
2683
- public func saveBundleInfo(id: String, bundle: BundleInfo?) {
3430
+ @discardableResult
3431
+ public func saveBundleInfo(id: String, bundle: BundleInfo?) -> Bool {
2684
3432
  if bundle != nil && (bundle!.isBuiltin() || bundle!.isUnknown()) {
2685
3433
  logger.info("Not saving info for bundle [\(id)] \(bundle?.toString() ?? "")")
2686
- return
3434
+ return false
2687
3435
  }
2688
3436
  if bundle == nil {
2689
3437
  logger.info("Removing info for bundle [\(id)]")
2690
3438
  UserDefaults.standard.removeObject(forKey: "\(id)\(self.INFO_SUFFIX)")
2691
- } else {
2692
- let update = bundle!.setId(id: id)
2693
- logger.info("Storing info for bundle [\(id)] \(update.toString())")
2694
- do {
2695
- try UserDefaults.standard.setObj(update, forKey: "\(id)\(self.INFO_SUFFIX)")
2696
- } catch {
2697
- logger.error("Failed to save bundle info")
2698
- logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
2699
- }
3439
+ return true
3440
+ }
3441
+ let update = bundle!.setId(id: id)
3442
+ logger.info("Storing info for bundle [\(id)] \(update.toString())")
3443
+ do {
3444
+ try UserDefaults.standard.setObj(update, forKey: "\(id)\(self.INFO_SUFFIX)")
3445
+ return true
3446
+ } catch {
3447
+ logger.error("Failed to save bundle info")
3448
+ logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
3449
+ return false
2700
3450
  }
2701
3451
  }
2702
3452