@capgo/capacitor-updater 5.50.2 → 5.51.15

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 (28) hide show
  1. package/CapgoCapacitorUpdater.podspec +1 -1
  2. package/Package.swift +3 -2
  3. package/README.md +53 -48
  4. package/android/build.gradle +1 -0
  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 +452 -139
  9. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1354 -412
  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/DownloadService.java +540 -224
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +103 -3
  15. package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +1 -1
  16. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +131 -145
  17. package/dist/docs.json +32 -8
  18. package/dist/esm/definitions.d.ts +41 -17
  19. package/dist/esm/definitions.js.map +1 -1
  20. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +124 -0
  21. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +9 -1
  22. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +3 -0
  23. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +787 -92
  24. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1014 -268
  25. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +49 -31
  26. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +44 -20
  27. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +28 -0
  28. package/package.json +12 -7
@@ -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,42 @@ 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. The
932
+ /// built-in bundle stores files under this exact name (see
933
+ /// isManifestEntryAvailableLocally), unlike the extracted/cached copy which
934
+ /// is always named without the suffix.
935
+ let originalFileName: String
936
+ }
937
+
938
+ /// Keys are stripped of the `.br` suffix so they match the extracted file names on
939
+ /// disk, not the manifest's (possibly brotli-compressed) original file names.
940
+ func manifestHashLookup(manifest: [ManifestEntry]?, sessionKey: String) -> [String: ManifestLookupEntry] {
941
+ guard let manifest else {
942
+ return [:]
943
+ }
944
+ var lookup: [String: ManifestLookupEntry] = [:]
945
+ for entry in manifest {
946
+ guard let fileName = entry.file_name,
947
+ let hash = resolveManifestFileHash(entry: entry, sessionKey: sessionKey) else {
948
+ continue
949
+ }
950
+ let destFileName = fileName.hasSuffix(".br") ? String(fileName.dropLast(3)) : fileName
951
+ lookup[destFileName] = ManifestLookupEntry(hash: hash, originalFileName: fileName)
708
952
  }
953
+ return lookup
709
954
  }
710
955
 
711
- private func populateDeltaCache(for id: String) {
956
+ /// `manifest` must only contain entries the caller already checksum-verified
957
+ /// (as `downloadManifest` does) — the hashes are trusted as-is, not re-checked.
958
+ func populateDeltaCache(for id: String, manifest: [ManifestEntry]? = nil, sessionKey: String = "") {
712
959
  let bundleDir = self.getBundleDirectory(id: id)
713
960
  let fileManager = FileManager.default
714
961
 
@@ -728,24 +975,40 @@ import UIKit
728
975
  return
729
976
  }
730
977
 
978
+ let knownEntries = manifestHashLookup(manifest: manifest, sessionKey: sessionKey)
979
+ let builtinFolder = self.builtinFolderURL()
980
+
731
981
  for case let fileURL as URL in enumerator {
732
982
  let resourceValues = try? fileURL.resourceValues(forKeys: [.isDirectoryKey])
733
983
  if resourceValues?.isDirectory == true {
734
984
  continue
735
985
  }
736
986
 
737
- let checksum = CryptoCipher.calcChecksum(filePath: fileURL)
987
+ let relativePath = String(fileURL.path.dropFirst(bundleDir.path.count + 1))
988
+ let knownEntry = knownEntries[relativePath]
989
+
990
+ let checksum = knownEntry?.hash ?? CryptoCipher.calcChecksum(filePath: fileURL)
738
991
  if checksum.isEmpty {
739
992
  continue
740
993
  }
741
994
 
995
+ // Builtin is already a permanent reuse source (see isManifestEntryAvailableLocally),
996
+ // so there's no need to also duplicate this file into the delta cache
997
+ let builtinRelativePath = knownEntry?.originalFileName ?? relativePath
998
+ let builtinFilePath = builtinFolder.appendingPathComponent(builtinRelativePath)
999
+ let isBuiltinOrigin = fileManager.fileExists(atPath: builtinFilePath.path) &&
1000
+ verifyChecksum(file: builtinFilePath, expectedHash: checksum)
1001
+ if isBuiltinOrigin {
1002
+ continue
1003
+ }
1004
+
742
1005
  let cacheFile = cacheFolder.appendingPathComponent("\(checksum)_\(fileURL.lastPathComponent)")
743
1006
  if fileManager.fileExists(atPath: cacheFile.path) {
744
1007
  continue
745
1008
  }
746
1009
 
747
1010
  do {
748
- try fileManager.copyItem(at: fileURL, to: cacheFile)
1011
+ try copyItemAtomically(from: fileURL, to: cacheFile)
749
1012
  } catch {
750
1013
  logger.debug("Delta cache copy failed: \(fileURL.path)")
751
1014
  }
@@ -775,6 +1038,14 @@ import UIKit
775
1038
 
776
1039
  public func getLatest(url: URL, channel: String?, appIdOverride: String? = nil) -> AppVersion {
777
1040
  let latest: AppVersion = AppVersion()
1041
+ if isRemoteBlocked() {
1042
+ let blocked = remoteBlockedClientError()
1043
+ logger.debug("Skipping getLatest due to remote block (\(blocked.error)).")
1044
+ latest.message = blocked.message
1045
+ latest.error = blocked.error
1046
+ latest.kind = "failed"
1047
+ return latest
1048
+ }
778
1049
  func applyLatestResponse(_ value: AppVersionDec?) {
779
1050
  if let url = value?.url {
780
1051
  latest.url = url
@@ -855,9 +1126,14 @@ import UIKit
855
1126
  return latest
856
1127
  }
857
1128
 
858
- if self.checkAndHandleRateLimitResponse(statusCode: latest.statusCode) {
859
- latest.message = "Rate limit exceeded"
860
- latest.error = "rate_limit_exceeded"
1129
+ let rateLimit = self.checkAndHandleRateLimitResponse(
1130
+ statusCode: latest.statusCode,
1131
+ data: data,
1132
+ response: result.response
1133
+ )
1134
+ if rateLimit.blocked {
1135
+ latest.message = rateLimit.message
1136
+ latest.error = rateLimit.error
861
1137
  latest.kind = "failed"
862
1138
  return latest
863
1139
  }
@@ -926,6 +1202,12 @@ import UIKit
926
1202
  return actualHash == expectedHash
927
1203
  }
928
1204
 
1205
+ /// Overridable so tests can point it at a writable directory instead of the
1206
+ /// real (read-only) app bundle.
1207
+ func builtinFolderURL() -> URL {
1208
+ Bundle.main.bundleURL.appendingPathComponent("public")
1209
+ }
1210
+
929
1211
  private func resolveManifestFileHash(entry: ManifestEntry, sessionKey: String) -> String? {
930
1212
  guard var fileHash = entry.file_hash, !fileHash.isEmpty else {
931
1213
  return nil
@@ -948,7 +1230,7 @@ import UIKit
948
1230
  return false
949
1231
  }
950
1232
 
951
- let builtinFolder = Bundle.main.bundleURL.appendingPathComponent("public")
1233
+ let builtinFolder = self.builtinFolderURL()
952
1234
  let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
953
1235
  if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
954
1236
  return true
@@ -957,21 +1239,52 @@ import UIKit
957
1239
  let fileNameWithoutPath = (fileName as NSString).lastPathComponent
958
1240
  let isBrotli = fileName.hasSuffix(".br")
959
1241
  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) {
1242
+ if Self.isSafeCacheHash(fileHash) {
1243
+ let cacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(cacheBaseName)")
1244
+ // Cache files are named `{hash}_{filename}` and were checksum-verified
1245
+ // when written. Re-hashing every hit re-reads the whole bundle and
1246
+ // OOMs/janks low-RAM devices during getMissing / delta apply.
1247
+ if isReusableCacheFile(cacheFilePath, expectedHash: fileHash) {
968
1248
  return true
969
1249
  }
1250
+
1251
+ if isBrotli {
1252
+ let legacyCacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(fileNameWithoutPath)")
1253
+ if isReusableCacheFile(legacyCacheFilePath, expectedHash: fileHash) {
1254
+ return true
1255
+ }
1256
+ }
970
1257
  }
971
1258
 
972
1259
  return false
973
1260
  }
974
1261
 
1262
+ /// SHA-256 hash-named cache files were verified when written. Existence is
1263
+ /// enough for non-empty files; empty files are reused only for the empty SHA-256.
1264
+ /// CRC32 (8 hex) is too collision-prone to trust without a re-read.
1265
+ private func isReusableCacheFile(_ url: URL, expectedHash: String) -> Bool {
1266
+ guard Self.isSafeCacheHash(expectedHash), expectedHash.count == 64 else {
1267
+ return false
1268
+ }
1269
+ let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? -1
1270
+ if size > 0 {
1271
+ return true
1272
+ }
1273
+ return size == 0 && expectedHash.lowercased() == Self.emptySha256
1274
+ }
1275
+
1276
+ static func isSafeCacheHash(_ hash: String) -> Bool {
1277
+ let count = hash.count
1278
+ guard count == 64 || count == 8 else {
1279
+ return false
1280
+ }
1281
+ return hash.unicodeScalars.allSatisfy { scalar in
1282
+ (0x30...0x39).contains(scalar.value) ||
1283
+ (0x41...0x46).contains(scalar.value) ||
1284
+ (0x61...0x66).contains(scalar.value)
1285
+ }
1286
+ }
1287
+
975
1288
  public func getMissingBundleFiles(manifest: [ManifestEntry], sessionKey: String) -> [ManifestEntry] {
976
1289
  return manifest.filter { entry in
977
1290
  !isManifestEntryAvailableLocally(entry: entry, sessionKey: sessionKey)
@@ -1052,17 +1365,23 @@ import UIKit
1052
1365
  return json
1053
1366
  }
1054
1367
 
1368
+ private func runBeforeDownload() throws {
1369
+ try beforeDownload?()
1370
+ }
1371
+
1055
1372
  public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
1373
+ try self.runBeforeDownload()
1056
1374
  let id = self.randomString(length: 10)
1057
1375
  logger.info("downloadManifest start \(id)")
1058
1376
  let destFolder = self.getBundleDirectory(id: id)
1059
- let builtinFolder = Bundle.main.bundleURL.appendingPathComponent("public")
1377
+ let builtinFolder = self.builtinFolderURL()
1060
1378
 
1061
1379
  // Check disk space before starting manifest download (estimate 100KB per file, minimum 50MB)
1062
1380
  let estimatedSize = Int64(max(manifest.count * 100 * 1024, 50 * 1024 * 1024))
1063
1381
  try checkDiskSpace(estimatedSize: estimatedSize)
1064
1382
 
1065
1383
  try FileManager.default.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
1384
+ cleanupOldManifestPartials()
1066
1385
  try FileManager.default.createDirectory(at: destFolder, withIntermediateDirectories: true, attributes: nil)
1067
1386
 
1068
1387
  // Create and save BundleInfo before starting the download process
@@ -1077,9 +1396,6 @@ import UIKit
1077
1396
 
1078
1397
  let totalFiles = manifest.count
1079
1398
 
1080
- // Keep this bounded because each manifest operation waits on a URLSession callback.
1081
- manifestDownloadQueue.maxConcurrentOperationCount = min(8, max(1, totalFiles))
1082
-
1083
1399
  // Thread-safe counters for concurrent operations
1084
1400
  let completedFiles = AtomicCounter()
1085
1401
  let hasError = AtomicBool(initialValue: false)
@@ -1146,8 +1462,12 @@ import UIKit
1146
1462
  let fileNameWithoutPath = (fileName as NSString).lastPathComponent
1147
1463
  let isBrotli = fileName.hasSuffix(".br")
1148
1464
  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
1465
+ let cacheFilePath: URL? = Self.isSafeCacheHash(finalFileHash)
1466
+ ? cacheFolder.appendingPathComponent("\(finalFileHash)_\(cacheBaseName)")
1467
+ : nil
1468
+ let legacyCacheFilePath: URL? = isBrotli && cacheFilePath != nil
1469
+ ? cacheFolder.appendingPathComponent("\(finalFileHash)_\(fileNameWithoutPath)")
1470
+ : nil
1151
1471
 
1152
1472
  let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
1153
1473
  let destFilePath: URL
@@ -1182,7 +1502,7 @@ import UIKit
1182
1502
  }
1183
1503
  // Try cache
1184
1504
  else if
1185
- self.tryCopyFromCache(from: cacheFilePath, to: destFilePath, expectedHash: finalFileHash) ||
1505
+ (cacheFilePath != nil && self.tryCopyFromCache(from: cacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) ||
1186
1506
  (legacyCacheFilePath != nil && self.tryCopyFromCache(from: legacyCacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) {
1187
1507
  self.logger.info("downloadManifest \(fileName) copy from cache \(id)")
1188
1508
  }
@@ -1253,7 +1573,7 @@ import UIKit
1253
1573
  private func downloadManifestFile(
1254
1574
  downloadUrl: String,
1255
1575
  destFilePath: URL,
1256
- cacheFilePath: URL,
1576
+ cacheFilePath: URL?,
1257
1577
  fileHash: String,
1258
1578
  fileName: String,
1259
1579
  destFileName: String,
@@ -1270,7 +1590,7 @@ import UIKit
1270
1590
  )
1271
1591
  }
1272
1592
 
1273
- guard let request = createRequest(url: url, method: "GET") else {
1593
+ guard var request = createRequest(url: url, method: "GET") else {
1274
1594
  throw NSError(
1275
1595
  domain: "ManifestDownloadError",
1276
1596
  code: 2,
@@ -1278,9 +1598,27 @@ import UIKit
1278
1598
  )
1279
1599
  }
1280
1600
 
1281
- let result = performRequest(request, label: "downloadManifestFile \(fileName)")
1601
+ try FileManager.default.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
1602
+ let partialURL = Self.manifestPartialURL(cacheFolder: cacheFolder, hash: fileHash, fileName: fileName)
1603
+ let existingBytes: Int64
1604
+ if FileManager.default.fileExists(atPath: partialURL.path) {
1605
+ existingBytes = Int64((try FileManager.default.attributesOfItem(atPath: partialURL.path)[.size] as? NSNumber)?.int64Value ?? 0)
1606
+ } else {
1607
+ existingBytes = 0
1608
+ }
1609
+ if existingBytes > 0 {
1610
+ request.setValue("bytes=\(existingBytes)-", forHTTPHeaderField: "Range")
1611
+ }
1612
+
1613
+ let result = performDownloadRequest(request, label: "downloadManifestFile \(fileName)")
1614
+ defer {
1615
+ if let fileURL = result.fileURL {
1616
+ try? FileManager.default.removeItem(at: fileURL)
1617
+ }
1618
+ }
1282
1619
 
1283
1620
  if result.timedOut {
1621
+ persistPartialDownload(result, id: bundleId, tempPath: partialURL, existingBytes: existingBytes)
1284
1622
  self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1285
1623
  throw NSError(
1286
1624
  domain: NSURLErrorDomain,
@@ -1290,13 +1628,32 @@ import UIKit
1290
1628
  }
1291
1629
 
1292
1630
  if let error = result.error {
1631
+ persistPartialDownload(result, id: bundleId, tempPath: partialURL, existingBytes: existingBytes)
1293
1632
  self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1294
1633
  self.logger.error("Manifest file download network error")
1295
1634
  self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
1296
1635
  throw error
1297
1636
  }
1298
1637
 
1299
- guard let data = result.data else {
1638
+ let statusCode = result.response?.statusCode ?? 200
1639
+ if statusCode == 416 && existingBytes > 0 {
1640
+ logger.debug("Range not satisfiable, using existing partial \(partialURL.lastPathComponent)")
1641
+ } else if statusCode < 200 || statusCode >= 300 {
1642
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1643
+ throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
1644
+ } else {
1645
+ guard let downloadedFileURL = result.fileURL, FileManager.default.fileExists(atPath: downloadedFileURL.path) else {
1646
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1647
+ throw NSError(
1648
+ domain: "ManifestDownloadError",
1649
+ code: 3,
1650
+ userInfo: [NSLocalizedDescriptionKey: "Manifest file response was empty for \(fileName) at url \(downloadUrl)"]
1651
+ )
1652
+ }
1653
+ try storeDownloadedFile(downloadedFileURL, at: partialURL, existingBytes: existingBytes, response: result.response)
1654
+ }
1655
+
1656
+ guard FileManager.default.fileExists(atPath: partialURL.path) else {
1300
1657
  self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1301
1658
  throw NSError(
1302
1659
  domain: "ManifestDownloadError",
@@ -1305,56 +1662,60 @@ import UIKit
1305
1662
  )
1306
1663
  }
1307
1664
 
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)"])
1665
+ var workURL: URL?
1666
+ defer {
1667
+ if let workURL {
1668
+ try? FileManager.default.removeItem(at: workURL)
1315
1669
  }
1316
1670
  }
1317
1671
 
1318
1672
  do {
1319
- // Add decryption step if public key is set and sessionKey is provided
1320
- var finalData = data
1673
+ var source = partialURL
1321
1674
  if !self.publicKey.isEmpty && !sessionKey.isEmpty {
1322
- let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
1323
- try finalData.write(to: tempFile)
1675
+ let work = cacheFolder.appendingPathComponent("work_\(UUID().uuidString)_\((fileName as NSString).lastPathComponent)")
1676
+ try FileManager.default.copyItem(at: partialURL, to: work)
1677
+ workURL = work
1324
1678
  do {
1325
- try CryptoCipher.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
1679
+ try CryptoCipher.decryptFile(filePath: work, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
1326
1680
  } catch {
1681
+ try? FileManager.default.removeItem(at: partialURL)
1327
1682
  self.sendStats(action: "decrypt_fail", versionName: version)
1328
1683
  throw error
1329
1684
  }
1330
- finalData = try Data(contentsOf: tempFile)
1331
- try FileManager.default.removeItem(at: tempFile)
1685
+ source = work
1332
1686
  }
1333
1687
 
1334
- // Decompress Brotli if needed
1688
+ let calculatedChecksum: String
1335
1689
  if isBrotli {
1336
- guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
1690
+ do {
1691
+ calculatedChecksum = try decompressBrotli(from: source, to: destFilePath, fileName: fileName)
1692
+ } catch {
1693
+ try? FileManager.default.removeItem(at: partialURL)
1337
1694
  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)"])
1695
+ throw error
1696
+ }
1697
+ } else {
1698
+ let handle = try FileHandle(forReadingFrom: source)
1699
+ defer {
1700
+ try? handle.close()
1339
1701
  }
1340
- finalData = decompressedData
1702
+ let length = (try FileManager.default.attributesOfItem(atPath: source.path)[.size] as? NSNumber)?.uint64Value ?? 0
1703
+ calculatedChecksum = try streamCopy(from: handle, count: length, to: destFilePath)
1341
1704
  }
1342
1705
 
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
1706
  CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
1349
1707
  CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
1350
1708
  if calculatedChecksum != fileHash {
1351
1709
  try? FileManager.default.removeItem(at: destFilePath)
1710
+ try? FileManager.default.removeItem(at: partialURL)
1352
1711
  self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
1353
1712
  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
1713
  }
1355
1714
 
1356
- // Save to cache (replace stale cache entries from partial or concurrent downloads)
1357
- try writeDataAtomically(finalData, to: cacheFilePath)
1715
+ if let cacheFilePath {
1716
+ try copyItemAtomically(from: destFilePath, to: cacheFilePath)
1717
+ }
1718
+ try? FileManager.default.removeItem(at: partialURL)
1358
1719
 
1359
1720
  self.logger.info("Manifest file downloaded and cached")
1360
1721
  self.logger.debug("Bundle: \(bundleId), File: \(fileName), Brotli: \(isBrotli), Encrypted: \(!self.publicKey.isEmpty && !sessionKey.isEmpty)")
@@ -1365,49 +1726,87 @@ import UIKit
1365
1726
  }
1366
1727
  }
1367
1728
 
1368
- /// Atomically write data to a file, replacing any existing file at the destination.
1369
- private func writeDataAtomically(_ data: Data, to destination: URL) throws {
1729
+ /// Copy a file to the destination, replacing any existing file.
1730
+ private func copyItemReplacing(from source: URL, to destination: URL) throws {
1370
1731
  let fileManager = FileManager.default
1732
+ try fileManager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
1733
+ if fileManager.fileExists(atPath: destination.path) {
1734
+ try fileManager.removeItem(at: destination)
1735
+ }
1736
+ try fileManager.copyItem(at: source, to: destination)
1737
+ }
1738
+
1739
+ func copyMatchingBuiltinFilesForTests(files: [(source: URL, dest: URL, hash: String)]) throws {
1740
+ let queue = OperationQueue()
1741
+ queue.maxConcurrentOperationCount = CapgoUpdater.manifestMaxConcurrentFiles
1742
+ let lock = NSLock()
1743
+ var firstError: Error?
1744
+ for file in files {
1745
+ queue.addOperation { [weak self] in
1746
+ guard let self else { return }
1747
+ do {
1748
+ guard self.verifyChecksum(file: file.source, expectedHash: file.hash) else {
1749
+ throw NSError(
1750
+ domain: "CapgoInstallPerf",
1751
+ code: 1,
1752
+ userInfo: [NSLocalizedDescriptionKey: "builtin checksum mismatch"]
1753
+ )
1754
+ }
1755
+ try self.copyItemReplacing(from: file.source, to: file.dest)
1756
+ } catch {
1757
+ lock.lock()
1758
+ if firstError == nil {
1759
+ firstError = error
1760
+ }
1761
+ lock.unlock()
1762
+ }
1763
+ }
1764
+ }
1765
+ queue.waitUntilAllOperationsAreFinished()
1766
+ if let firstError {
1767
+ throw firstError
1768
+ }
1769
+ }
1770
+
1771
+ /// Copy via a unique temp name then rename, so a crash cannot leave a
1772
+ /// non-empty partial file that `isReusableCacheFile` would trust.
1773
+ private func copyItemAtomically(from source: URL, to destination: URL) throws {
1774
+ let fileManager = FileManager.default
1775
+ try fileManager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
1371
1776
  let tempURL = destination.deletingLastPathComponent().appendingPathComponent("\(destination.lastPathComponent).\(UUID().uuidString).tmp")
1372
1777
  defer {
1373
1778
  try? fileManager.removeItem(at: tempURL)
1374
1779
  }
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)
1780
+ try fileManager.copyItem(at: source, to: tempURL)
1781
+ try replaceItemAtomically(at: destination, withItemAt: tempURL)
1381
1782
  }
1382
1783
 
1383
- /// Copy a file to the destination, replacing any existing file.
1384
- private func copyItemReplacing(from source: URL, to destination: URL) throws {
1784
+ /// One-step replace when dest exists, move when it does not. Avoids the
1785
+ /// fileExists/removeItem race that can fail a verified install.
1786
+ private func replaceItemAtomically(at destination: URL, withItemAt tempURL: URL) throws {
1385
1787
  let fileManager = FileManager.default
1386
- if fileManager.fileExists(atPath: destination.path) {
1387
- try fileManager.removeItem(at: destination)
1788
+ do {
1789
+ _ = try fileManager.replaceItemAt(destination, withItemAt: tempURL)
1790
+ } catch {
1791
+ if fileManager.fileExists(atPath: destination.path) {
1792
+ throw error
1793
+ }
1794
+ try fileManager.moveItem(at: tempURL, to: destination)
1388
1795
  }
1389
- try fileManager.copyItem(at: source, to: destination)
1390
1796
  }
1391
1797
 
1392
1798
  /// Atomically try to copy a file from cache - returns true if successful, false if file doesn't exist or copy failed
1393
1799
  /// This handles the race condition where OS can delete cache files between exists() check and copy
1394
1800
  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)
1801
+ // First quick check - if file doesn't exist or was truncated, don't bother
1802
+ guard isReusableCacheFile(source, expectedHash: expectedHash) else {
1405
1803
  return false
1406
1804
  }
1407
1805
 
1408
- // Try to copy - if it fails (file deleted by OS between check and copy), return false
1806
+ // Hash is in the cache file name and was verified when written.
1807
+ // Re-hashing here would re-read every reused file on low-RAM devices.
1409
1808
  do {
1410
- try copyItemReplacing(from: source, to: destination)
1809
+ try copyItemAtomically(from: source, to: destination)
1411
1810
  return true
1412
1811
  } catch {
1413
1812
  // File was deleted between check and copy, or other IO error - caller should download instead
@@ -1416,125 +1815,165 @@ import UIKit
1416
1815
  }
1417
1816
  }
1418
1817
 
1419
- private func decompressBrotli(data: Data, fileName: String) -> Data? {
1420
- // Handle empty files
1421
- if data.count == 0 {
1422
- return data
1818
+ /// Stream Brotli from disk to disk. Peek only the 3-byte header and last byte
1819
+ /// for the empty/wrapper special cases; never load the whole file.
1820
+ func decompressBrotli(from source: URL, to dest: URL, fileName: String) throws -> String {
1821
+ let fileManager = FileManager.default
1822
+ try fileManager.createDirectory(at: dest.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
1823
+ let length = (try fileManager.attributesOfItem(atPath: source.path)[.size] as? NSNumber)?.uint64Value ?? 0
1824
+ if length == 0 {
1825
+ try Data().write(to: dest, options: .atomic)
1826
+ return CryptoCipher.RunningChecksum().hex()
1827
+ }
1828
+
1829
+ let handle = try FileHandle(forReadingFrom: source)
1830
+ defer {
1831
+ try? handle.close()
1832
+ }
1833
+
1834
+ let head = try handle.read(upToCount: 3) ?? Data()
1835
+ var last: UInt8 = 0
1836
+ if length >= 1 {
1837
+ try handle.seek(toOffset: length - 1)
1838
+ last = try handle.read(upToCount: 1)?.first ?? 0
1423
1839
  }
1424
1840
 
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()
1841
+ if length == 3 && head.count == 3 && head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06 {
1842
+ try Data().write(to: dest, options: .atomic)
1843
+ return CryptoCipher.RunningChecksum().hex()
1428
1844
  }
1429
1845
 
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]
1846
+ if length > 3 && head.count == 3 && last == 0x03 {
1847
+ let isEmptyWrapper = head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06
1848
+ let isQualityZeroWrapper = head[0] == 0x0b && head[1] == 0x02 && head[2] == 0x80
1849
+ if isEmptyWrapper || isQualityZeroWrapper {
1850
+ try handle.seek(toOffset: 3)
1851
+ return try streamCopy(from: handle, count: length - 4, to: dest)
1438
1852
  }
1853
+ }
1854
+
1855
+ try handle.seek(toOffset: 0)
1856
+ return try streamBrotliDecode(from: handle, to: dest, fileName: fileName)
1857
+ }
1439
1858
 
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]
1859
+ func streamCopy(from handle: FileHandle, count: UInt64, to dest: URL) throws -> String {
1860
+ let fileManager = FileManager.default
1861
+ let tempURL = dest.deletingLastPathComponent().appendingPathComponent("capgo-br-\(UUID().uuidString).tmp")
1862
+ fileManager.createFile(atPath: tempURL.path, contents: nil)
1863
+ let output = try FileHandle(forWritingTo: tempURL)
1864
+ defer {
1865
+ try? output.close()
1866
+ try? fileManager.removeItem(at: tempURL)
1867
+ }
1868
+
1869
+ let hasher = CryptoCipher.RunningChecksum()
1870
+ var remaining = count
1871
+ let chunkSize = CryptoCipher.ioBufferBytes()
1872
+ while remaining > 0 {
1873
+ let readCount: Int = try autoreleasepool {
1874
+ let toRead = Int(min(UInt64(chunkSize), remaining))
1875
+ let chunk = try handle.read(upToCount: toRead) ?? Data()
1876
+ if !chunk.isEmpty {
1877
+ hasher.update(chunk)
1878
+ try output.write(contentsOf: chunk)
1879
+ }
1880
+ return chunk.count
1444
1881
  }
1882
+ if readCount == 0 {
1883
+ break
1884
+ }
1885
+ remaining -= UInt64(readCount)
1445
1886
  }
1887
+ try output.close()
1888
+ try replaceItemAtomically(at: dest, withItemAt: tempURL)
1889
+ return hasher.hex()
1890
+ }
1446
1891
 
1447
- // For all other cases, try standard decompression
1448
- let outputBufferSize = 65536
1449
- var outputBuffer = [UInt8](repeating: 0, count: outputBufferSize)
1450
- var decompressedData = Data()
1892
+ private func streamBrotliDecode(from handle: FileHandle, to dest: URL, fileName: String) throws -> String {
1893
+ let fileManager = FileManager.default
1894
+ let tempURL = dest.deletingLastPathComponent().appendingPathComponent("capgo-br-\(UUID().uuidString).tmp")
1895
+ fileManager.createFile(atPath: tempURL.path, contents: nil)
1896
+ let output = try FileHandle(forWritingTo: tempURL)
1897
+ defer {
1898
+ try? output.close()
1899
+ try? fileManager.removeItem(at: tempURL)
1900
+ }
1901
+ let hasher = CryptoCipher.RunningChecksum()
1902
+
1903
+ let chunkSize = max(CryptoCipher.ioBufferBytes(), 65536)
1904
+ var inputBuffer = [UInt8](repeating: 0, count: chunkSize)
1905
+ var outputBuffer = [UInt8](repeating: 0, count: chunkSize)
1451
1906
 
1452
1907
  let streamPointer = UnsafeMutablePointer<compression_stream>.allocate(capacity: 1)
1453
1908
  var status = compression_stream_init(streamPointer, COMPRESSION_STREAM_DECODE, COMPRESSION_BROTLI)
1454
-
1455
1909
  guard status != COMPRESSION_STATUS_ERROR else {
1456
1910
  logger.error("Failed to initialize Brotli stream")
1457
1911
  logger.debug("File: \(fileName), Status: \(status)")
1458
- return nil
1912
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to initialize Brotli stream for \(fileName)"])
1459
1913
  }
1460
-
1461
1914
  defer {
1462
1915
  compression_stream_destroy(streamPointer)
1463
1916
  streamPointer.deallocate()
1464
1917
  }
1465
1918
 
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
- }
1919
+ try inputBuffer.withUnsafeMutableBufferPointer { inBuf in
1920
+ try outputBuffer.withUnsafeMutableBufferPointer { outBuf in
1921
+ guard let inBase = inBuf.baseAddress, let outBase = outBuf.baseAddress else {
1922
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to get buffer address for \(fileName)"])
1484
1923
  }
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)
1924
+ streamPointer.pointee.src_size = 0
1925
+ streamPointer.pointee.dst_ptr = outBase
1926
+ streamPointer.pointee.dst_size = chunkSize
1927
+
1928
+ var flags: Int32 = 0
1929
+ var inputExhausted = false
1930
+ while true {
1931
+ if streamPointer.pointee.src_size == 0 && !inputExhausted {
1932
+ let chunk = try handle.read(upToCount: chunkSize) ?? Data()
1933
+ if chunk.isEmpty {
1934
+ inputExhausted = true
1935
+ flags = Int32(bitPattern: COMPRESSION_STREAM_FINALIZE.rawValue)
1936
+ } else {
1937
+ chunk.copyBytes(to: inBase, count: chunk.count)
1938
+ streamPointer.pointee.src_ptr = UnsafePointer(inBase)
1939
+ streamPointer.pointee.src_size = chunk.count
1940
+ }
1941
+ }
1496
1942
 
1497
- let have = outputBufferSize - streamPointer.pointee.dst_size
1498
- if have > 0 {
1499
- decompressedData.append(outputBuffer, count: have)
1500
- }
1943
+ status = compression_stream_process(streamPointer, flags)
1944
+ let have = chunkSize - streamPointer.pointee.dst_size
1945
+ if have > 0 {
1946
+ let decoded = Data(bytes: outBase, count: have)
1947
+ hasher.update(decoded)
1948
+ try output.write(contentsOf: decoded)
1949
+ }
1950
+ streamPointer.pointee.dst_ptr = outBase
1951
+ streamPointer.pointee.dst_size = chunkSize
1501
1952
 
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)")
1953
+ if status == COMPRESSION_STATUS_END {
1954
+ break
1955
+ }
1956
+ if status == COMPRESSION_STATUS_ERROR {
1957
+ logger.error("Brotli process failed")
1958
+ logger.debug("File: \(fileName), Status: \(status)")
1959
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName)"])
1960
+ }
1961
+ if inputExhausted && streamPointer.pointee.src_size == 0 && have == 0 {
1962
+ logger.error("Brotli decompression stalled")
1963
+ logger.debug("File: \(fileName)")
1964
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName)"])
1512
1965
  }
1513
1966
  }
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
1967
  }
1532
1968
  }
1533
1969
 
1534
- return status == COMPRESSION_STATUS_END ? decompressedData : nil
1970
+ try output.close()
1971
+ try replaceItemAtomically(at: dest, withItemAt: tempURL)
1972
+ return hasher.hex()
1535
1973
  }
1536
1974
 
1537
1975
  public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
1976
+ try self.runBeforeDownload()
1538
1977
  let id: String = self.randomString(length: 10)
1539
1978
  // Each download uses its own temp files keyed by bundle ID to prevent collisions
1540
1979
  if version != getLocalUpdateVersion(for: id) {
@@ -1811,6 +2250,9 @@ import UIKit
1811
2250
  }
1812
2251
 
1813
2252
  public func delete(id: String, removeInfo: Bool) -> Bool {
2253
+ self.deleteLock.lock()
2254
+ defer { self.deleteLock.unlock() }
2255
+
1814
2256
  let deleted: BundleInfo = self.getBundleInfo(id: id)
1815
2257
  if deleted.isBuiltin() || self.getCurrentBundleId() == id {
1816
2258
  logger.info("Cannot delete current or builtin bundle")
@@ -1821,6 +2263,7 @@ import UIKit
1821
2263
  if let previewFallback = self.getPreviewFallbackBundle(),
1822
2264
  !previewFallback.isDeleted(),
1823
2265
  !previewFallback.isErrorStatus(),
2266
+ !previewFallback.isDeleting(),
1824
2267
  previewFallback.getId() == id {
1825
2268
  logger.info("Cannot delete the preview fallback bundle")
1826
2269
  logger.debug("Bundle ID: \(id)")
@@ -1831,6 +2274,7 @@ import UIKit
1831
2274
  if let next = self.getNextBundle(),
1832
2275
  !next.isDeleted() &&
1833
2276
  !next.isErrorStatus() &&
2277
+ !next.isDeleting() &&
1834
2278
  next.getId() == id {
1835
2279
  logger.info("Cannot delete the next bundle")
1836
2280
  logger.debug("Bundle ID: \(id)")
@@ -1838,24 +2282,56 @@ import UIKit
1838
2282
  }
1839
2283
 
1840
2284
  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)
2285
+ let hadRegistry = self.hasStoredBundleInfo(id: id)
2286
+ let hadFolder = FileManager.default.fileExists(atPath: destPersist.path)
2287
+ if !hadRegistry && !hadFolder {
2288
+ logger.error("Cannot delete unknown bundle")
2289
+ logger.debug("Bundle ID: \(id)")
2290
+ self.dequeuePendingDelete(id: id)
2291
+ return false
2292
+ }
2293
+
2294
+ // Persist DELETING before touching disk so kill/OOM can resume on next launch.
2295
+ if !deleted.isDeleting() {
2296
+ if !self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETING.storedValue)) {
2297
+ logger.error("Failed to persist DELETING marker, aborting disk delete")
2298
+ logger.debug("Bundle ID: \(id)")
2299
+ return false
1849
2300
  }
1850
- self.sendStats(action: "delete", versionName: deleted.getVersionName())
2301
+ UserDefaults.standard.synchronize()
2302
+ }
2303
+
2304
+ if FileManager.default.fileExists(atPath: destPersist.path) {
2305
+ do {
2306
+ try FileManager.default.removeItem(atPath: destPersist.path)
2307
+ } catch {
2308
+ logger.error("Bundle folder not removed, will retry later")
2309
+ logger.debug("Path: \(destPersist.path), Error: \(error.localizedDescription)")
2310
+ return false
2311
+ }
2312
+ }
2313
+
2314
+ // Only drop registry after the folder is confirmed gone.
2315
+ if FileManager.default.fileExists(atPath: destPersist.path) {
2316
+ logger.error("Bundle folder still present after delete, will retry later")
2317
+ logger.debug("Bundle ID: \(id)")
1851
2318
  return false
1852
2319
  }
2320
+
2321
+ let finalized: Bool
1853
2322
  if removeInfo {
1854
- self.removeBundleInfo(id: id)
2323
+ finalized = self.saveBundleInfo(id: id, bundle: nil)
1855
2324
  } else {
1856
- self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.storedValue))
2325
+ finalized = self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.storedValue))
2326
+ }
2327
+ guard finalized else {
2328
+ logger.error("Failed to finalize delete registry update, will retry later")
2329
+ logger.debug("Bundle ID: \(id)")
2330
+ return false
1857
2331
  }
1858
- logger.info("Bundle deleted successfully")
2332
+ UserDefaults.standard.synchronize()
2333
+ self.dequeuePendingDelete(id: id)
2334
+ logger.info("Bundle deleted and confirmed gone")
1859
2335
  logger.debug("Version: \(deleted.getVersionName())")
1860
2336
  self.sendStats(action: "delete", versionName: deleted.getVersionName())
1861
2337
  return true
@@ -1865,6 +2341,51 @@ import UIKit
1865
2341
  return self.delete(id: id, removeInfo: true)
1866
2342
  }
1867
2343
 
2344
+ /// Resume incomplete deletes one-by-one. Safe across app kill / OOM because
2345
+ /// delete() marks DELETING before disk work and only clears registry after confirm.
2346
+ public func drainPendingDeletes() {
2347
+ var pendingIds = Set(self.list(raw: true).filter { $0.isDeleting() }.map { $0.getId() }.filter { !$0.isEmpty })
2348
+ pendingIds.formUnion(self.getPendingDeleteIds())
2349
+ for id in pendingIds {
2350
+ if Thread.current.isCancelled {
2351
+ logger.warn("drainPendingDeletes was cancelled")
2352
+ return
2353
+ }
2354
+ logger.info("Resuming pending delete for bundle: \(id)")
2355
+ if self.delete(id: id, removeInfo: true) {
2356
+ self.dequeuePendingDelete(id: id)
2357
+ }
2358
+ Thread.sleep(forTimeInterval: self.deletePaceSeconds)
2359
+ }
2360
+ }
2361
+
2362
+ private func getPendingDeleteIds() -> Set<String> {
2363
+ guard let raw = UserDefaults.standard.string(forKey: self.PENDING_DELETE_IDS), !raw.isEmpty else {
2364
+ return []
2365
+ }
2366
+ return Set(raw.split(separator: ",").map { String($0) }.filter { !$0.isEmpty })
2367
+ }
2368
+
2369
+ private func enqueuePendingDelete(id: String) {
2370
+ guard !id.isEmpty else { return }
2371
+ var ids = self.getPendingDeleteIds()
2372
+ guard ids.insert(id).inserted else { return }
2373
+ UserDefaults.standard.set(ids.sorted().joined(separator: ","), forKey: self.PENDING_DELETE_IDS)
2374
+ UserDefaults.standard.synchronize()
2375
+ }
2376
+
2377
+ private func dequeuePendingDelete(id: String) {
2378
+ guard !id.isEmpty else { return }
2379
+ var ids = self.getPendingDeleteIds()
2380
+ guard ids.remove(id) != nil else { return }
2381
+ if ids.isEmpty {
2382
+ UserDefaults.standard.removeObject(forKey: self.PENDING_DELETE_IDS)
2383
+ } else {
2384
+ UserDefaults.standard.set(ids.sorted().joined(separator: ","), forKey: self.PENDING_DELETE_IDS)
2385
+ }
2386
+ UserDefaults.standard.synchronize()
2387
+ }
2388
+
1868
2389
  public func cleanupDeltaCache() {
1869
2390
  cleanupDeltaCache(threadToCheck: nil)
1870
2391
  }
@@ -1924,6 +2445,11 @@ import UIKit
1924
2445
 
1925
2446
  do {
1926
2447
  try fileManager.removeItem(at: url)
2448
+ if fileManager.fileExists(atPath: url.path) {
2449
+ logger.error("Orphan bundle directory still present after delete")
2450
+ logger.debug("Bundle ID: \(id)")
2451
+ continue
2452
+ }
1927
2453
  self.removeBundleInfo(id: id)
1928
2454
  logger.info("Deleted orphan bundle directory")
1929
2455
  logger.debug("Bundle ID: \(id)")
@@ -1938,6 +2464,40 @@ import UIKit
1938
2464
  }
1939
2465
  }
1940
2466
 
2467
+ public func allowedBundleIdsForCleanup() -> Set<String> {
2468
+ var allowedIds = Set(self.list(raw: true).compactMap { info -> String? in
2469
+ let id = info.getId()
2470
+ // DELETED tombstones must not protect leftover folders.
2471
+ // DELETING stays protected so drainPendingDeletes owns the removal.
2472
+ if id.isEmpty || info.isDeleted() {
2473
+ return nil
2474
+ }
2475
+ return id
2476
+ })
2477
+ let currentId = self.getCurrentBundleId()
2478
+ if !currentId.isEmpty {
2479
+ allowedIds.insert(currentId)
2480
+ }
2481
+ let fallback = self.getFallbackBundle()
2482
+ let fallbackId = fallback.getId()
2483
+ if !fallbackId.isEmpty && !fallback.isDeleting() {
2484
+ allowedIds.insert(fallbackId)
2485
+ }
2486
+ if let next = self.getNextBundle() {
2487
+ let nextId = next.getId()
2488
+ if !nextId.isEmpty && !next.isDeleting() {
2489
+ allowedIds.insert(nextId)
2490
+ }
2491
+ }
2492
+ if let previewFallback = self.getPreviewFallbackBundle() {
2493
+ let previewId = previewFallback.getId()
2494
+ if !previewId.isEmpty && !previewFallback.isDeleting() {
2495
+ allowedIds.insert(previewId)
2496
+ }
2497
+ }
2498
+ return allowedIds
2499
+ }
2500
+
1941
2501
  public func cleanupOrphanedTempFolders(threadToCheck: Thread?) {
1942
2502
  let fileManager = FileManager.default
1943
2503
 
@@ -1977,11 +2537,16 @@ import UIKit
1977
2537
  logger.debug("Error: \(error.localizedDescription)")
1978
2538
  }
1979
2539
 
2540
+ if let thread = threadToCheck, thread.isCancelled {
2541
+ logger.warn("cleanupOrphanedTempFolders was cancelled")
2542
+ return
2543
+ }
2544
+
1980
2545
  // Also cleanup old download temp files (package_*.tmp and update_*.dat)
1981
- cleanupOldDownloadTempFiles()
2546
+ cleanupOldDownloadTempFiles(threadToCheck: threadToCheck)
1982
2547
  }
1983
2548
 
1984
- private func cleanupOldDownloadTempFiles() {
2549
+ private func cleanupOldDownloadTempFiles(threadToCheck: Thread? = nil) {
1985
2550
  let fileManager = FileManager.default
1986
2551
  guard let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
1987
2552
  return
@@ -1992,6 +2557,10 @@ import UIKit
1992
2557
  let oneHourAgo = Date().addingTimeInterval(-3600)
1993
2558
 
1994
2559
  for url in contents {
2560
+ if let thread = threadToCheck, thread.isCancelled {
2561
+ logger.warn("cleanupOldDownloadTempFiles was cancelled")
2562
+ return
2563
+ }
1995
2564
  let fileName = url.lastPathComponent
1996
2565
  // Only cleanup package_*.tmp and update_*.dat files
1997
2566
  let isDownloadTemp = (fileName.hasPrefix("package_") && fileName.hasSuffix(".tmp")) ||
@@ -2079,7 +2648,8 @@ import UIKit
2079
2648
  destPersist.isDirectory &&
2080
2649
  !indexPersist.isDirectory &&
2081
2650
  indexPersist.exist &&
2082
- !bundleIndo.isDeleted() {
2651
+ !bundleIndo.isDeleted() &&
2652
+ !bundleIndo.isDeleting() {
2083
2653
  return true
2084
2654
  }
2085
2655
  return false
@@ -2169,17 +2739,39 @@ import UIKit
2169
2739
  let fallbackIsPreviewFallback = previewFallback?.getId() == fallback.getId()
2170
2740
  logger.info("Fallback bundle is: \(fallback.toString())")
2171
2741
  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())")
2742
+ let previousFallbackId = fallback.getId()
2743
+ let nextBundle = self.getNextBundle()
2744
+ let previousIsNext = nextBundle?.getId() == previousFallbackId &&
2745
+ !(nextBundle?.isDeleted() ?? true) &&
2746
+ !(nextBundle?.isErrorStatus() ?? true) &&
2747
+ !(nextBundle?.isDeleting() ?? true)
2748
+ let shouldDeletePrevious = autoDeletePrevious &&
2749
+ !fallback.isBuiltin() &&
2750
+ previousFallbackId != bundle.getId() &&
2751
+ !fallbackIsPreviewFallback &&
2752
+ !previousIsNext
2753
+ if shouldDeletePrevious {
2754
+ // Mark durable intent before fallback switch so a kill mid-flight still retries.
2755
+ if !self.saveBundleInfo(id: previousFallbackId, bundle: fallback.setStatus(status: BundleStatus.DELETING.storedValue)) {
2756
+ self.logger.error("Failed to persist DELETING for previous bundle; queueing durable retry")
2757
+ self.logger.debug("Bundle ID: \(previousFallbackId)")
2758
+ self.enqueuePendingDelete(id: previousFallbackId)
2180
2759
  }
2760
+ UserDefaults.standard.synchronize()
2181
2761
  }
2182
2762
  self.setFallbackBundle(fallback: bundle)
2763
+ if shouldDeletePrevious {
2764
+ DispatchQueue.global(qos: .utility).async {
2765
+ let res = self.delete(id: previousFallbackId)
2766
+ if res {
2767
+ self.logger.info("Deleted previous bundle")
2768
+ self.logger.debug("Bundle ID: \(previousFallbackId)")
2769
+ } else {
2770
+ self.logger.info("Previous bundle delete incomplete, will retry")
2771
+ self.logger.debug("Bundle ID: \(previousFallbackId)")
2772
+ }
2773
+ }
2774
+ }
2183
2775
  }
2184
2776
 
2185
2777
  public func setError(bundle: BundleInfo) {
@@ -2216,11 +2808,11 @@ import UIKit
2216
2808
  return setChannel
2217
2809
  }
2218
2810
 
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"
2811
+ if isRemoteBlocked() {
2812
+ let blocked = remoteBlockedClientError()
2813
+ logger.debug("Skipping setChannel due to remote block (\(blocked.error)).")
2814
+ setChannel.message = blocked.message
2815
+ setChannel.error = blocked.error
2224
2816
  return setChannel
2225
2817
  }
2226
2818
 
@@ -2245,9 +2837,14 @@ import UIKit
2245
2837
 
2246
2838
  let result = performRequest(request, label: "setChannel")
2247
2839
 
2248
- if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
2249
- setChannel.message = "Rate limit exceeded"
2250
- setChannel.error = "rate_limit_exceeded"
2840
+ let rateLimit = self.checkAndHandleRateLimitResponse(
2841
+ statusCode: result.response?.statusCode,
2842
+ data: result.data,
2843
+ response: result.response
2844
+ )
2845
+ if rateLimit.blocked {
2846
+ setChannel.message = rateLimit.message
2847
+ setChannel.error = rateLimit.error
2251
2848
  return setChannel
2252
2849
  }
2253
2850
 
@@ -2289,7 +2886,8 @@ import UIKit
2289
2886
  self.logger.info("Public channel requested, channel override removed")
2290
2887
 
2291
2888
  setChannel.status = responseValue.status ?? "ok"
2292
- setChannel.message = responseValue.message ?? "Public channel requested, channel override removed. Device will use public channel automatically."
2889
+ setChannel.message = responseValue.message
2890
+ ?? "Public channel requested, channel override removed. Device will use public channel automatically."
2293
2891
  } else {
2294
2892
  self.defaultChannel = channel
2295
2893
  UserDefaults.standard.set(channel, forKey: defaultChannelKey)
@@ -2304,12 +2902,12 @@ import UIKit
2304
2902
 
2305
2903
  func getChannel(defaultChannelKey: String? = nil) -> GetChannel {
2306
2904
  let getChannel: GetChannel = GetChannel()
2307
-
2308
2905
  // 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"
2906
+ if isRemoteBlocked() {
2907
+ let blocked = remoteBlockedClientError()
2908
+ logger.debug("Skipping getChannel due to remote block (\(blocked.error)).")
2909
+ getChannel.message = blocked.message
2910
+ getChannel.error = blocked.error
2313
2911
  return getChannel
2314
2912
  }
2315
2913
 
@@ -2333,9 +2931,14 @@ import UIKit
2333
2931
 
2334
2932
  let result = performRequest(request, label: "getChannel")
2335
2933
 
2336
- if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
2337
- getChannel.message = "Rate limit exceeded"
2338
- getChannel.error = "rate_limit_exceeded"
2934
+ let rateLimit = self.checkAndHandleRateLimitResponse(
2935
+ statusCode: result.response?.statusCode,
2936
+ data: result.data,
2937
+ response: result.response
2938
+ )
2939
+ if rateLimit.blocked {
2940
+ getChannel.message = rateLimit.message
2941
+ getChannel.error = rateLimit.error
2339
2942
  return getChannel
2340
2943
  }
2341
2944
 
@@ -2413,9 +3016,10 @@ import UIKit
2413
3016
  let listChannels: ListChannels = ListChannels()
2414
3017
 
2415
3018
  // 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"
3019
+ if isRemoteBlocked() {
3020
+ let blocked = remoteBlockedClientError()
3021
+ logger.debug("Skipping listChannels due to remote block (\(blocked.error)).")
3022
+ listChannels.error = blocked.error
2419
3023
  return listChannels
2420
3024
  }
2421
3025
 
@@ -2449,8 +3053,13 @@ import UIKit
2449
3053
 
2450
3054
  let result = performRequest(request, label: "listChannels")
2451
3055
 
2452
- if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
2453
- listChannels.error = "rate_limit_exceeded"
3056
+ let rateLimit = self.checkAndHandleRateLimitResponse(
3057
+ statusCode: result.response?.statusCode,
3058
+ data: result.data,
3059
+ response: result.response
3060
+ )
3061
+ if rateLimit.blocked {
3062
+ listChannels.error = rateLimit.error
2454
3063
  return listChannels
2455
3064
  }
2456
3065
 
@@ -2507,6 +3116,7 @@ import UIKit
2507
3116
  let queue = OperationQueue()
2508
3117
  queue.name = "com.capgo.manifestDownload"
2509
3118
  queue.qualityOfService = .userInitiated
3119
+ queue.maxConcurrentOperationCount = CapgoUpdater.manifestMaxConcurrentFiles
2510
3120
  return queue
2511
3121
  }()
2512
3122
 
@@ -2529,14 +3139,12 @@ import UIKit
2529
3139
  metadata: [String: String]?,
2530
3140
  onSent: (() -> Void)?
2531
3141
  ) {
2532
- if previewSession {
2533
- logger.debug("Skipping sendStats during preview session.")
3142
+ if statsStopped {
2534
3143
  return
2535
3144
  }
2536
3145
 
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.")
3146
+ if previewSession {
3147
+ logger.debug("Skipping sendStats during preview session.")
2540
3148
  return
2541
3149
  }
2542
3150
 
@@ -2570,15 +3178,90 @@ import UIKit
2570
3178
  )
2571
3179
 
2572
3180
  statsQueueLock.lock()
3181
+ if statsStopped {
3182
+ statsQueueLock.unlock()
3183
+ return
3184
+ }
3185
+ if statsQueue.count >= CapgoUpdater.maxPendingStats {
3186
+ statsQueue.removeFirst(statsQueue.count - CapgoUpdater.maxPendingStats + 1)
3187
+ }
2573
3188
  statsQueue.append(QueuedStatsEvent(event: event, onSent: onSent))
2574
3189
  statsQueueLock.unlock()
2575
3190
 
2576
3191
  ensureStatsTimerStarted()
2577
3192
  }
2578
3193
 
3194
+ func restorePendingStats() {
3195
+ let fileURL = pendingStatsFileURL()
3196
+ guard FileManager.default.fileExists(atPath: fileURL.path),
3197
+ let data = try? Data(contentsOf: fileURL),
3198
+ let events = try? JSONDecoder().decode([StatsEvent].self, from: data) else {
3199
+ return
3200
+ }
3201
+
3202
+ statsQueueLock.lock()
3203
+ for event in events {
3204
+ if statsQueue.count >= CapgoUpdater.maxPendingStats {
3205
+ break
3206
+ }
3207
+ statsQueue.append(QueuedStatsEvent(event: event, onSent: nil))
3208
+ }
3209
+ let restoredCount = statsQueue.count
3210
+ statsQueueLock.unlock()
3211
+
3212
+ if restoredCount > 0 {
3213
+ logger.info("Restored \(restoredCount) pending stats events")
3214
+ ensureStatsTimerStarted()
3215
+ }
3216
+ }
3217
+
3218
+ func persistPendingStats() {
3219
+ persistStatsQueue()
3220
+ }
3221
+
3222
+ private func pendingStatsFileURL() -> URL {
3223
+ libraryDir.appendingPathComponent(pendingStatsFileName)
3224
+ }
3225
+
3226
+ private func persistStatsQueue(force: Bool = false) {
3227
+ statsPersistLock.lock()
3228
+ defer { statsPersistLock.unlock() }
3229
+ if statsStopped && !force {
3230
+ return
3231
+ }
3232
+
3233
+ statsQueueLock.lock()
3234
+ var events = statsInFlight.map(\.event) + statsQueue.map(\.event)
3235
+ statsQueueLock.unlock()
3236
+ if events.count > CapgoUpdater.maxPendingStats {
3237
+ events = Array(events.suffix(CapgoUpdater.maxPendingStats))
3238
+ }
3239
+
3240
+ let fileURL = pendingStatsFileURL()
3241
+ if events.isEmpty {
3242
+ try? FileManager.default.removeItem(at: fileURL)
3243
+ return
3244
+ }
3245
+
3246
+ do {
3247
+ let data = try JSONEncoder().encode(events)
3248
+ try data.write(to: fileURL, options: .atomic)
3249
+ var resourceURL = fileURL
3250
+ var values = URLResourceValues()
3251
+ values.isExcludedFromBackup = true
3252
+ try resourceURL.setResourceValues(values)
3253
+ } catch {
3254
+ logger.error("Failed to persist stats queue")
3255
+ logger.debug("Error: \(error.localizedDescription)")
3256
+ }
3257
+ }
3258
+
2579
3259
  private func ensureStatsTimerStarted() {
3260
+ if statsStopped {
3261
+ return
3262
+ }
2580
3263
  DispatchQueue.main.async { [weak self] in
2581
- guard let self = self else { return }
3264
+ guard let self = self, !self.statsStopped else { return }
2582
3265
  if self.statsFlushTimer == nil || !self.statsFlushTimer!.isValid {
2583
3266
  // Use closure-based timer to avoid strong reference cycle
2584
3267
  self.statsFlushTimer = Timer.scheduledTimer(
@@ -2592,17 +3275,27 @@ import UIKit
2592
3275
  }
2593
3276
 
2594
3277
  private func flushStatsQueue() {
3278
+ if statsStopped {
3279
+ return
3280
+ }
3281
+ // While Retry-After is active, keep stats queued and skip the network call.
3282
+ if isRemoteBlocked() {
3283
+ logger.debug("Deferring stats flush until Retry-After expires.")
3284
+ return
3285
+ }
3286
+
2595
3287
  statsQueueLock.lock()
2596
- guard !statsQueue.isEmpty else {
3288
+ guard statsInFlight.isEmpty, !statsQueue.isEmpty else {
2597
3289
  statsQueueLock.unlock()
2598
3290
  return
2599
3291
  }
2600
3292
  let queuedEvents = statsQueue
2601
3293
  statsQueue.removeAll()
3294
+ statsInFlight = queuedEvents
2602
3295
  statsQueueLock.unlock()
3296
+ persistStatsQueue()
2603
3297
 
2604
3298
  let eventsToSend = queuedEvents.map(\.event)
2605
- let onSentCallbacks = queuedEvents.compactMap(\.onSent)
2606
3299
 
2607
3300
  operationQueue.maxConcurrentOperationCount = 1
2608
3301
 
@@ -2615,35 +3308,85 @@ import UIKit
2615
3308
  encoder: JSONParameterEncoder.default,
2616
3309
  requestModifier: { $0.timeoutInterval = self.timeout }
2617
3310
  ).responseData { response in
2618
- // Check for 429 rate limit
2619
- if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
3311
+ if self.abandonStoppedStatsFlush() {
3312
+ semaphore.signal()
3313
+ return
3314
+ }
3315
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode, data: response.data, response: response.response).blocked {
3316
+ self.requeueStatsEvents(queuedEvents)
2620
3317
  semaphore.signal()
2621
3318
  return
2622
3319
  }
2623
3320
 
2624
3321
  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)")
3322
+ if CapgoUpdater.isTransientStatsFailure(statusCode) {
3323
+ self.requeueStatsEvents(queuedEvents)
3324
+ self.logger.error("Error sending stats batch")
3325
+ self.logger.debug("Retrying later, response code: \(statusCode)")
3326
+ } else {
3327
+ self.clearStatsInFlight()
3328
+ self.logger.error("Dropping stats batch after permanent error")
3329
+ self.logger.debug("Response code: \(statusCode)")
3330
+ }
2627
3331
  semaphore.signal()
2628
3332
  return
2629
3333
  }
2630
3334
 
2631
3335
  switch response.result {
2632
3336
  case .success:
3337
+ self.clearStatsInFlight()
2633
3338
  self.logger.info("Stats batch sent successfully")
2634
3339
  self.logger.debug("Sent \(eventsToSend.count) events")
2635
- onSentCallbacks.forEach { $0() }
3340
+ self.runStatsCallbacks(queuedEvents)
2636
3341
  case let .failure(error):
3342
+ self.requeueStatsEvents(queuedEvents)
2637
3343
  self.logger.error("Error sending stats batch")
2638
3344
  self.logger.debug("Response: \(response.value?.debugDescription ?? "nil"), Error: \(error.localizedDescription)")
2639
3345
  }
2640
3346
  semaphore.signal()
2641
3347
  }
2642
3348
  semaphore.wait()
3349
+ if !self.statsStopped {
3350
+ self.persistStatsQueue()
3351
+ }
2643
3352
  }
2644
3353
  operationQueue.addOperation(operation)
2645
3354
  }
2646
3355
 
3356
+ private func abandonStoppedStatsFlush() -> Bool {
3357
+ statsStopped
3358
+ }
3359
+
3360
+ /// Only 429, request timeout and 5xx are worth retrying; other 4xx are permanent rejections.
3361
+ private static func isTransientStatsFailure(_ statusCode: Int) -> Bool {
3362
+ return statusCode == 429 || statusCode == 408 || statusCode >= 500
3363
+ }
3364
+
3365
+ private func runStatsCallbacks(_ sentEvents: [QueuedStatsEvent]) {
3366
+ for sentEvent in sentEvents {
3367
+ sentEvent.onSent?()
3368
+ }
3369
+ }
3370
+
3371
+ private func requeueStatsEvents(_ events: [QueuedStatsEvent]) {
3372
+ guard !statsStopped, !events.isEmpty else { return }
3373
+ statsQueueLock.lock()
3374
+ statsInFlight.removeAll()
3375
+ statsQueue.insert(contentsOf: events, at: 0)
3376
+ if statsQueue.count > CapgoUpdater.maxPendingStats {
3377
+ statsQueue.removeFirst(statsQueue.count - CapgoUpdater.maxPendingStats)
3378
+ }
3379
+ statsQueueLock.unlock()
3380
+ persistStatsQueue()
3381
+ ensureStatsTimerStarted()
3382
+ }
3383
+
3384
+ private func clearStatsInFlight() {
3385
+ statsQueueLock.lock()
3386
+ statsInFlight.removeAll()
3387
+ statsQueueLock.unlock()
3388
+ }
3389
+
2647
3390
  public func getBundleInfo(id: String?) -> BundleInfo {
2648
3391
  var trueId = BundleInfo.VERSION_UNKNOWN
2649
3392
  if id != nil {
@@ -2680,23 +3423,26 @@ import UIKit
2680
3423
  self.saveBundleInfo(id: id, bundle: nil)
2681
3424
  }
2682
3425
 
2683
- public func saveBundleInfo(id: String, bundle: BundleInfo?) {
3426
+ @discardableResult
3427
+ public func saveBundleInfo(id: String, bundle: BundleInfo?) -> Bool {
2684
3428
  if bundle != nil && (bundle!.isBuiltin() || bundle!.isUnknown()) {
2685
3429
  logger.info("Not saving info for bundle [\(id)] \(bundle?.toString() ?? "")")
2686
- return
3430
+ return false
2687
3431
  }
2688
3432
  if bundle == nil {
2689
3433
  logger.info("Removing info for bundle [\(id)]")
2690
3434
  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
- }
3435
+ return true
3436
+ }
3437
+ let update = bundle!.setId(id: id)
3438
+ logger.info("Storing info for bundle [\(id)] \(update.toString())")
3439
+ do {
3440
+ try UserDefaults.standard.setObj(update, forKey: "\(id)\(self.INFO_SUFFIX)")
3441
+ return true
3442
+ } catch {
3443
+ logger.error("Failed to save bundle info")
3444
+ logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
3445
+ return false
2700
3446
  }
2701
3447
  }
2702
3448