@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.
- package/CapgoCapacitorUpdater.podspec +1 -1
- package/Package.swift +3 -2
- package/README.md +53 -48
- package/android/build.gradle +1 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/AppLifecycleObserver.java +29 -2
- package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +7 -3
- package/android/src/main/java/ee/forgr/capacitor_updater/BundleStatus.java +1 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +452 -139
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1354 -412
- package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +102 -31
- package/android/src/main/java/ee/forgr/capacitor_updater/DataManager.java +23 -7
- package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +2 -2
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +540 -224
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +103 -3
- package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +1 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +131 -145
- package/dist/docs.json +32 -8
- package/dist/esm/definitions.d.ts +41 -17
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +124 -0
- package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +9 -1
- package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +3 -0
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +787 -92
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1014 -268
- package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +49 -31
- package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +44 -20
- package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +28 -0
- 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
|
-
//
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
285
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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(
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
859
|
-
latest.
|
|
860
|
-
|
|
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 =
|
|
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
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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 =
|
|
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 =
|
|
1150
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
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
|
-
|
|
1320
|
-
var finalData = data
|
|
1673
|
+
var source = partialURL
|
|
1321
1674
|
if !self.publicKey.isEmpty && !sessionKey.isEmpty {
|
|
1322
|
-
let
|
|
1323
|
-
try
|
|
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:
|
|
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
|
-
|
|
1331
|
-
try FileManager.default.removeItem(at: tempFile)
|
|
1685
|
+
source = work
|
|
1332
1686
|
}
|
|
1333
1687
|
|
|
1334
|
-
|
|
1688
|
+
let calculatedChecksum: String
|
|
1335
1689
|
if isBrotli {
|
|
1336
|
-
|
|
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
|
|
1695
|
+
throw error
|
|
1696
|
+
}
|
|
1697
|
+
} else {
|
|
1698
|
+
let handle = try FileHandle(forReadingFrom: source)
|
|
1699
|
+
defer {
|
|
1700
|
+
try? handle.close()
|
|
1339
1701
|
}
|
|
1340
|
-
|
|
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
|
-
|
|
1357
|
-
|
|
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
|
-
///
|
|
1369
|
-
private func
|
|
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
|
|
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
|
-
///
|
|
1384
|
-
|
|
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
|
-
|
|
1387
|
-
try fileManager.
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
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
|
-
|
|
1426
|
-
|
|
1427
|
-
return
|
|
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
|
-
|
|
1431
|
-
|
|
1432
|
-
let
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
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
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
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
|
-
|
|
1448
|
-
let
|
|
1449
|
-
|
|
1450
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
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
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
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
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
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
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
if
|
|
1511
|
-
logger.
|
|
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
|
-
|
|
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
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
logger.error("
|
|
1845
|
-
logger.debug("
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
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
|
-
|
|
2220
|
-
|
|
2221
|
-
logger.debug("Skipping setChannel due to
|
|
2222
|
-
setChannel.message =
|
|
2223
|
-
setChannel.error =
|
|
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
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
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
|
|
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
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
getChannel.
|
|
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
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
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
|
|
2417
|
-
|
|
2418
|
-
listChannels.error
|
|
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
|
-
|
|
2453
|
-
|
|
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
|
|
2533
|
-
logger.debug("Skipping sendStats during preview session.")
|
|
3142
|
+
if statsStopped {
|
|
2534
3143
|
return
|
|
2535
3144
|
}
|
|
2536
3145
|
|
|
2537
|
-
|
|
2538
|
-
|
|
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
|
-
|
|
2619
|
-
|
|
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
|
-
|
|
2626
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
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
|
|