@capgo/capacitor-updater 8.50.2 → 8.51.1

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.
@@ -85,7 +85,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
85
85
  CAPPluginMethod(name: "completeFlexibleUpdate", returnType: CAPPluginReturnPromise)
86
86
  ]
87
87
  public var implementation = CapgoUpdater()
88
- private let pluginVersion: String = "8.50.2"
88
+ private let pluginVersion: String = "8.51.1"
89
+ private let launchStartedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
89
90
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
90
91
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
91
92
  static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
@@ -135,6 +136,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
135
136
  private var currentBuildVersion: String = "0"
136
137
  private var autoUpdate = false
137
138
  private var autoUpdateMode = CapacitorUpdaterPlugin.autoUpdateModeOff
139
+ private var launchStartReported = false
140
+ private var launchReadyReported = false
138
141
  private var appReadyTimeout = 10000
139
142
  private var appReadyCheck: DispatchWorkItem?
140
143
  private var resetWhenUpdate = true
@@ -172,6 +175,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
172
175
 
173
176
  // Lock to ensure cleanup completes before downloads start
174
177
  private let cleanupLock = NSLock()
178
+ private let cleanupGroup = DispatchGroup()
175
179
  private var cleanupComplete = false
176
180
  private var cleanupThread: Thread?
177
181
  private var persistCustomId = false
@@ -191,6 +195,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
191
195
  private var isLeavingPreviewForIncomingLink = false
192
196
  private var previewTransitionClearWorkItem: DispatchWorkItem?
193
197
  let semaphoreReady = DispatchSemaphore(value: 0)
198
+ // Best-effort flag: set before we expect notifyAppReady (load/reload).
199
+ // No lock — never held across waits; a rare race only mis-times one wait.
200
+ private var pendingNotifyAppReady = false
194
201
 
195
202
  private var delayUpdateUtils: DelayUpdateUtils!
196
203
 
@@ -339,6 +346,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
339
346
  } else {
340
347
  implementation.defaultChannel = getConfig().getString("defaultChannel", "")!
341
348
  }
349
+ self.reportAppLaunchStart()
342
350
  self.implementation.autoReset()
343
351
  let appHealthTracker = AppHealthTracker(implementation: self.implementation)
344
352
  self.appHealthTracker = appHealthTracker
@@ -353,10 +361,21 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
353
361
  }
354
362
  self.leavePreviewSessionForLaunchURLIfNeeded()
355
363
 
356
- if resetWhenUpdate {
357
- let didResetCurrentBundle = self.resetCurrentBundleForNativeBuildChangeIfNeeded()
358
- self.cleanupObsoleteVersions(didResetCurrentBundle: didResetCurrentBundle)
364
+ // Downloads (including shake-menu / CapgoUpdater entry points) wait on this gate.
365
+ self.implementation.beforeDownload = { [weak self] in
366
+ self?.waitForCleanupIfNeeded()
359
367
  }
368
+ // Always run async cleanup: delete obsolete bundles on native update (when enabled)
369
+ // and sweep orphan directories every launch. Must not block app startup.
370
+ if !resetWhenUpdate {
371
+ UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
372
+ UserDefaults.standard.synchronize()
373
+ }
374
+ let didResetCurrentBundle = resetWhenUpdate ? self.resetCurrentBundleForNativeBuildChangeIfNeeded() : false
375
+ self.cleanupObsoleteVersions(
376
+ resetWhenUpdate: resetWhenUpdate,
377
+ didResetCurrentBundle: didResetCurrentBundle
378
+ )
360
379
  self.reportNativeVersionStatsIfChanged()
361
380
 
362
381
  // Load the server
@@ -518,6 +537,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
518
537
  }
519
538
 
520
539
  private func semaphoreUp() {
540
+ pendingNotifyAppReady = true
521
541
  DispatchQueue.global().async {
522
542
  self.semaphoreWait(waitTime: 0)
523
543
  }
@@ -629,12 +649,30 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
629
649
  return true
630
650
  }
631
651
 
632
- private func cleanupObsoleteVersions(didResetCurrentBundle: Bool = false) {
652
+ private func cleanupObsoleteVersions(resetWhenUpdate: Bool = true, didResetCurrentBundle: Bool = false) {
653
+ // Enter before start so waiters never race past an unstarted cleanup thread.
654
+ self.cleanupComplete = false
655
+ self.cleanupGroup.enter()
633
656
  cleanupThread = Thread {
657
+ let bgTaskLock = NSLock()
658
+ var cleanupBackgroundTask = UIBackgroundTaskIdentifier.invalid
659
+ let endCleanupBackgroundTask = {
660
+ bgTaskLock.lock()
661
+ defer { bgTaskLock.unlock() }
662
+ if cleanupBackgroundTask != .invalid {
663
+ UIApplication.shared.endBackgroundTask(cleanupBackgroundTask)
664
+ cleanupBackgroundTask = .invalid
665
+ }
666
+ }
667
+ cleanupBackgroundTask = UIApplication.shared.beginBackgroundTask(withName: "CapgoBundleCleanup") {
668
+ endCleanupBackgroundTask()
669
+ }
634
670
  self.cleanupLock.lock()
635
671
  defer {
636
672
  self.cleanupComplete = true
637
673
  self.cleanupLock.unlock()
674
+ self.cleanupGroup.leave()
675
+ endCleanupBackgroundTask()
638
676
  self.logger.info("Cleanup complete")
639
677
  }
640
678
 
@@ -665,54 +703,37 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
665
703
  // 2. Compare both keys. If any is not equal to "currentBuildVersion", then revert to builtin version. This fixes the part 2 of this bug
666
704
 
667
705
  let previous = self.storedNativeBuildVersion()
668
- if previous != "0" && self.currentBuildVersion != previous {
706
+ let nativeVersionChanged = previous != "0" && self.currentBuildVersion != previous
707
+ if resetWhenUpdate && nativeVersionChanged {
669
708
  if !didResetCurrentBundle {
670
709
  self.logger.info("Native build version changed from \(previous) to \(self.currentBuildVersion). Resetting current bundle to builtin.")
671
710
  self.implementation.reset(isInternal: true)
672
711
  }
673
712
  let res = self.implementation.list()
674
713
  for version in res {
675
- // Check if thread was cancelled
676
- if Thread.current.isCancelled {
677
- self.logger.warn("Cleanup was cancelled, stopping")
678
- return
679
- }
680
714
  self.logger.info("Deleting obsolete bundle: \(version.getId())")
681
- let res = self.implementation.delete(id: version.getId())
682
- if !res {
715
+ let deleted = self.implementation.delete(id: version.getId())
716
+ if !deleted {
683
717
  self.logger.error("Delete failed, id \(version.getId()) doesn't exist")
684
718
  }
719
+ Thread.sleep(forTimeInterval: 0.075)
685
720
  }
686
-
687
- let storedBundles = self.implementation.list(raw: true)
688
- let allowedIds = Set(storedBundles.compactMap { info -> String? in
689
- let id = info.getId()
690
- return id.isEmpty ? nil : id
691
- })
692
- self.implementation.cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: Thread.current)
693
- self.implementation.cleanupOrphanedTempFolders(threadToCheck: Thread.current)
694
-
695
- // Check again before the expensive delta cache cleanup
696
- if Thread.current.isCancelled {
697
- self.logger.warn("Cleanup was cancelled before delta cache cleanup")
698
- return
699
- }
700
- self.implementation.cleanupDeltaCache(threadToCheck: Thread.current)
721
+ self.implementation.cleanupDeltaCache()
701
722
  }
723
+
724
+ // Resume any DELETING leftovers from prior kills, one-by-one.
725
+ self.implementation.drainPendingDeletes()
726
+
727
+ // Always sweep orphan directories so incomplete prior cleanups (or failed deletes)
728
+ // cannot leave hundreds of MB behind across launches.
729
+ let allowedIds = self.implementation.allowedBundleIdsForCleanup()
730
+ self.implementation.cleanupDownloadDirectories(allowedIds: allowedIds)
731
+ self.implementation.cleanupOrphanedTempFolders(threadToCheck: nil)
732
+
702
733
  UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
703
734
  UserDefaults.standard.synchronize()
704
735
  }
705
736
  cleanupThread?.start()
706
-
707
- // Start a timeout watchdog thread to cancel cleanup if it takes too long
708
- let timeout = Double(self.appReadyTimeout / 2) / 1000.0
709
- Thread.detachNewThread {
710
- Thread.sleep(forTimeInterval: timeout)
711
- if let thread = self.cleanupThread, !thread.isFinished && !self.cleanupComplete {
712
- self.logger.warn("Cleanup timeout exceeded (\(timeout)s), cancelling cleanup thread")
713
- thread.cancel()
714
- }
715
- }
716
737
  }
717
738
 
718
739
  private func waitForCleanupIfNeeded() {
@@ -721,11 +742,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
721
742
  }
722
743
 
723
744
  logger.info("Waiting for cleanup to complete before starting download...")
724
-
725
- // Wait for cleanup to complete - blocks until lock is released
726
- cleanupLock.lock()
727
- cleanupLock.unlock()
728
-
745
+ cleanupGroup.wait()
729
746
  logger.info("Cleanup finished, proceeding with download")
730
747
  }
731
748
 
@@ -1142,6 +1159,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1142
1159
  }
1143
1160
 
1144
1161
  private func downloadBundle(urlString: String, version: String, sessionKey: String, checksum rawChecksum: String, manifestEntries: [ManifestEntry]?) throws -> BundleInfo {
1162
+ // Manual/preview downloads must wait too - launch orphan sweep can delete their temps.
1163
+ self.waitForCleanupIfNeeded()
1145
1164
  guard let url = URL(string: urlString) else {
1146
1165
  throw makePreviewError("Invalid download URL")
1147
1166
  }
@@ -1293,6 +1312,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1293
1312
 
1294
1313
  let performReload: () -> Bool = {
1295
1314
  guard self.applyCurrentBundleToBridge(bridge) else {
1315
+ self.clearPendingNotifyAppReady()
1296
1316
  return false
1297
1317
  }
1298
1318
  self.checkAppReady()
@@ -2674,10 +2694,67 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2674
2694
  ])
2675
2695
  }
2676
2696
 
2697
+ private func reportAppLaunchStart() {
2698
+ guard !self.implementation.statsUrl.isEmpty, !launchStartReported else {
2699
+ return
2700
+ }
2701
+
2702
+ launchStartReported = true
2703
+ let current = self.implementation.getCurrentBundle()
2704
+ self.implementation.sendStats(
2705
+ action: "app_launch_start",
2706
+ versionName: current.getVersionName(),
2707
+ oldVersionName: "",
2708
+ metadata: [
2709
+ "launch_started_at": String(launchStartedAtMs),
2710
+ "source": "plugin_load"
2711
+ ]
2712
+ )
2713
+ }
2714
+
2715
+ private func reportAppLaunchReady(_ bundle: BundleInfo) {
2716
+ guard !self.implementation.statsUrl.isEmpty, !launchReadyReported else {
2717
+ return
2718
+ }
2719
+
2720
+ launchReadyReported = true
2721
+ let duration = max(0, Int64(Date().timeIntervalSince1970 * 1000) - launchStartedAtMs)
2722
+ self.implementation.sendStats(
2723
+ action: "app_launch_ready",
2724
+ versionName: bundle.getVersionName(),
2725
+ oldVersionName: "",
2726
+ metadata: [
2727
+ "duration_ms": String(duration),
2728
+ "launch_started_at": String(launchStartedAtMs),
2729
+ "source": "notify_app_ready"
2730
+ ]
2731
+ )
2732
+ }
2733
+
2734
+ private func reportAppLaunchTimeout(_ bundle: BundleInfo) {
2735
+ guard !self.implementation.statsUrl.isEmpty else {
2736
+ return
2737
+ }
2738
+
2739
+ let duration = max(0, Int64(Date().timeIntervalSince1970 * 1000) - launchStartedAtMs)
2740
+ self.implementation.sendStats(
2741
+ action: "app_launch_timeout",
2742
+ versionName: bundle.getVersionName(),
2743
+ oldVersionName: "",
2744
+ metadata: [
2745
+ "duration_ms": String(duration),
2746
+ "launch_started_at": String(launchStartedAtMs),
2747
+ "timeout_ms": String(appReadyTimeout),
2748
+ "source": "app_ready_timeout"
2749
+ ]
2750
+ )
2751
+ }
2752
+
2677
2753
  @objc func notifyAppReady(_ call: CAPPluginCall) {
2678
2754
  self.semaphoreDown()
2679
2755
  let bundle = self.implementation.getCurrentBundle()
2680
2756
  self.implementation.setSuccess(bundle: bundle, autoDeletePrevious: self.autoDeletePrevious)
2757
+ self.reportAppLaunchReady(bundle)
2681
2758
  logger.info("Current bundle loaded successfully. [notifyAppReady was called] \(bundle.toString())")
2682
2759
  self.clearIncomingPreviewTransition()
2683
2760
  self.hidePreviewTransitionLoader(reason: "notify-app-ready")
@@ -2785,16 +2862,26 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2785
2862
  "bundle": current.toJSON()
2786
2863
  ])
2787
2864
  self.persistLastFailedBundle(current)
2865
+ self.reportAppLaunchTimeout(current)
2788
2866
  self.implementation.sendStats(action: "update_fail", versionName: current.getVersionName())
2789
2867
  self.implementation.setError(bundle: current)
2790
2868
  _ = self.performReset(toLastSuccessful: true, usePendingBundle: false, isInternal: true)
2791
2869
  if self.autoDeleteFailed && !current.isBuiltin() {
2870
+ let failedId = current.getId()
2792
2871
  logger.info("Deleting failing bundle: \(current.toString())")
2793
- let res = self.implementation.delete(id: current.getId(), removeInfo: false)
2794
- if !res {
2795
- logger.info("Delete version deleted: \(current.toString())")
2796
- } else {
2797
- logger.error("Failed to delete failed bundle: \(current.toString())")
2872
+ // Mark before async work so kill/OOM still resumes via drainPendingDeletes.
2873
+ self.implementation.saveBundleInfo(
2874
+ id: failedId,
2875
+ bundle: current.setStatus(status: BundleStatus.DELETING.storedValue)
2876
+ )
2877
+ UserDefaults.standard.synchronize()
2878
+ DispatchQueue.global(qos: .utility).async {
2879
+ let res = self.implementation.delete(id: failedId, removeInfo: false)
2880
+ if res {
2881
+ self.logger.info("Failed bundle deleted: \(failedId)")
2882
+ } else {
2883
+ self.logger.error("Failed to delete failed bundle: \(failedId)")
2884
+ }
2798
2885
  }
2799
2886
  }
2800
2887
  } else {
@@ -2819,7 +2906,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2819
2906
  func sendReadyToJs(current: BundleInfo, msg: String) {
2820
2907
  logger.info("sendReadyToJs")
2821
2908
  DispatchQueue.global().async {
2822
- self.semaphoreWait(waitTime: self.appReadyTimeout)
2909
+ // Only wait after load()/reload() armed the semaphore. Foreground resumes
2910
+ // with autoUpdate disabled call sendReadyToJs again without a fresh
2911
+ // notifyAppReady(), so waiting there always timed out after appReadyTimeout.
2912
+ if self.consumePendingNotifyAppReady() {
2913
+ self.semaphoreWait(waitTime: self.appReadyTimeout)
2914
+ }
2823
2915
  self.notifyListeners("appReady", data: ["bundle": current.toJSON(), "status": msg], retainUntilConsumed: true)
2824
2916
 
2825
2917
  // Auto hide splashscreen if enabled
@@ -2831,6 +2923,16 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2831
2923
  }
2832
2924
  }
2833
2925
 
2926
+ private func consumePendingNotifyAppReady() -> Bool {
2927
+ let shouldWait = pendingNotifyAppReady
2928
+ pendingNotifyAppReady = false
2929
+ return shouldWait
2930
+ }
2931
+
2932
+ private func clearPendingNotifyAppReady() {
2933
+ pendingNotifyAppReady = false
2934
+ }
2935
+
2834
2936
  private func hideSplashscreen() {
2835
2937
  if Thread.isMainThread {
2836
2938
  self.performHideSplashscreen()
@@ -3444,6 +3546,22 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3444
3546
  self.currentBuildVersion = currentBuildVersion
3445
3547
  }
3446
3548
 
3549
+ func setAppReadyTimeoutForTesting(_ timeout: Int) {
3550
+ self.appReadyTimeout = timeout
3551
+ }
3552
+
3553
+ func armPendingNotifyAppReadyForTesting() {
3554
+ pendingNotifyAppReady = true
3555
+ }
3556
+
3557
+ func clearPendingNotifyAppReadyForTesting() {
3558
+ clearPendingNotifyAppReady()
3559
+ }
3560
+
3561
+ var isPendingNotifyAppReadyForTesting: Bool {
3562
+ pendingNotifyAppReady
3563
+ }
3564
+
3447
3565
  func shouldUseDirectUpdateForTesting() -> Bool {
3448
3566
  self.shouldUseDirectUpdate()
3449
3567
  }