@capgo/capacitor-updater 8.51.4 → 8.51.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/ee/forgr/capacitor_updater/AppLifecycleObserver.java +29 -2
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +26 -10
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +525 -89
- package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +22 -17
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +7 -1
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +372 -61
- package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +6 -2
- package/package.json +1 -1
|
@@ -49,17 +49,30 @@ import UIKit
|
|
|
49
49
|
// Cached key ID calculated once from publicKey
|
|
50
50
|
private var cachedKeyId: String?
|
|
51
51
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
// Temporary 429 block until this epoch ms (Retry-After / rateLimitResetAt). No sticky latch.
|
|
53
|
+
// Guarded by rateLimitStateLock so concurrent 429s cannot shorten the window or mix metadata.
|
|
54
|
+
private static let rateLimitStateLock = NSLock()
|
|
55
|
+
private static var rateLimitBlockedUntilMs: Double = 0
|
|
56
|
+
private static var rateLimitBlockedError: String = "too_many_requests"
|
|
57
|
+
private static var rateLimitBlockedMessage: String = "Too many requests"
|
|
58
|
+
|
|
59
|
+
// Flag to track if we've already sent the rate limit statistic - prevents infinite loop.
|
|
60
|
+
// Released again when the send fails, so a later 429 can retry it.
|
|
56
61
|
private static var rateLimitStatisticSent = false
|
|
57
62
|
|
|
63
|
+
// Upper bound for a client-side 429 block, so a bogus Retry-After cannot block the app for days.
|
|
64
|
+
private static let maxRateLimitWindowMs: Double = 24 * 60 * 60 * 1000
|
|
65
|
+
|
|
58
66
|
// Stats batching - queue events and send max once per second
|
|
59
67
|
private var statsQueue: [QueuedStatsEvent] = []
|
|
68
|
+
private var statsInFlight: [QueuedStatsEvent] = []
|
|
60
69
|
private let statsQueueLock = NSLock()
|
|
70
|
+
private let statsPersistLock = NSLock()
|
|
61
71
|
private var statsFlushTimer: Timer?
|
|
72
|
+
private var statsStopped = false
|
|
62
73
|
private static let statsFlushInterval: TimeInterval = 1.0
|
|
74
|
+
private static let maxPendingStats = 200
|
|
75
|
+
private let pendingStatsFileName = "capgo_pending_stats.json"
|
|
63
76
|
|
|
64
77
|
private struct QueuedStatsEvent {
|
|
65
78
|
let event: StatsEvent
|
|
@@ -323,12 +336,16 @@ import UIKit
|
|
|
323
336
|
}
|
|
324
337
|
|
|
325
338
|
deinit {
|
|
326
|
-
|
|
339
|
+
shutdown()
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
public func shutdown() {
|
|
343
|
+
statsPersistLock.lock()
|
|
344
|
+
statsStopped = true
|
|
345
|
+
statsPersistLock.unlock()
|
|
327
346
|
statsFlushTimer?.invalidate()
|
|
328
347
|
statsFlushTimer = nil
|
|
329
|
-
|
|
330
|
-
// Flush any remaining stats before deallocation
|
|
331
|
-
flushStatsQueue()
|
|
348
|
+
persistStatsQueue(force: true)
|
|
332
349
|
}
|
|
333
350
|
|
|
334
351
|
private func calcTotalPercent(percent: Int, min: Int, max: Int) -> Int {
|
|
@@ -421,26 +438,149 @@ import UIKit
|
|
|
421
438
|
}
|
|
422
439
|
}
|
|
423
440
|
|
|
441
|
+
private struct RemoteBlockResult {
|
|
442
|
+
let blocked: Bool
|
|
443
|
+
let error: String
|
|
444
|
+
let message: String
|
|
445
|
+
}
|
|
446
|
+
|
|
424
447
|
/**
|
|
425
|
-
*
|
|
448
|
+
* Handle HTTP 429 responses by honouring Retry-After / rateLimitResetAt.
|
|
449
|
+
* All 429s use the same temporary client block — no sticky latch until restart.
|
|
426
450
|
*/
|
|
427
|
-
private func checkAndHandleRateLimitResponse(
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
451
|
+
private func checkAndHandleRateLimitResponse(
|
|
452
|
+
statusCode: Int?,
|
|
453
|
+
data: Data? = nil,
|
|
454
|
+
response: HTTPURLResponse? = nil
|
|
455
|
+
) -> RemoteBlockResult {
|
|
456
|
+
guard statusCode == 429 else {
|
|
457
|
+
return RemoteBlockResult(blocked: false, error: "", message: "")
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
let parsed = parseRemoteError(from: data)
|
|
461
|
+
let errorCode = parsed.error.isEmpty ? "too_many_requests" : parsed.error
|
|
462
|
+
let message = parsed.message.isEmpty ? "Too many requests" : parsed.message
|
|
463
|
+
|
|
464
|
+
let retryUntilMs = resolveRateLimitBlockedUntilMs(data: data, response: response)
|
|
465
|
+
CapgoUpdater.recordRateLimitBlock(untilMs: retryUntilMs, error: errorCode, message: message)
|
|
466
|
+
|
|
467
|
+
// Claim last, and only when there is somewhere to send it, so a 429 burst with no
|
|
468
|
+
// stats URL does not claim and release the latch once per response.
|
|
469
|
+
if errorCode == "too_many_requests" && !previewSession && !statsUrl.isEmpty && CapgoUpdater.claimRateLimitStatistic() {
|
|
470
|
+
DispatchQueue.global(qos: .utility).async {
|
|
471
|
+
self.sendRateLimitStatistic()
|
|
438
472
|
}
|
|
439
|
-
CapgoUpdater.rateLimitExceeded = true
|
|
440
|
-
logger.warn("Rate limit exceeded (429). Stopping all stats and channel requests until app restart.")
|
|
441
|
-
return true
|
|
442
473
|
}
|
|
443
|
-
|
|
474
|
+
|
|
475
|
+
let nowMs = Date().timeIntervalSince1970 * 1000
|
|
476
|
+
let retryAfter = CapgoUpdater.retryAfterSecondsForLog(untilMs: retryUntilMs, nowMs: nowMs)
|
|
477
|
+
logger.warn("Received 429 (\(errorCode)). Honouring Retry-After: \(retryAfter)s.")
|
|
478
|
+
return RemoteBlockResult(blocked: true, error: errorCode, message: message)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/// Stores the block deadline and its metadata together, keeping the longest deadline
|
|
482
|
+
/// so a concurrent 429 with a shorter window cannot cut the block short.
|
|
483
|
+
private static func recordRateLimitBlock(untilMs: Double, error: String, message: String) {
|
|
484
|
+
rateLimitStateLock.lock()
|
|
485
|
+
defer { rateLimitStateLock.unlock() }
|
|
486
|
+
if untilMs > rateLimitBlockedUntilMs {
|
|
487
|
+
rateLimitBlockedUntilMs = untilMs
|
|
488
|
+
rateLimitBlockedError = error
|
|
489
|
+
rateLimitBlockedMessage = message
|
|
490
|
+
} else if rateLimitBlockedUntilMs <= 0 {
|
|
491
|
+
rateLimitBlockedError = error
|
|
492
|
+
rateLimitBlockedMessage = message
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/// Seconds left in the block, clamped and finite so the Int conversion can never trap.
|
|
497
|
+
private static func retryAfterSecondsForLog(untilMs: Double, nowMs: Double) -> Int {
|
|
498
|
+
let seconds = ((untilMs - nowMs) / 1000).rounded(.up)
|
|
499
|
+
guard seconds.isFinite, seconds > 0 else {
|
|
500
|
+
return 0
|
|
501
|
+
}
|
|
502
|
+
return Int(min(seconds, maxRateLimitWindowMs / 1000))
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/// Returns true for the first 429 only, so the rate-limit statistic is sent once.
|
|
506
|
+
private static func claimRateLimitStatistic() -> Bool {
|
|
507
|
+
rateLimitStateLock.lock()
|
|
508
|
+
defer { rateLimitStateLock.unlock() }
|
|
509
|
+
if rateLimitStatisticSent {
|
|
510
|
+
return false
|
|
511
|
+
}
|
|
512
|
+
rateLimitStatisticSent = true
|
|
513
|
+
return true
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/// Gives the claim back when the statistic never made it out, so a later 429 can retry it.
|
|
517
|
+
private static func releaseRateLimitStatisticClaim() {
|
|
518
|
+
rateLimitStateLock.lock()
|
|
519
|
+
defer { rateLimitStateLock.unlock() }
|
|
520
|
+
rateLimitStatisticSent = false
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private func parseRemoteError(from data: Data?) -> (error: String, message: String) {
|
|
524
|
+
guard let data = data,
|
|
525
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
526
|
+
return ("", "")
|
|
527
|
+
}
|
|
528
|
+
let error = json["error"] as? String ?? ""
|
|
529
|
+
let message = json["message"] as? String ?? ""
|
|
530
|
+
return (error, message)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private func resolveRateLimitBlockedUntilMs(data: Data?, response: HTTPURLResponse?) -> Double {
|
|
534
|
+
let nowMs = Date().timeIntervalSince1970 * 1000
|
|
535
|
+
let candidate = rawRateLimitDeadlineMs(data: data, response: response, nowMs: nowMs)
|
|
536
|
+
// NaN and past deadlines mean "no client-side block"; anything further out is capped.
|
|
537
|
+
guard candidate > nowMs else {
|
|
538
|
+
return 0
|
|
539
|
+
}
|
|
540
|
+
return min(candidate, nowMs + CapgoUpdater.maxRateLimitWindowMs)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private func rawRateLimitDeadlineMs(data: Data?, response: HTTPURLResponse?, nowMs: Double) -> Double {
|
|
544
|
+
if let header = response?.value(forHTTPHeaderField: "Retry-After")?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
545
|
+
let seconds = Double(header), seconds >= 0 {
|
|
546
|
+
return nowMs + seconds * 1000
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if let data = data,
|
|
550
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
551
|
+
let moreInfo = json["moreInfo"] as? [String: Any]
|
|
552
|
+
if let retryAfter = (moreInfo?["retryAfterSeconds"] as? NSNumber)?.doubleValue
|
|
553
|
+
?? (json["retryAfterSeconds"] as? NSNumber)?.doubleValue,
|
|
554
|
+
retryAfter >= 0 {
|
|
555
|
+
return nowMs + retryAfter * 1000
|
|
556
|
+
}
|
|
557
|
+
if let resetAt = (moreInfo?["rateLimitResetAt"] as? NSNumber)?.doubleValue
|
|
558
|
+
?? (json["rateLimitResetAt"] as? NSNumber)?.doubleValue {
|
|
559
|
+
return resetAt
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// No retry hint — do not hold a client-side block; allow immediate retry to the worker
|
|
564
|
+
return 0
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
private func isRemoteBlocked() -> Bool {
|
|
568
|
+
CapgoUpdater.rateLimitStateLock.lock()
|
|
569
|
+
defer { CapgoUpdater.rateLimitStateLock.unlock() }
|
|
570
|
+
if CapgoUpdater.rateLimitBlockedUntilMs <= 0 {
|
|
571
|
+
return false
|
|
572
|
+
}
|
|
573
|
+
if Date().timeIntervalSince1970 * 1000 >= CapgoUpdater.rateLimitBlockedUntilMs {
|
|
574
|
+
CapgoUpdater.rateLimitBlockedUntilMs = 0
|
|
575
|
+
return false
|
|
576
|
+
}
|
|
577
|
+
return true
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private func remoteBlockedClientError() -> (error: String, message: String) {
|
|
581
|
+
CapgoUpdater.rateLimitStateLock.lock()
|
|
582
|
+
defer { CapgoUpdater.rateLimitStateLock.unlock() }
|
|
583
|
+
return (CapgoUpdater.rateLimitBlockedError, CapgoUpdater.rateLimitBlockedMessage)
|
|
444
584
|
}
|
|
445
585
|
|
|
446
586
|
/**
|
|
@@ -450,6 +590,8 @@ import UIKit
|
|
|
450
590
|
*/
|
|
451
591
|
private func sendRateLimitStatistic() {
|
|
452
592
|
guard !statsUrl.isEmpty else {
|
|
593
|
+
// The URL was cleared after the claim was taken; nothing went out, so hand it back.
|
|
594
|
+
CapgoUpdater.releaseRateLimitStatisticClaim()
|
|
453
595
|
return
|
|
454
596
|
}
|
|
455
597
|
|
|
@@ -468,10 +610,16 @@ import UIKit
|
|
|
468
610
|
encoding: JSONEncoding.default,
|
|
469
611
|
requestModifier: { $0.timeoutInterval = self.timeout }
|
|
470
612
|
).responseData { response in
|
|
613
|
+
let statusCode = response.response?.statusCode
|
|
471
614
|
switch response.result {
|
|
472
|
-
case .success:
|
|
615
|
+
case .success where (200...299).contains(statusCode ?? 0):
|
|
473
616
|
self.logger.info("Rate limit statistic sent")
|
|
617
|
+
case .success:
|
|
618
|
+
CapgoUpdater.releaseRateLimitStatisticClaim()
|
|
619
|
+
self.logger.error("Error sending rate limit statistic")
|
|
620
|
+
self.logger.debug("Response code: \(statusCode.map(String.init) ?? "nil")")
|
|
474
621
|
case let .failure(error):
|
|
622
|
+
CapgoUpdater.releaseRateLimitStatisticClaim()
|
|
475
623
|
self.logger.error("Error sending rate limit statistic")
|
|
476
624
|
self.logger.debug("Error: \(error.localizedDescription)")
|
|
477
625
|
}
|
|
@@ -825,6 +973,14 @@ import UIKit
|
|
|
825
973
|
|
|
826
974
|
public func getLatest(url: URL, channel: String?, appIdOverride: String? = nil) -> AppVersion {
|
|
827
975
|
let latest: AppVersion = AppVersion()
|
|
976
|
+
if isRemoteBlocked() {
|
|
977
|
+
let blocked = remoteBlockedClientError()
|
|
978
|
+
logger.debug("Skipping getLatest due to remote block (\(blocked.error)).")
|
|
979
|
+
latest.message = blocked.message
|
|
980
|
+
latest.error = blocked.error
|
|
981
|
+
latest.kind = "failed"
|
|
982
|
+
return latest
|
|
983
|
+
}
|
|
828
984
|
func applyLatestResponse(_ value: AppVersionDec?) {
|
|
829
985
|
if let url = value?.url {
|
|
830
986
|
latest.url = url
|
|
@@ -905,9 +1061,14 @@ import UIKit
|
|
|
905
1061
|
return latest
|
|
906
1062
|
}
|
|
907
1063
|
|
|
908
|
-
|
|
909
|
-
latest.
|
|
910
|
-
|
|
1064
|
+
let rateLimit = self.checkAndHandleRateLimitResponse(
|
|
1065
|
+
statusCode: latest.statusCode,
|
|
1066
|
+
data: data,
|
|
1067
|
+
response: result.response
|
|
1068
|
+
)
|
|
1069
|
+
if rateLimit.blocked {
|
|
1070
|
+
latest.message = rateLimit.message
|
|
1071
|
+
latest.error = rateLimit.error
|
|
911
1072
|
latest.kind = "failed"
|
|
912
1073
|
return latest
|
|
913
1074
|
}
|
|
@@ -2419,11 +2580,11 @@ import UIKit
|
|
|
2419
2580
|
return setChannel
|
|
2420
2581
|
}
|
|
2421
2582
|
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
logger.debug("Skipping setChannel due to
|
|
2425
|
-
setChannel.message =
|
|
2426
|
-
setChannel.error =
|
|
2583
|
+
if isRemoteBlocked() {
|
|
2584
|
+
let blocked = remoteBlockedClientError()
|
|
2585
|
+
logger.debug("Skipping setChannel due to remote block (\(blocked.error)).")
|
|
2586
|
+
setChannel.message = blocked.message
|
|
2587
|
+
setChannel.error = blocked.error
|
|
2427
2588
|
return setChannel
|
|
2428
2589
|
}
|
|
2429
2590
|
|
|
@@ -2448,9 +2609,14 @@ import UIKit
|
|
|
2448
2609
|
|
|
2449
2610
|
let result = performRequest(request, label: "setChannel")
|
|
2450
2611
|
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2612
|
+
let rateLimit = self.checkAndHandleRateLimitResponse(
|
|
2613
|
+
statusCode: result.response?.statusCode,
|
|
2614
|
+
data: result.data,
|
|
2615
|
+
response: result.response
|
|
2616
|
+
)
|
|
2617
|
+
if rateLimit.blocked {
|
|
2618
|
+
setChannel.message = rateLimit.message
|
|
2619
|
+
setChannel.error = rateLimit.error
|
|
2454
2620
|
return setChannel
|
|
2455
2621
|
}
|
|
2456
2622
|
|
|
@@ -2509,10 +2675,11 @@ import UIKit
|
|
|
2509
2675
|
func getChannel(defaultChannelKey: String? = nil) -> GetChannel {
|
|
2510
2676
|
let getChannel: GetChannel = GetChannel()
|
|
2511
2677
|
// Check if rate limit was exceeded
|
|
2512
|
-
if
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
getChannel.
|
|
2678
|
+
if isRemoteBlocked() {
|
|
2679
|
+
let blocked = remoteBlockedClientError()
|
|
2680
|
+
logger.debug("Skipping getChannel due to remote block (\(blocked.error)).")
|
|
2681
|
+
getChannel.message = blocked.message
|
|
2682
|
+
getChannel.error = blocked.error
|
|
2516
2683
|
return getChannel
|
|
2517
2684
|
}
|
|
2518
2685
|
|
|
@@ -2536,9 +2703,14 @@ import UIKit
|
|
|
2536
2703
|
|
|
2537
2704
|
let result = performRequest(request, label: "getChannel")
|
|
2538
2705
|
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2706
|
+
let rateLimit = self.checkAndHandleRateLimitResponse(
|
|
2707
|
+
statusCode: result.response?.statusCode,
|
|
2708
|
+
data: result.data,
|
|
2709
|
+
response: result.response
|
|
2710
|
+
)
|
|
2711
|
+
if rateLimit.blocked {
|
|
2712
|
+
getChannel.message = rateLimit.message
|
|
2713
|
+
getChannel.error = rateLimit.error
|
|
2542
2714
|
return getChannel
|
|
2543
2715
|
}
|
|
2544
2716
|
|
|
@@ -2616,9 +2788,10 @@ import UIKit
|
|
|
2616
2788
|
let listChannels: ListChannels = ListChannels()
|
|
2617
2789
|
|
|
2618
2790
|
// Check if rate limit was exceeded
|
|
2619
|
-
if
|
|
2620
|
-
|
|
2621
|
-
listChannels.error
|
|
2791
|
+
if isRemoteBlocked() {
|
|
2792
|
+
let blocked = remoteBlockedClientError()
|
|
2793
|
+
logger.debug("Skipping listChannels due to remote block (\(blocked.error)).")
|
|
2794
|
+
listChannels.error = blocked.error
|
|
2622
2795
|
return listChannels
|
|
2623
2796
|
}
|
|
2624
2797
|
|
|
@@ -2652,8 +2825,13 @@ import UIKit
|
|
|
2652
2825
|
|
|
2653
2826
|
let result = performRequest(request, label: "listChannels")
|
|
2654
2827
|
|
|
2655
|
-
|
|
2656
|
-
|
|
2828
|
+
let rateLimit = self.checkAndHandleRateLimitResponse(
|
|
2829
|
+
statusCode: result.response?.statusCode,
|
|
2830
|
+
data: result.data,
|
|
2831
|
+
response: result.response
|
|
2832
|
+
)
|
|
2833
|
+
if rateLimit.blocked {
|
|
2834
|
+
listChannels.error = rateLimit.error
|
|
2657
2835
|
return listChannels
|
|
2658
2836
|
}
|
|
2659
2837
|
|
|
@@ -2732,14 +2910,12 @@ import UIKit
|
|
|
2732
2910
|
metadata: [String: String]?,
|
|
2733
2911
|
onSent: (() -> Void)?
|
|
2734
2912
|
) {
|
|
2735
|
-
if
|
|
2736
|
-
logger.debug("Skipping sendStats during preview session.")
|
|
2913
|
+
if statsStopped {
|
|
2737
2914
|
return
|
|
2738
2915
|
}
|
|
2739
2916
|
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.")
|
|
2917
|
+
if previewSession {
|
|
2918
|
+
logger.debug("Skipping sendStats during preview session.")
|
|
2743
2919
|
return
|
|
2744
2920
|
}
|
|
2745
2921
|
|
|
@@ -2773,15 +2949,90 @@ import UIKit
|
|
|
2773
2949
|
)
|
|
2774
2950
|
|
|
2775
2951
|
statsQueueLock.lock()
|
|
2952
|
+
if statsStopped {
|
|
2953
|
+
statsQueueLock.unlock()
|
|
2954
|
+
return
|
|
2955
|
+
}
|
|
2956
|
+
if statsQueue.count >= CapgoUpdater.maxPendingStats {
|
|
2957
|
+
statsQueue.removeFirst(statsQueue.count - CapgoUpdater.maxPendingStats + 1)
|
|
2958
|
+
}
|
|
2776
2959
|
statsQueue.append(QueuedStatsEvent(event: event, onSent: onSent))
|
|
2777
2960
|
statsQueueLock.unlock()
|
|
2778
2961
|
|
|
2779
2962
|
ensureStatsTimerStarted()
|
|
2780
2963
|
}
|
|
2781
2964
|
|
|
2965
|
+
func restorePendingStats() {
|
|
2966
|
+
let fileURL = pendingStatsFileURL()
|
|
2967
|
+
guard FileManager.default.fileExists(atPath: fileURL.path),
|
|
2968
|
+
let data = try? Data(contentsOf: fileURL),
|
|
2969
|
+
let events = try? JSONDecoder().decode([StatsEvent].self, from: data) else {
|
|
2970
|
+
return
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
statsQueueLock.lock()
|
|
2974
|
+
for event in events {
|
|
2975
|
+
if statsQueue.count >= CapgoUpdater.maxPendingStats {
|
|
2976
|
+
break
|
|
2977
|
+
}
|
|
2978
|
+
statsQueue.append(QueuedStatsEvent(event: event, onSent: nil))
|
|
2979
|
+
}
|
|
2980
|
+
let restoredCount = statsQueue.count
|
|
2981
|
+
statsQueueLock.unlock()
|
|
2982
|
+
|
|
2983
|
+
if restoredCount > 0 {
|
|
2984
|
+
logger.info("Restored \(restoredCount) pending stats events")
|
|
2985
|
+
ensureStatsTimerStarted()
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
func persistPendingStats() {
|
|
2990
|
+
persistStatsQueue()
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
private func pendingStatsFileURL() -> URL {
|
|
2994
|
+
libraryDir.appendingPathComponent(pendingStatsFileName)
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
private func persistStatsQueue(force: Bool = false) {
|
|
2998
|
+
statsPersistLock.lock()
|
|
2999
|
+
defer { statsPersistLock.unlock() }
|
|
3000
|
+
if statsStopped && !force {
|
|
3001
|
+
return
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
statsQueueLock.lock()
|
|
3005
|
+
var events = statsInFlight.map(\.event) + statsQueue.map(\.event)
|
|
3006
|
+
statsQueueLock.unlock()
|
|
3007
|
+
if events.count > CapgoUpdater.maxPendingStats {
|
|
3008
|
+
events = Array(events.suffix(CapgoUpdater.maxPendingStats))
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
let fileURL = pendingStatsFileURL()
|
|
3012
|
+
if events.isEmpty {
|
|
3013
|
+
try? FileManager.default.removeItem(at: fileURL)
|
|
3014
|
+
return
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
do {
|
|
3018
|
+
let data = try JSONEncoder().encode(events)
|
|
3019
|
+
try data.write(to: fileURL, options: .atomic)
|
|
3020
|
+
var resourceURL = fileURL
|
|
3021
|
+
var values = URLResourceValues()
|
|
3022
|
+
values.isExcludedFromBackup = true
|
|
3023
|
+
try resourceURL.setResourceValues(values)
|
|
3024
|
+
} catch {
|
|
3025
|
+
logger.error("Failed to persist stats queue")
|
|
3026
|
+
logger.debug("Error: \(error.localizedDescription)")
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
|
|
2782
3030
|
private func ensureStatsTimerStarted() {
|
|
3031
|
+
if statsStopped {
|
|
3032
|
+
return
|
|
3033
|
+
}
|
|
2783
3034
|
DispatchQueue.main.async { [weak self] in
|
|
2784
|
-
guard let self = self else { return }
|
|
3035
|
+
guard let self = self, !self.statsStopped else { return }
|
|
2785
3036
|
if self.statsFlushTimer == nil || !self.statsFlushTimer!.isValid {
|
|
2786
3037
|
// Use closure-based timer to avoid strong reference cycle
|
|
2787
3038
|
self.statsFlushTimer = Timer.scheduledTimer(
|
|
@@ -2795,17 +3046,27 @@ import UIKit
|
|
|
2795
3046
|
}
|
|
2796
3047
|
|
|
2797
3048
|
private func flushStatsQueue() {
|
|
3049
|
+
if statsStopped {
|
|
3050
|
+
return
|
|
3051
|
+
}
|
|
3052
|
+
// While Retry-After is active, keep stats queued and skip the network call.
|
|
3053
|
+
if isRemoteBlocked() {
|
|
3054
|
+
logger.debug("Deferring stats flush until Retry-After expires.")
|
|
3055
|
+
return
|
|
3056
|
+
}
|
|
3057
|
+
|
|
2798
3058
|
statsQueueLock.lock()
|
|
2799
|
-
guard !statsQueue.isEmpty else {
|
|
3059
|
+
guard statsInFlight.isEmpty, !statsQueue.isEmpty else {
|
|
2800
3060
|
statsQueueLock.unlock()
|
|
2801
3061
|
return
|
|
2802
3062
|
}
|
|
2803
3063
|
let queuedEvents = statsQueue
|
|
2804
3064
|
statsQueue.removeAll()
|
|
3065
|
+
statsInFlight = queuedEvents
|
|
2805
3066
|
statsQueueLock.unlock()
|
|
3067
|
+
persistStatsQueue()
|
|
2806
3068
|
|
|
2807
3069
|
let eventsToSend = queuedEvents.map(\.event)
|
|
2808
|
-
let onSentCallbacks = queuedEvents.compactMap(\.onSent)
|
|
2809
3070
|
|
|
2810
3071
|
operationQueue.maxConcurrentOperationCount = 1
|
|
2811
3072
|
|
|
@@ -2818,35 +3079,85 @@ import UIKit
|
|
|
2818
3079
|
encoder: JSONParameterEncoder.default,
|
|
2819
3080
|
requestModifier: { $0.timeoutInterval = self.timeout }
|
|
2820
3081
|
).responseData { response in
|
|
2821
|
-
|
|
2822
|
-
|
|
3082
|
+
if self.abandonStoppedStatsFlush() {
|
|
3083
|
+
semaphore.signal()
|
|
3084
|
+
return
|
|
3085
|
+
}
|
|
3086
|
+
if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode, data: response.data, response: response.response).blocked {
|
|
3087
|
+
self.requeueStatsEvents(queuedEvents)
|
|
2823
3088
|
semaphore.signal()
|
|
2824
3089
|
return
|
|
2825
3090
|
}
|
|
2826
3091
|
|
|
2827
3092
|
if let statusCode = response.response?.statusCode, !(200...299).contains(statusCode) {
|
|
2828
|
-
|
|
2829
|
-
|
|
3093
|
+
if CapgoUpdater.isTransientStatsFailure(statusCode) {
|
|
3094
|
+
self.requeueStatsEvents(queuedEvents)
|
|
3095
|
+
self.logger.error("Error sending stats batch")
|
|
3096
|
+
self.logger.debug("Retrying later, response code: \(statusCode)")
|
|
3097
|
+
} else {
|
|
3098
|
+
self.clearStatsInFlight()
|
|
3099
|
+
self.logger.error("Dropping stats batch after permanent error")
|
|
3100
|
+
self.logger.debug("Response code: \(statusCode)")
|
|
3101
|
+
}
|
|
2830
3102
|
semaphore.signal()
|
|
2831
3103
|
return
|
|
2832
3104
|
}
|
|
2833
3105
|
|
|
2834
3106
|
switch response.result {
|
|
2835
3107
|
case .success:
|
|
3108
|
+
self.clearStatsInFlight()
|
|
2836
3109
|
self.logger.info("Stats batch sent successfully")
|
|
2837
3110
|
self.logger.debug("Sent \(eventsToSend.count) events")
|
|
2838
|
-
|
|
3111
|
+
self.runStatsCallbacks(queuedEvents)
|
|
2839
3112
|
case let .failure(error):
|
|
3113
|
+
self.requeueStatsEvents(queuedEvents)
|
|
2840
3114
|
self.logger.error("Error sending stats batch")
|
|
2841
3115
|
self.logger.debug("Response: \(response.value?.debugDescription ?? "nil"), Error: \(error.localizedDescription)")
|
|
2842
3116
|
}
|
|
2843
3117
|
semaphore.signal()
|
|
2844
3118
|
}
|
|
2845
3119
|
semaphore.wait()
|
|
3120
|
+
if !self.statsStopped {
|
|
3121
|
+
self.persistStatsQueue()
|
|
3122
|
+
}
|
|
2846
3123
|
}
|
|
2847
3124
|
operationQueue.addOperation(operation)
|
|
2848
3125
|
}
|
|
2849
3126
|
|
|
3127
|
+
private func abandonStoppedStatsFlush() -> Bool {
|
|
3128
|
+
statsStopped
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
/// Only 429, request timeout and 5xx are worth retrying; other 4xx are permanent rejections.
|
|
3132
|
+
private static func isTransientStatsFailure(_ statusCode: Int) -> Bool {
|
|
3133
|
+
return statusCode == 429 || statusCode == 408 || statusCode >= 500
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
private func runStatsCallbacks(_ sentEvents: [QueuedStatsEvent]) {
|
|
3137
|
+
for sentEvent in sentEvents {
|
|
3138
|
+
sentEvent.onSent?()
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
private func requeueStatsEvents(_ events: [QueuedStatsEvent]) {
|
|
3143
|
+
guard !statsStopped, !events.isEmpty else { return }
|
|
3144
|
+
statsQueueLock.lock()
|
|
3145
|
+
statsInFlight.removeAll()
|
|
3146
|
+
statsQueue.insert(contentsOf: events, at: 0)
|
|
3147
|
+
if statsQueue.count > CapgoUpdater.maxPendingStats {
|
|
3148
|
+
statsQueue.removeFirst(statsQueue.count - CapgoUpdater.maxPendingStats)
|
|
3149
|
+
}
|
|
3150
|
+
statsQueueLock.unlock()
|
|
3151
|
+
persistStatsQueue()
|
|
3152
|
+
ensureStatsTimerStarted()
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
private func clearStatsInFlight() {
|
|
3156
|
+
statsQueueLock.lock()
|
|
3157
|
+
statsInFlight.removeAll()
|
|
3158
|
+
statsQueueLock.unlock()
|
|
3159
|
+
}
|
|
3160
|
+
|
|
2850
3161
|
public func getBundleInfo(id: String?) -> BundleInfo {
|
|
2851
3162
|
var trueId = BundleInfo.VERSION_UNKNOWN
|
|
2852
3163
|
if id != nil {
|
|
@@ -726,8 +726,12 @@ extension UIWindow {
|
|
|
726
726
|
return
|
|
727
727
|
}
|
|
728
728
|
|
|
729
|
-
// Check if there's an actual update available
|
|
730
|
-
|
|
729
|
+
// Check if there's an actual update available. A manifest-only
|
|
730
|
+
// response legitimately has no URL (the files come from the
|
|
731
|
+
// manifest, not a zip), so only report "already on latest" when
|
|
732
|
+
// the URL is empty AND there is no manifest to download from.
|
|
733
|
+
let hasManifest = !(latest.manifest?.isEmpty ?? true)
|
|
734
|
+
if latestKind == "up_to_date" || (latest.url.isEmpty && !hasManifest) {
|
|
731
735
|
DispatchQueue.main.async {
|
|
732
736
|
progressAlert.dismiss(animated: true) {
|
|
733
737
|
self.showSuccess(message: "Channel set to \(name). Already on latest version.", plugin: plugin)
|