@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.
@@ -49,17 +49,30 @@ import UIKit
49
49
  // Cached key ID calculated once from publicKey
50
50
  private var cachedKeyId: String?
51
51
 
52
- // Flag to track if we received a 429 response - stops requests until app restart
53
- private static var rateLimitExceeded = false
54
-
55
- // Flag to track if we've already sent the rate limit statistic - prevents infinite loop
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
- // Invalidate the stats timer to prevent memory leaks
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
- * Check if a 429 (Too Many Requests) response was received and set the flag
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(statusCode: Int?) -> Bool {
428
- if statusCode == 429 {
429
- // Send a statistic about the rate limit BEFORE setting the flag
430
- // Only send once to prevent infinite loop if the stat request itself gets rate limited
431
- if !previewSession && !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
432
- CapgoUpdater.rateLimitStatisticSent = true
433
-
434
- // Dispatch to background queue to avoid blocking the main thread
435
- DispatchQueue.global(qos: .utility).async {
436
- self.sendRateLimitStatistic()
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
- return false
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
- if self.checkAndHandleRateLimitResponse(statusCode: latest.statusCode) {
909
- latest.message = "Rate limit exceeded"
910
- latest.error = "rate_limit_exceeded"
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
- // Check if rate limit was exceeded
2423
- if CapgoUpdater.rateLimitExceeded {
2424
- logger.debug("Skipping setChannel due to rate limit (429). Requests will resume after app restart.")
2425
- setChannel.message = "Rate limit exceeded"
2426
- setChannel.error = "rate_limit_exceeded"
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
- if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
2452
- setChannel.message = "Rate limit exceeded"
2453
- setChannel.error = "rate_limit_exceeded"
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 CapgoUpdater.rateLimitExceeded {
2513
- logger.debug("Skipping getChannel due to rate limit (429). Requests will resume after app restart.")
2514
- getChannel.message = "Rate limit exceeded"
2515
- getChannel.error = "rate_limit_exceeded"
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
- if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
2540
- getChannel.message = "Rate limit exceeded"
2541
- getChannel.error = "rate_limit_exceeded"
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 CapgoUpdater.rateLimitExceeded {
2620
- logger.debug("Skipping listChannels due to rate limit (429). Requests will resume after app restart.")
2621
- listChannels.error = "rate_limit_exceeded"
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
- if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
2656
- listChannels.error = "rate_limit_exceeded"
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 previewSession {
2736
- logger.debug("Skipping sendStats during preview session.")
2913
+ if statsStopped {
2737
2914
  return
2738
2915
  }
2739
2916
 
2740
- // Check if rate limit was exceeded
2741
- if CapgoUpdater.rateLimitExceeded {
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
- // Check for 429 rate limit
2822
- if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
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
- self.logger.error("Error sending stats batch")
2829
- self.logger.debug("Response code: \(statusCode)")
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
- onSentCallbacks.forEach { $0() }
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
- if latestKind == "up_to_date" || latest.url.isEmpty {
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "8.51.4",
3
+ "version": "8.51.6",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",