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