@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.
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +1 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +62 -21
- package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +115 -20
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +142 -114
- package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +119 -0
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +249 -160
- package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +41 -29
- package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +30 -18
- package/package.json +5 -5
|
@@ -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
|
-
|
|
307
|
-
|
|
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
|
|
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
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
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 =
|
|
1372
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
|
|
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
|
|
1611
|
+
throw error
|
|
1563
1612
|
}
|
|
1564
|
-
|
|
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
|
-
|
|
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
|
-
///
|
|
1593
|
-
private func
|
|
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
|
|
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
|
-
///
|
|
1608
|
-
|
|
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
|
-
|
|
1611
|
-
try fileManager.
|
|
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
|
-
|
|
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
|
-
//
|
|
1627
|
-
|
|
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
|
|
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
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
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
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
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
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
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
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
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
|
-
|
|
1672
|
-
let
|
|
1673
|
-
|
|
1674
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
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
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
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
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
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
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
if
|
|
1735
|
-
logger.
|
|
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
|
-
|
|
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
|
|