@capgo/capacitor-updater 8.51.7 → 8.51.9

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.
@@ -25,6 +25,9 @@ import UIKit
25
25
  private let PENDING_DELETE_IDS: String = "pendingDeleteIds"
26
26
  private var unzipPercent = 0
27
27
  private let TEMP_UNZIP_PREFIX: String = "capgo_unzip_"
28
+ /// Match URLSession per-host limit so 64 workers actually fetch in parallel.
29
+ private static let manifestMaxConcurrentFiles = 64
30
+ private static let emptySha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
28
31
  private let deletePaceSeconds: TimeInterval = 0.075
29
32
  private let deleteLock = NSLock()
30
33
 
@@ -172,6 +175,7 @@ import UIKit
172
175
  configuration.httpShouldSetCookies = false
173
176
  configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
174
177
  configuration.urlCache = nil
178
+ configuration.httpMaximumConnectionsPerHost = Self.manifestMaxConcurrentFiles
175
179
  return Session(configuration: configuration)
176
180
  }()
177
181
  private let networkResponseQueue = DispatchQueue(label: "ee.forgr.capacitor-updater.network-response", qos: .utility)
@@ -300,11 +304,29 @@ import UIKit
300
304
  private func storeDownloadedFile(_ downloadedFileURL: URL, at tempPath: URL, existingBytes: Int64, response: HTTPURLResponse?) throws {
301
305
  let fileManager = FileManager.default
302
306
  if existingBytes > 0 && (response?.statusCode == 206 || response == nil) {
303
- let resumedData = try Data(contentsOf: downloadedFileURL)
304
307
  let fileHandle = try FileHandle(forWritingTo: tempPath)
308
+ defer {
309
+ try? fileHandle.close()
310
+ }
305
311
  fileHandle.seek(toFileOffset: UInt64(existingBytes))
306
- fileHandle.write(resumedData)
307
- try fileHandle.close()
312
+ let input = try FileHandle(forReadingFrom: downloadedFileURL)
313
+ defer {
314
+ try? input.close()
315
+ }
316
+ let chunkSize = CryptoCipher.copyBufferBytes()
317
+ while true {
318
+ let done: Bool = try autoreleasepool {
319
+ let chunk = try input.read(upToCount: chunkSize) ?? Data()
320
+ if chunk.isEmpty {
321
+ return true
322
+ }
323
+ fileHandle.write(chunk)
324
+ return false
325
+ }
326
+ if done {
327
+ break
328
+ }
329
+ }
308
330
  try? fileManager.removeItem(at: downloadedFileURL)
309
331
  return
310
332
  }
@@ -943,7 +965,7 @@ import UIKit
943
965
  }
944
966
 
945
967
  do {
946
- try fileManager.copyItem(at: fileURL, to: cacheFile)
968
+ try copyItemAtomically(from: fileURL, to: cacheFile)
947
969
  } catch {
948
970
  logger.debug("Delta cache copy failed: \(fileURL.path)")
949
971
  }
@@ -1174,21 +1196,52 @@ import UIKit
1174
1196
  let fileNameWithoutPath = (fileName as NSString).lastPathComponent
1175
1197
  let isBrotli = fileName.hasSuffix(".br")
1176
1198
  let cacheBaseName = isBrotli ? String(fileNameWithoutPath.dropLast(3)) : fileNameWithoutPath
1177
- let cacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(cacheBaseName)")
1178
- if FileManager.default.fileExists(atPath: cacheFilePath.path) && verifyChecksum(file: cacheFilePath, expectedHash: fileHash) {
1179
- return true
1180
- }
1181
-
1182
- if isBrotli {
1183
- let legacyCacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(fileNameWithoutPath)")
1184
- if FileManager.default.fileExists(atPath: legacyCacheFilePath.path) && verifyChecksum(file: legacyCacheFilePath, expectedHash: fileHash) {
1199
+ if Self.isSafeCacheHash(fileHash) {
1200
+ let cacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(cacheBaseName)")
1201
+ // Cache files are named `{hash}_{filename}` and were checksum-verified
1202
+ // when written. Re-hashing every hit re-reads the whole bundle and
1203
+ // OOMs/janks low-RAM devices during getMissing / delta apply.
1204
+ if isReusableCacheFile(cacheFilePath, expectedHash: fileHash) {
1185
1205
  return true
1186
1206
  }
1207
+
1208
+ if isBrotli {
1209
+ let legacyCacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(fileNameWithoutPath)")
1210
+ if isReusableCacheFile(legacyCacheFilePath, expectedHash: fileHash) {
1211
+ return true
1212
+ }
1213
+ }
1187
1214
  }
1188
1215
 
1189
1216
  return false
1190
1217
  }
1191
1218
 
1219
+ /// SHA-256 hash-named cache files were verified when written. Existence is
1220
+ /// enough for non-empty files; empty files are reused only for the empty SHA-256.
1221
+ /// CRC32 (8 hex) is too collision-prone to trust without a re-read.
1222
+ private func isReusableCacheFile(_ url: URL, expectedHash: String) -> Bool {
1223
+ guard Self.isSafeCacheHash(expectedHash), expectedHash.count == 64 else {
1224
+ return false
1225
+ }
1226
+ let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? -1
1227
+ if size > 0 {
1228
+ return true
1229
+ }
1230
+ return size == 0 && expectedHash.lowercased() == Self.emptySha256
1231
+ }
1232
+
1233
+ static func isSafeCacheHash(_ hash: String) -> Bool {
1234
+ let count = hash.count
1235
+ guard count == 64 || count == 8 else {
1236
+ return false
1237
+ }
1238
+ return hash.unicodeScalars.allSatisfy { scalar in
1239
+ (0x30...0x39).contains(scalar.value) ||
1240
+ (0x41...0x46).contains(scalar.value) ||
1241
+ (0x61...0x66).contains(scalar.value)
1242
+ }
1243
+ }
1244
+
1192
1245
  public func getMissingBundleFiles(manifest: [ManifestEntry], sessionKey: String) -> [ManifestEntry] {
1193
1246
  return manifest.filter { entry in
1194
1247
  !isManifestEntryAvailableLocally(entry: entry, sessionKey: sessionKey)
@@ -1299,9 +1352,6 @@ import UIKit
1299
1352
 
1300
1353
  let totalFiles = manifest.count
1301
1354
 
1302
- // Configure concurrent operation count similar to Android: min(64, max(32, totalFiles))
1303
- manifestDownloadQueue.maxConcurrentOperationCount = min(64, max(32, totalFiles))
1304
-
1305
1355
  // Thread-safe counters for concurrent operations
1306
1356
  let completedFiles = AtomicCounter()
1307
1357
  let hasError = AtomicBool(initialValue: false)
@@ -1368,8 +1418,12 @@ import UIKit
1368
1418
  let fileNameWithoutPath = (fileName as NSString).lastPathComponent
1369
1419
  let isBrotli = fileName.hasSuffix(".br")
1370
1420
  let cacheBaseName = isBrotli ? String(fileNameWithoutPath.dropLast(3)) : fileNameWithoutPath
1371
- let cacheFilePath = cacheFolder.appendingPathComponent("\(finalFileHash)_\(cacheBaseName)")
1372
- let legacyCacheFilePath: URL? = isBrotli ? cacheFolder.appendingPathComponent("\(finalFileHash)_\(fileNameWithoutPath)") : nil
1421
+ let cacheFilePath: URL? = Self.isSafeCacheHash(finalFileHash)
1422
+ ? cacheFolder.appendingPathComponent("\(finalFileHash)_\(cacheBaseName)")
1423
+ : nil
1424
+ let legacyCacheFilePath: URL? = isBrotli && cacheFilePath != nil
1425
+ ? cacheFolder.appendingPathComponent("\(finalFileHash)_\(fileNameWithoutPath)")
1426
+ : nil
1373
1427
 
1374
1428
  let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
1375
1429
  let destFilePath: URL
@@ -1404,7 +1458,7 @@ import UIKit
1404
1458
  }
1405
1459
  // Try cache
1406
1460
  else if
1407
- self.tryCopyFromCache(from: cacheFilePath, to: destFilePath, expectedHash: finalFileHash) ||
1461
+ (cacheFilePath != nil && self.tryCopyFromCache(from: cacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) ||
1408
1462
  (legacyCacheFilePath != nil && self.tryCopyFromCache(from: legacyCacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) {
1409
1463
  self.logger.info("downloadManifest \(fileName) copy from cache \(id)")
1410
1464
  }
@@ -1465,8 +1519,6 @@ import UIKit
1465
1519
  // Send stats for manifest download complete
1466
1520
  self.sendStats(action: "download_manifest_complete", versionName: version)
1467
1521
 
1468
- self.populateDeltaCacheAsync(for: id, manifest: manifest, sessionKey: sessionKey)
1469
-
1470
1522
  self.notifyDownload(id: id, percent: 100, bundle: updatedBundle)
1471
1523
  logger.info("downloadManifest done \(id)")
1472
1524
  return updatedBundle
@@ -1477,7 +1529,7 @@ import UIKit
1477
1529
  private func downloadManifestFile(
1478
1530
  downloadUrl: String,
1479
1531
  destFilePath: URL,
1480
- cacheFilePath: URL,
1532
+ cacheFilePath: URL?,
1481
1533
  fileHash: String,
1482
1534
  fileName: String,
1483
1535
  destFileName: String,
@@ -1502,7 +1554,12 @@ import UIKit
1502
1554
  )
1503
1555
  }
1504
1556
 
1505
- let result = performRequest(request, label: "downloadManifestFile \(fileName)")
1557
+ let result = performDownloadRequest(request, label: "downloadManifestFile \(fileName)")
1558
+ defer {
1559
+ if let fileURL = result.fileURL {
1560
+ try? FileManager.default.removeItem(at: fileURL)
1561
+ }
1562
+ }
1506
1563
 
1507
1564
  if result.timedOut {
1508
1565
  self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
@@ -1520,7 +1577,13 @@ import UIKit
1520
1577
  throw error
1521
1578
  }
1522
1579
 
1523
- guard let data = result.data else {
1580
+ let statusCode = result.response?.statusCode ?? 200
1581
+ if statusCode < 200 || statusCode >= 300 {
1582
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1583
+ throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
1584
+ }
1585
+
1586
+ guard let downloadedFileURL = result.fileURL, FileManager.default.fileExists(atPath: downloadedFileURL.path) else {
1524
1587
  self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1525
1588
  throw NSError(
1526
1589
  domain: "ManifestDownloadError",
@@ -1529,44 +1592,28 @@ import UIKit
1529
1592
  )
1530
1593
  }
1531
1594
 
1532
- let statusCode = result.response?.statusCode ?? 200
1533
- if statusCode < 200 || statusCode >= 300 {
1534
- self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1535
- if let stringData = String(data: data, encoding: .utf8) {
1536
- throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid. Data: \(stringData) for file \(fileName) at url \(downloadUrl)"])
1537
- } else {
1538
- throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
1539
- }
1540
- }
1541
-
1542
1595
  do {
1543
- // Add decryption step if public key is set and sessionKey is provided
1544
- var finalData = data
1596
+ // Decrypt in place when a session key is present streamed, not a whole-file copy.
1545
1597
  if !self.publicKey.isEmpty && !sessionKey.isEmpty {
1546
- let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
1547
- try finalData.write(to: tempFile)
1548
1598
  do {
1549
- try CryptoCipher.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
1599
+ try CryptoCipher.decryptFile(filePath: downloadedFileURL, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
1550
1600
  } catch {
1551
1601
  self.sendStats(action: "decrypt_fail", versionName: version)
1552
1602
  throw error
1553
1603
  }
1554
- finalData = try Data(contentsOf: tempFile)
1555
- try FileManager.default.removeItem(at: tempFile)
1556
1604
  }
1557
1605
 
1558
- // Decompress Brotli if needed
1559
1606
  if isBrotli {
1560
- guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
1607
+ do {
1608
+ try decompressBrotli(from: downloadedFileURL, to: destFilePath, fileName: fileName)
1609
+ } catch {
1561
1610
  self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
1562
- throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
1611
+ throw error
1563
1612
  }
1564
- finalData = decompressedData
1613
+ } else {
1614
+ try copyItemReplacing(from: downloadedFileURL, to: destFilePath)
1565
1615
  }
1566
1616
 
1567
- // Write to destination (replace if leftover from a previous failed download)
1568
- try writeDataAtomically(finalData, to: destFilePath)
1569
-
1570
1617
  // Always verify checksum when file_hash is present
1571
1618
  let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
1572
1619
  CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
@@ -1578,7 +1625,9 @@ import UIKit
1578
1625
  }
1579
1626
 
1580
1627
  // Save to cache (replace stale cache entries from partial or concurrent downloads)
1581
- try writeDataAtomically(finalData, to: cacheFilePath)
1628
+ if let cacheFilePath {
1629
+ try copyItemAtomically(from: destFilePath, to: cacheFilePath)
1630
+ }
1582
1631
 
1583
1632
  self.logger.info("Manifest file downloaded and cached")
1584
1633
  self.logger.debug("Bundle: \(bundleId), File: \(fileName), Brotli: \(isBrotli), Encrypted: \(!self.publicKey.isEmpty && !sessionKey.isEmpty)")
@@ -1589,49 +1638,55 @@ import UIKit
1589
1638
  }
1590
1639
  }
1591
1640
 
1592
- /// Atomically write data to a file, replacing any existing file at the destination.
1593
- private func writeDataAtomically(_ data: Data, to destination: URL) throws {
1641
+ /// Copy a file to the destination, replacing any existing file.
1642
+ private func copyItemReplacing(from source: URL, to destination: URL) throws {
1643
+ let fileManager = FileManager.default
1644
+ try fileManager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
1645
+ if fileManager.fileExists(atPath: destination.path) {
1646
+ try fileManager.removeItem(at: destination)
1647
+ }
1648
+ try fileManager.copyItem(at: source, to: destination)
1649
+ }
1650
+
1651
+ /// Copy via a unique temp name then rename, so a crash cannot leave a
1652
+ /// non-empty partial file that `isReusableCacheFile` would trust.
1653
+ private func copyItemAtomically(from source: URL, to destination: URL) throws {
1594
1654
  let fileManager = FileManager.default
1655
+ try fileManager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
1595
1656
  let tempURL = destination.deletingLastPathComponent().appendingPathComponent("\(destination.lastPathComponent).\(UUID().uuidString).tmp")
1596
1657
  defer {
1597
1658
  try? fileManager.removeItem(at: tempURL)
1598
1659
  }
1599
-
1600
- try data.write(to: tempURL, options: .atomic)
1601
- if fileManager.fileExists(atPath: destination.path) {
1602
- try fileManager.removeItem(at: destination)
1603
- }
1604
- try fileManager.moveItem(at: tempURL, to: destination)
1660
+ try fileManager.copyItem(at: source, to: tempURL)
1661
+ try replaceItemAtomically(at: destination, withItemAt: tempURL)
1605
1662
  }
1606
1663
 
1607
- /// Copy a file to the destination, replacing any existing file.
1608
- private func copyItemReplacing(from source: URL, to destination: URL) throws {
1664
+ /// One-step replace when dest exists, move when it does not. Avoids the
1665
+ /// fileExists/removeItem race that can fail a verified install.
1666
+ private func replaceItemAtomically(at destination: URL, withItemAt tempURL: URL) throws {
1609
1667
  let fileManager = FileManager.default
1610
- if fileManager.fileExists(atPath: destination.path) {
1611
- try fileManager.removeItem(at: destination)
1668
+ do {
1669
+ _ = try fileManager.replaceItemAt(destination, withItemAt: tempURL)
1670
+ } catch {
1671
+ if fileManager.fileExists(atPath: destination.path) {
1672
+ throw error
1673
+ }
1674
+ try fileManager.moveItem(at: tempURL, to: destination)
1612
1675
  }
1613
- try fileManager.copyItem(at: source, to: destination)
1614
1676
  }
1615
1677
 
1616
1678
  /// Atomically try to copy a file from cache - returns true if successful, false if file doesn't exist or copy failed
1617
1679
  /// This handles the race condition where OS can delete cache files between exists() check and copy
1618
1680
  private func tryCopyFromCache(from source: URL, to destination: URL, expectedHash: String) -> Bool {
1619
- let fileManager = FileManager.default
1620
-
1621
- // First quick check - if file doesn't exist, don't bother
1622
- guard fileManager.fileExists(atPath: source.path) else {
1681
+ // First quick check - if file doesn't exist or was truncated, don't bother
1682
+ guard isReusableCacheFile(source, expectedHash: expectedHash) else {
1623
1683
  return false
1624
1684
  }
1625
1685
 
1626
- // Verify checksum before copy; remove stale cache entries that would block re-download
1627
- guard verifyChecksum(file: source, expectedHash: expectedHash) else {
1628
- try? fileManager.removeItem(at: source)
1629
- return false
1630
- }
1631
-
1632
- // Try to copy - if it fails (file deleted by OS between check and copy), return false
1686
+ // Hash is in the cache file name and was verified when written.
1687
+ // Re-hashing here would re-read every reused file on low-RAM devices.
1633
1688
  do {
1634
- try copyItemReplacing(from: source, to: destination)
1689
+ try copyItemAtomically(from: source, to: destination)
1635
1690
  return true
1636
1691
  } catch {
1637
1692
  // File was deleted between check and copy, or other IO error - caller should download instead
@@ -1640,122 +1695,155 @@ import UIKit
1640
1695
  }
1641
1696
  }
1642
1697
 
1643
- private func decompressBrotli(data: Data, fileName: String) -> Data? {
1644
- // Handle empty files
1645
- if data.count == 0 {
1646
- return data
1698
+ /// Stream Brotli from disk to disk. Peek only the 3-byte header and last byte
1699
+ /// for the empty/wrapper special cases; never load the whole file.
1700
+ func decompressBrotli(from source: URL, to dest: URL, fileName: String) throws {
1701
+ let fileManager = FileManager.default
1702
+ try fileManager.createDirectory(at: dest.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
1703
+ let length = (try fileManager.attributesOfItem(atPath: source.path)[.size] as? NSNumber)?.uint64Value ?? 0
1704
+ if length == 0 {
1705
+ try Data().write(to: dest, options: .atomic)
1706
+ return
1707
+ }
1708
+
1709
+ let handle = try FileHandle(forReadingFrom: source)
1710
+ defer {
1711
+ try? handle.close()
1647
1712
  }
1648
1713
 
1649
- // Handle the special EMPTY_BROTLI_STREAM case
1650
- if data.count == 3 && data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 {
1651
- return Data()
1714
+ let head = try handle.read(upToCount: 3) ?? Data()
1715
+ var last: UInt8 = 0
1716
+ if length >= 1 {
1717
+ try handle.seek(toOffset: length - 1)
1718
+ last = try handle.read(upToCount: 1)?.first ?? 0
1652
1719
  }
1653
1720
 
1654
- // For small files, check if it's a minimal Brotli wrapper
1655
- if data.count > 3 {
1656
- let maxBytes = min(32, data.count)
1657
- let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
1658
- // Handle our minimal wrapper pattern
1659
- if data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 && data.last == 0x03 {
1660
- let range = data.index(data.startIndex, offsetBy: 3)..<data.index(data.endIndex, offsetBy: -1)
1661
- return data[range]
1721
+ if length == 3 && head.count == 3 && head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06 {
1722
+ try Data().write(to: dest, options: .atomic)
1723
+ return
1724
+ }
1725
+
1726
+ if length > 3 && head.count == 3 && last == 0x03 {
1727
+ let isEmptyWrapper = head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06
1728
+ let isQualityZeroWrapper = head[0] == 0x0b && head[1] == 0x02 && head[2] == 0x80
1729
+ if isEmptyWrapper || isQualityZeroWrapper {
1730
+ try handle.seek(toOffset: 3)
1731
+ try streamCopy(from: handle, count: length - 4, to: dest)
1732
+ return
1662
1733
  }
1734
+ }
1735
+
1736
+ try handle.seek(toOffset: 0)
1737
+ try streamBrotliDecode(from: handle, to: dest, fileName: fileName)
1738
+ }
1739
+
1740
+ private func streamCopy(from handle: FileHandle, count: UInt64, to dest: URL) throws {
1741
+ let fileManager = FileManager.default
1742
+ let tempURL = dest.deletingLastPathComponent().appendingPathComponent("capgo-br-\(UUID().uuidString).tmp")
1743
+ fileManager.createFile(atPath: tempURL.path, contents: nil)
1744
+ let output = try FileHandle(forWritingTo: tempURL)
1745
+ defer {
1746
+ try? output.close()
1747
+ try? fileManager.removeItem(at: tempURL)
1748
+ }
1663
1749
 
1664
- // Handle brotli.compress minimal wrapper (quality 0)
1665
- if data[0] == 0x0b && data[1] == 0x02 && data[2] == 0x80 && data.last == 0x03 {
1666
- let range = data.index(data.startIndex, offsetBy: 3)..<data.index(data.endIndex, offsetBy: -1)
1667
- return data[range]
1750
+ var remaining = count
1751
+ let chunkSize = CryptoCipher.ioBufferBytes()
1752
+ while remaining > 0 {
1753
+ let readCount: Int = try autoreleasepool {
1754
+ let toRead = Int(min(UInt64(chunkSize), remaining))
1755
+ let chunk = try handle.read(upToCount: toRead) ?? Data()
1756
+ if !chunk.isEmpty {
1757
+ try output.write(contentsOf: chunk)
1758
+ }
1759
+ return chunk.count
1760
+ }
1761
+ if readCount == 0 {
1762
+ break
1668
1763
  }
1764
+ remaining -= UInt64(readCount)
1669
1765
  }
1766
+ try output.close()
1767
+ try replaceItemAtomically(at: dest, withItemAt: tempURL)
1768
+ }
1670
1769
 
1671
- // For all other cases, try standard decompression
1672
- let outputBufferSize = 65536
1673
- var outputBuffer = [UInt8](repeating: 0, count: outputBufferSize)
1674
- var decompressedData = Data()
1770
+ private func streamBrotliDecode(from handle: FileHandle, to dest: URL, fileName: String) throws {
1771
+ let fileManager = FileManager.default
1772
+ let tempURL = dest.deletingLastPathComponent().appendingPathComponent("capgo-br-\(UUID().uuidString).tmp")
1773
+ fileManager.createFile(atPath: tempURL.path, contents: nil)
1774
+ let output = try FileHandle(forWritingTo: tempURL)
1775
+ defer {
1776
+ try? output.close()
1777
+ try? fileManager.removeItem(at: tempURL)
1778
+ }
1779
+
1780
+ let chunkSize = max(CryptoCipher.ioBufferBytes(), 65536)
1781
+ var inputBuffer = [UInt8](repeating: 0, count: chunkSize)
1782
+ var outputBuffer = [UInt8](repeating: 0, count: chunkSize)
1675
1783
 
1676
1784
  let streamPointer = UnsafeMutablePointer<compression_stream>.allocate(capacity: 1)
1677
1785
  var status = compression_stream_init(streamPointer, COMPRESSION_STREAM_DECODE, COMPRESSION_BROTLI)
1678
-
1679
1786
  guard status != COMPRESSION_STATUS_ERROR else {
1680
1787
  logger.error("Failed to initialize Brotli stream")
1681
1788
  logger.debug("File: \(fileName), Status: \(status)")
1682
- return nil
1789
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to initialize Brotli stream for \(fileName)"])
1683
1790
  }
1684
-
1685
1791
  defer {
1686
1792
  compression_stream_destroy(streamPointer)
1687
1793
  streamPointer.deallocate()
1688
1794
  }
1689
1795
 
1690
- streamPointer.pointee.src_size = 0
1691
- streamPointer.pointee.dst_ptr = UnsafeMutablePointer<UInt8>(&outputBuffer)
1692
- streamPointer.pointee.dst_size = outputBufferSize
1693
-
1694
- let input = data
1695
-
1696
- while true {
1697
- if streamPointer.pointee.src_size == 0 {
1698
- streamPointer.pointee.src_size = input.count
1699
- input.withUnsafeBytes { rawBufferPointer in
1700
- if let baseAddress = rawBufferPointer.baseAddress {
1701
- streamPointer.pointee.src_ptr = baseAddress.assumingMemoryBound(to: UInt8.self)
1702
- } else {
1703
- logger.error("Failed to get base address for Brotli decompression")
1704
- logger.debug("File: \(fileName)")
1705
- status = COMPRESSION_STATUS_ERROR
1706
- return
1707
- }
1796
+ try inputBuffer.withUnsafeMutableBufferPointer { inBuf in
1797
+ try outputBuffer.withUnsafeMutableBufferPointer { outBuf in
1798
+ guard let inBase = inBuf.baseAddress, let outBase = outBuf.baseAddress else {
1799
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to get buffer address for \(fileName)"])
1708
1800
  }
1709
- }
1710
-
1711
- if status == COMPRESSION_STATUS_ERROR {
1712
- let maxBytes = min(32, data.count)
1713
- let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
1714
- logger.error("Brotli decompression failed")
1715
- logger.debug("File: \(fileName), First \(maxBytes) bytes: \(hexDump)")
1716
- break
1717
- }
1718
-
1719
- status = compression_stream_process(streamPointer, 0)
1801
+ streamPointer.pointee.src_size = 0
1802
+ streamPointer.pointee.dst_ptr = outBase
1803
+ streamPointer.pointee.dst_size = chunkSize
1804
+
1805
+ var flags: Int32 = 0
1806
+ var inputExhausted = false
1807
+ while true {
1808
+ if streamPointer.pointee.src_size == 0 && !inputExhausted {
1809
+ let chunk = try handle.read(upToCount: chunkSize) ?? Data()
1810
+ if chunk.isEmpty {
1811
+ inputExhausted = true
1812
+ flags = Int32(bitPattern: COMPRESSION_STREAM_FINALIZE.rawValue)
1813
+ } else {
1814
+ chunk.copyBytes(to: inBase, count: chunk.count)
1815
+ streamPointer.pointee.src_ptr = UnsafePointer(inBase)
1816
+ streamPointer.pointee.src_size = chunk.count
1817
+ }
1818
+ }
1720
1819
 
1721
- let have = outputBufferSize - streamPointer.pointee.dst_size
1722
- if have > 0 {
1723
- decompressedData.append(outputBuffer, count: have)
1724
- }
1820
+ status = compression_stream_process(streamPointer, flags)
1821
+ let have = chunkSize - streamPointer.pointee.dst_size
1822
+ if have > 0 {
1823
+ try output.write(contentsOf: Data(bytes: outBase, count: have))
1824
+ }
1825
+ streamPointer.pointee.dst_ptr = outBase
1826
+ streamPointer.pointee.dst_size = chunkSize
1725
1827
 
1726
- if status == COMPRESSION_STATUS_END {
1727
- break
1728
- } else if status == COMPRESSION_STATUS_ERROR {
1729
- logger.error("Brotli process failed")
1730
- logger.debug("File: \(fileName), Status: \(status)")
1731
- if let text = String(data: data, encoding: .utf8) {
1732
- let asciiCount = text.unicodeScalars.filter { $0.isASCII }.count
1733
- let totalCount = text.unicodeScalars.count
1734
- if totalCount > 0 && Double(asciiCount) / Double(totalCount) >= 0.8 {
1735
- logger.debug("Input appears to be plain text: \(text)")
1828
+ if status == COMPRESSION_STATUS_END {
1829
+ break
1830
+ }
1831
+ if status == COMPRESSION_STATUS_ERROR {
1832
+ logger.error("Brotli process failed")
1833
+ logger.debug("File: \(fileName), Status: \(status)")
1834
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName)"])
1835
+ }
1836
+ if inputExhausted && streamPointer.pointee.src_size == 0 && have == 0 {
1837
+ logger.error("Brotli decompression stalled")
1838
+ logger.debug("File: \(fileName)")
1839
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName)"])
1736
1840
  }
1737
1841
  }
1738
-
1739
- let maxBytes = min(32, data.count)
1740
- let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
1741
- logger.debug("Raw data: \(hexDump)")
1742
-
1743
- return nil
1744
- }
1745
-
1746
- if streamPointer.pointee.dst_size == 0 {
1747
- streamPointer.pointee.dst_ptr = UnsafeMutablePointer<UInt8>(&outputBuffer)
1748
- streamPointer.pointee.dst_size = outputBufferSize
1749
- }
1750
-
1751
- if input.count == 0 {
1752
- logger.error("Zero input size for Brotli decompression")
1753
- logger.debug("File: \(fileName)")
1754
- break
1755
1842
  }
1756
1843
  }
1757
1844
 
1758
- return status == COMPRESSION_STATUS_END ? decompressedData : nil
1845
+ try output.close()
1846
+ try replaceItemAtomically(at: dest, withItemAt: tempURL)
1759
1847
  }
1760
1848
 
1761
1849
  public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
@@ -2888,6 +2976,7 @@ import UIKit
2888
2976
  let queue = OperationQueue()
2889
2977
  queue.name = "com.capgo.manifestDownload"
2890
2978
  queue.qualityOfService = .userInitiated
2979
+ queue.maxConcurrentOperationCount = CapgoUpdater.manifestMaxConcurrentFiles
2891
2980
  return queue
2892
2981
  }()
2893
2982