@capgo/capacitor-updater 8.51.0 → 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,7 @@ 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.51.0"
88
+ private let pluginVersion: String = "8.51.1"
89
89
  private let launchStartedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
90
90
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
91
91
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
@@ -175,6 +175,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
175
175
 
176
176
  // Lock to ensure cleanup completes before downloads start
177
177
  private let cleanupLock = NSLock()
178
+ private let cleanupGroup = DispatchGroup()
178
179
  private var cleanupComplete = false
179
180
  private var cleanupThread: Thread?
180
181
  private var persistCustomId = false
@@ -194,6 +195,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
194
195
  private var isLeavingPreviewForIncomingLink = false
195
196
  private var previewTransitionClearWorkItem: DispatchWorkItem?
196
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
197
201
 
198
202
  private var delayUpdateUtils: DelayUpdateUtils!
199
203
 
@@ -357,10 +361,21 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
357
361
  }
358
362
  self.leavePreviewSessionForLaunchURLIfNeeded()
359
363
 
360
- if resetWhenUpdate {
361
- let didResetCurrentBundle = self.resetCurrentBundleForNativeBuildChangeIfNeeded()
362
- 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()
363
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
+ )
364
379
  self.reportNativeVersionStatsIfChanged()
365
380
 
366
381
  // Load the server
@@ -522,6 +537,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
522
537
  }
523
538
 
524
539
  private func semaphoreUp() {
540
+ pendingNotifyAppReady = true
525
541
  DispatchQueue.global().async {
526
542
  self.semaphoreWait(waitTime: 0)
527
543
  }
@@ -633,12 +649,30 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
633
649
  return true
634
650
  }
635
651
 
636
- 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()
637
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
+ }
638
670
  self.cleanupLock.lock()
639
671
  defer {
640
672
  self.cleanupComplete = true
641
673
  self.cleanupLock.unlock()
674
+ self.cleanupGroup.leave()
675
+ endCleanupBackgroundTask()
642
676
  self.logger.info("Cleanup complete")
643
677
  }
644
678
 
@@ -669,54 +703,37 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
669
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
670
704
 
671
705
  let previous = self.storedNativeBuildVersion()
672
- if previous != "0" && self.currentBuildVersion != previous {
706
+ let nativeVersionChanged = previous != "0" && self.currentBuildVersion != previous
707
+ if resetWhenUpdate && nativeVersionChanged {
673
708
  if !didResetCurrentBundle {
674
709
  self.logger.info("Native build version changed from \(previous) to \(self.currentBuildVersion). Resetting current bundle to builtin.")
675
710
  self.implementation.reset(isInternal: true)
676
711
  }
677
712
  let res = self.implementation.list()
678
713
  for version in res {
679
- // Check if thread was cancelled
680
- if Thread.current.isCancelled {
681
- self.logger.warn("Cleanup was cancelled, stopping")
682
- return
683
- }
684
714
  self.logger.info("Deleting obsolete bundle: \(version.getId())")
685
- let res = self.implementation.delete(id: version.getId())
686
- if !res {
715
+ let deleted = self.implementation.delete(id: version.getId())
716
+ if !deleted {
687
717
  self.logger.error("Delete failed, id \(version.getId()) doesn't exist")
688
718
  }
719
+ Thread.sleep(forTimeInterval: 0.075)
689
720
  }
690
-
691
- let storedBundles = self.implementation.list(raw: true)
692
- let allowedIds = Set(storedBundles.compactMap { info -> String? in
693
- let id = info.getId()
694
- return id.isEmpty ? nil : id
695
- })
696
- self.implementation.cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: Thread.current)
697
- self.implementation.cleanupOrphanedTempFolders(threadToCheck: Thread.current)
698
-
699
- // Check again before the expensive delta cache cleanup
700
- if Thread.current.isCancelled {
701
- self.logger.warn("Cleanup was cancelled before delta cache cleanup")
702
- return
703
- }
704
- self.implementation.cleanupDeltaCache(threadToCheck: Thread.current)
721
+ self.implementation.cleanupDeltaCache()
705
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
+
706
733
  UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
707
734
  UserDefaults.standard.synchronize()
708
735
  }
709
736
  cleanupThread?.start()
710
-
711
- // Start a timeout watchdog thread to cancel cleanup if it takes too long
712
- let timeout = Double(self.appReadyTimeout / 2) / 1000.0
713
- Thread.detachNewThread {
714
- Thread.sleep(forTimeInterval: timeout)
715
- if let thread = self.cleanupThread, !thread.isFinished && !self.cleanupComplete {
716
- self.logger.warn("Cleanup timeout exceeded (\(timeout)s), cancelling cleanup thread")
717
- thread.cancel()
718
- }
719
- }
720
737
  }
721
738
 
722
739
  private func waitForCleanupIfNeeded() {
@@ -725,11 +742,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
725
742
  }
726
743
 
727
744
  logger.info("Waiting for cleanup to complete before starting download...")
728
-
729
- // Wait for cleanup to complete - blocks until lock is released
730
- cleanupLock.lock()
731
- cleanupLock.unlock()
732
-
745
+ cleanupGroup.wait()
733
746
  logger.info("Cleanup finished, proceeding with download")
734
747
  }
735
748
 
@@ -1146,6 +1159,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1146
1159
  }
1147
1160
 
1148
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()
1149
1164
  guard let url = URL(string: urlString) else {
1150
1165
  throw makePreviewError("Invalid download URL")
1151
1166
  }
@@ -1297,6 +1312,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1297
1312
 
1298
1313
  let performReload: () -> Bool = {
1299
1314
  guard self.applyCurrentBundleToBridge(bridge) else {
1315
+ self.clearPendingNotifyAppReady()
1300
1316
  return false
1301
1317
  }
1302
1318
  self.checkAppReady()
@@ -2851,12 +2867,21 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2851
2867
  self.implementation.setError(bundle: current)
2852
2868
  _ = self.performReset(toLastSuccessful: true, usePendingBundle: false, isInternal: true)
2853
2869
  if self.autoDeleteFailed && !current.isBuiltin() {
2870
+ let failedId = current.getId()
2854
2871
  logger.info("Deleting failing bundle: \(current.toString())")
2855
- let res = self.implementation.delete(id: current.getId(), removeInfo: false)
2856
- if !res {
2857
- logger.info("Delete version deleted: \(current.toString())")
2858
- } else {
2859
- 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
+ }
2860
2885
  }
2861
2886
  }
2862
2887
  } else {
@@ -2881,7 +2906,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2881
2906
  func sendReadyToJs(current: BundleInfo, msg: String) {
2882
2907
  logger.info("sendReadyToJs")
2883
2908
  DispatchQueue.global().async {
2884
- 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
+ }
2885
2915
  self.notifyListeners("appReady", data: ["bundle": current.toJSON(), "status": msg], retainUntilConsumed: true)
2886
2916
 
2887
2917
  // Auto hide splashscreen if enabled
@@ -2893,6 +2923,16 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2893
2923
  }
2894
2924
  }
2895
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
+
2896
2936
  private func hideSplashscreen() {
2897
2937
  if Thread.isMainThread {
2898
2938
  self.performHideSplashscreen()
@@ -3506,6 +3546,22 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3506
3546
  self.currentBuildVersion = currentBuildVersion
3507
3547
  }
3508
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
+
3509
3565
  func shouldUseDirectUpdateForTesting() -> Bool {
3510
3566
  self.shouldUseDirectUpdate()
3511
3567
  }
@@ -22,8 +22,11 @@ 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
+ private let deletePaceSeconds: TimeInterval = 0.075
29
+ private let deleteLock = NSLock()
27
30
 
28
31
  // Add this line to declare cacheFolder
29
32
  private let cacheFolder: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("capgo_downloads")
@@ -34,6 +37,8 @@ import UIKit
34
37
  public var pluginVersion: String = ""
35
38
  public var timeout: Double = 20
36
39
  public var statsUrl: String = ""
40
+ /// Optional gate run before any download touches disk (e.g. wait for launch cleanup).
41
+ public var beforeDownload: (() -> Void)?
37
42
  public var channelUrl: String = ""
38
43
  public var defaultChannel: String = ""
39
44
  public var appId: String = ""
@@ -1052,7 +1057,12 @@ import UIKit
1052
1057
  return json
1053
1058
  }
1054
1059
 
1060
+ private func runBeforeDownload() {
1061
+ beforeDownload?()
1062
+ }
1063
+
1055
1064
  public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
1065
+ self.runBeforeDownload()
1056
1066
  let id = self.randomString(length: 10)
1057
1067
  logger.info("downloadManifest start \(id)")
1058
1068
  let destFolder = self.getBundleDirectory(id: id)
@@ -1535,6 +1545,7 @@ import UIKit
1535
1545
  }
1536
1546
 
1537
1547
  public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
1548
+ self.runBeforeDownload()
1538
1549
  let id: String = self.randomString(length: 10)
1539
1550
  // Each download uses its own temp files keyed by bundle ID to prevent collisions
1540
1551
  if version != getLocalUpdateVersion(for: id) {
@@ -1811,6 +1822,9 @@ import UIKit
1811
1822
  }
1812
1823
 
1813
1824
  public func delete(id: String, removeInfo: Bool) -> Bool {
1825
+ self.deleteLock.lock()
1826
+ defer { self.deleteLock.unlock() }
1827
+
1814
1828
  let deleted: BundleInfo = self.getBundleInfo(id: id)
1815
1829
  if deleted.isBuiltin() || self.getCurrentBundleId() == id {
1816
1830
  logger.info("Cannot delete current or builtin bundle")
@@ -1821,6 +1835,7 @@ import UIKit
1821
1835
  if let previewFallback = self.getPreviewFallbackBundle(),
1822
1836
  !previewFallback.isDeleted(),
1823
1837
  !previewFallback.isErrorStatus(),
1838
+ !previewFallback.isDeleting(),
1824
1839
  previewFallback.getId() == id {
1825
1840
  logger.info("Cannot delete the preview fallback bundle")
1826
1841
  logger.debug("Bundle ID: \(id)")
@@ -1831,6 +1846,7 @@ import UIKit
1831
1846
  if let next = self.getNextBundle(),
1832
1847
  !next.isDeleted() &&
1833
1848
  !next.isErrorStatus() &&
1849
+ !next.isDeleting() &&
1834
1850
  next.getId() == id {
1835
1851
  logger.info("Cannot delete the next bundle")
1836
1852
  logger.debug("Bundle ID: \(id)")
@@ -1838,24 +1854,55 @@ import UIKit
1838
1854
  }
1839
1855
 
1840
1856
  let destPersist: URL = libraryDir.appendingPathComponent(bundleDirectory).appendingPathComponent(id)
1841
- do {
1842
- try FileManager.default.removeItem(atPath: destPersist.path)
1843
- } catch {
1844
- logger.error("Bundle folder not removed")
1845
- logger.debug("Path: \(destPersist.path)")
1846
- // even if, we don;t care. Android doesn't care
1847
- if removeInfo {
1848
- self.removeBundleInfo(id: id)
1857
+ let hadRegistry = self.hasStoredBundleInfo(id: id)
1858
+ let hadFolder = FileManager.default.fileExists(atPath: destPersist.path)
1859
+ if !hadRegistry && !hadFolder {
1860
+ logger.error("Cannot delete unknown bundle")
1861
+ logger.debug("Bundle ID: \(id)")
1862
+ return false
1863
+ }
1864
+
1865
+ // Persist DELETING before touching disk so kill/OOM can resume on next launch.
1866
+ if !deleted.isDeleting() {
1867
+ if !self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETING.storedValue)) {
1868
+ logger.error("Failed to persist DELETING marker, aborting disk delete")
1869
+ logger.debug("Bundle ID: \(id)")
1870
+ return false
1849
1871
  }
1850
- self.sendStats(action: "delete", versionName: deleted.getVersionName())
1872
+ UserDefaults.standard.synchronize()
1873
+ }
1874
+
1875
+ if FileManager.default.fileExists(atPath: destPersist.path) {
1876
+ do {
1877
+ try FileManager.default.removeItem(atPath: destPersist.path)
1878
+ } catch {
1879
+ logger.error("Bundle folder not removed, will retry later")
1880
+ logger.debug("Path: \(destPersist.path), Error: \(error.localizedDescription)")
1881
+ return false
1882
+ }
1883
+ }
1884
+
1885
+ // Only drop registry after the folder is confirmed gone.
1886
+ if FileManager.default.fileExists(atPath: destPersist.path) {
1887
+ logger.error("Bundle folder still present after delete, will retry later")
1888
+ logger.debug("Bundle ID: \(id)")
1851
1889
  return false
1852
1890
  }
1891
+
1892
+ let finalized: Bool
1853
1893
  if removeInfo {
1854
- self.removeBundleInfo(id: id)
1894
+ finalized = self.saveBundleInfo(id: id, bundle: nil)
1855
1895
  } else {
1856
- self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.storedValue))
1896
+ finalized = self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.storedValue))
1857
1897
  }
1858
- logger.info("Bundle deleted successfully")
1898
+ guard finalized else {
1899
+ logger.error("Failed to finalize delete registry update, will retry later")
1900
+ logger.debug("Bundle ID: \(id)")
1901
+ return false
1902
+ }
1903
+ UserDefaults.standard.synchronize()
1904
+ self.dequeuePendingDelete(id: id)
1905
+ logger.info("Bundle deleted and confirmed gone")
1859
1906
  logger.debug("Version: \(deleted.getVersionName())")
1860
1907
  self.sendStats(action: "delete", versionName: deleted.getVersionName())
1861
1908
  return true
@@ -1865,6 +1912,47 @@ import UIKit
1865
1912
  return self.delete(id: id, removeInfo: true)
1866
1913
  }
1867
1914
 
1915
+ /// Resume incomplete deletes one-by-one. Safe across app kill / OOM because
1916
+ /// delete() marks DELETING before disk work and only clears registry after confirm.
1917
+ public func drainPendingDeletes() {
1918
+ var pendingIds = Set(self.list(raw: true).filter { $0.isDeleting() }.map { $0.getId() }.filter { !$0.isEmpty })
1919
+ pendingIds.formUnion(self.getPendingDeleteIds())
1920
+ for id in pendingIds {
1921
+ logger.info("Resuming pending delete for bundle: \(id)")
1922
+ if self.delete(id: id, removeInfo: true) {
1923
+ self.dequeuePendingDelete(id: id)
1924
+ }
1925
+ Thread.sleep(forTimeInterval: self.deletePaceSeconds)
1926
+ }
1927
+ }
1928
+
1929
+ private func getPendingDeleteIds() -> Set<String> {
1930
+ guard let raw = UserDefaults.standard.string(forKey: self.PENDING_DELETE_IDS), !raw.isEmpty else {
1931
+ return []
1932
+ }
1933
+ return Set(raw.split(separator: ",").map { String($0) }.filter { !$0.isEmpty })
1934
+ }
1935
+
1936
+ private func enqueuePendingDelete(id: String) {
1937
+ guard !id.isEmpty else { return }
1938
+ var ids = self.getPendingDeleteIds()
1939
+ guard ids.insert(id).inserted else { return }
1940
+ UserDefaults.standard.set(ids.sorted().joined(separator: ","), forKey: self.PENDING_DELETE_IDS)
1941
+ UserDefaults.standard.synchronize()
1942
+ }
1943
+
1944
+ private func dequeuePendingDelete(id: String) {
1945
+ guard !id.isEmpty else { return }
1946
+ var ids = self.getPendingDeleteIds()
1947
+ guard ids.remove(id) != nil else { return }
1948
+ if ids.isEmpty {
1949
+ UserDefaults.standard.removeObject(forKey: self.PENDING_DELETE_IDS)
1950
+ } else {
1951
+ UserDefaults.standard.set(ids.sorted().joined(separator: ","), forKey: self.PENDING_DELETE_IDS)
1952
+ }
1953
+ UserDefaults.standard.synchronize()
1954
+ }
1955
+
1868
1956
  public func cleanupDeltaCache() {
1869
1957
  cleanupDeltaCache(threadToCheck: nil)
1870
1958
  }
@@ -1924,6 +2012,11 @@ import UIKit
1924
2012
 
1925
2013
  do {
1926
2014
  try fileManager.removeItem(at: url)
2015
+ if fileManager.fileExists(atPath: url.path) {
2016
+ logger.error("Orphan bundle directory still present after delete")
2017
+ logger.debug("Bundle ID: \(id)")
2018
+ continue
2019
+ }
1927
2020
  self.removeBundleInfo(id: id)
1928
2021
  logger.info("Deleted orphan bundle directory")
1929
2022
  logger.debug("Bundle ID: \(id)")
@@ -1938,6 +2031,40 @@ import UIKit
1938
2031
  }
1939
2032
  }
1940
2033
 
2034
+ public func allowedBundleIdsForCleanup() -> Set<String> {
2035
+ var allowedIds = Set(self.list(raw: true).compactMap { info -> String? in
2036
+ let id = info.getId()
2037
+ // DELETED tombstones must not protect leftover folders.
2038
+ // DELETING stays protected so drainPendingDeletes owns the removal.
2039
+ if id.isEmpty || info.isDeleted() {
2040
+ return nil
2041
+ }
2042
+ return id
2043
+ })
2044
+ let currentId = self.getCurrentBundleId()
2045
+ if !currentId.isEmpty {
2046
+ allowedIds.insert(currentId)
2047
+ }
2048
+ let fallback = self.getFallbackBundle()
2049
+ let fallbackId = fallback.getId()
2050
+ if !fallbackId.isEmpty && !fallback.isDeleting() {
2051
+ allowedIds.insert(fallbackId)
2052
+ }
2053
+ if let next = self.getNextBundle() {
2054
+ let nextId = next.getId()
2055
+ if !nextId.isEmpty && !next.isDeleting() {
2056
+ allowedIds.insert(nextId)
2057
+ }
2058
+ }
2059
+ if let previewFallback = self.getPreviewFallbackBundle() {
2060
+ let previewId = previewFallback.getId()
2061
+ if !previewId.isEmpty && !previewFallback.isDeleting() {
2062
+ allowedIds.insert(previewId)
2063
+ }
2064
+ }
2065
+ return allowedIds
2066
+ }
2067
+
1941
2068
  public func cleanupOrphanedTempFolders(threadToCheck: Thread?) {
1942
2069
  let fileManager = FileManager.default
1943
2070
 
@@ -2079,7 +2206,8 @@ import UIKit
2079
2206
  destPersist.isDirectory &&
2080
2207
  !indexPersist.isDirectory &&
2081
2208
  indexPersist.exist &&
2082
- !bundleIndo.isDeleted() {
2209
+ !bundleIndo.isDeleted() &&
2210
+ !bundleIndo.isDeleting() {
2083
2211
  return true
2084
2212
  }
2085
2213
  return false
@@ -2169,17 +2297,39 @@ import UIKit
2169
2297
  let fallbackIsPreviewFallback = previewFallback?.getId() == fallback.getId()
2170
2298
  logger.info("Fallback bundle is: \(fallback.toString())")
2171
2299
  logger.info("Version successfully loaded: \(bundle.toString())")
2172
- if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() && !fallbackIsPreviewFallback {
2173
- let res = self.delete(id: fallback.getId())
2174
- if res {
2175
- logger.info("Deleted previous bundle")
2176
- logger.debug("Bundle: \(fallback.toString())")
2177
- } else {
2178
- logger.error("Failed to delete previous bundle")
2179
- logger.debug("Bundle: \(fallback.toString())")
2300
+ let previousFallbackId = fallback.getId()
2301
+ let nextBundle = self.getNextBundle()
2302
+ let previousIsNext = nextBundle?.getId() == previousFallbackId &&
2303
+ !(nextBundle?.isDeleted() ?? true) &&
2304
+ !(nextBundle?.isErrorStatus() ?? true) &&
2305
+ !(nextBundle?.isDeleting() ?? true)
2306
+ let shouldDeletePrevious = autoDeletePrevious &&
2307
+ !fallback.isBuiltin() &&
2308
+ previousFallbackId != bundle.getId() &&
2309
+ !fallbackIsPreviewFallback &&
2310
+ !previousIsNext
2311
+ if shouldDeletePrevious {
2312
+ // Mark durable intent before fallback switch so a kill mid-flight still retries.
2313
+ if !self.saveBundleInfo(id: previousFallbackId, bundle: fallback.setStatus(status: BundleStatus.DELETING.storedValue)) {
2314
+ self.logger.error("Failed to persist DELETING for previous bundle; queueing durable retry")
2315
+ self.logger.debug("Bundle ID: \(previousFallbackId)")
2316
+ self.enqueuePendingDelete(id: previousFallbackId)
2180
2317
  }
2318
+ UserDefaults.standard.synchronize()
2181
2319
  }
2182
2320
  self.setFallbackBundle(fallback: bundle)
2321
+ if shouldDeletePrevious {
2322
+ DispatchQueue.global(qos: .utility).async {
2323
+ let res = self.delete(id: previousFallbackId)
2324
+ if res {
2325
+ self.logger.info("Deleted previous bundle")
2326
+ self.logger.debug("Bundle ID: \(previousFallbackId)")
2327
+ } else {
2328
+ self.logger.info("Previous bundle delete incomplete, will retry")
2329
+ self.logger.debug("Bundle ID: \(previousFallbackId)")
2330
+ }
2331
+ }
2332
+ }
2183
2333
  }
2184
2334
 
2185
2335
  public func setError(bundle: BundleInfo) {
@@ -2680,26 +2830,30 @@ import UIKit
2680
2830
  self.saveBundleInfo(id: id, bundle: nil)
2681
2831
  }
2682
2832
 
2683
- public func saveBundleInfo(id: String, bundle: BundleInfo?) {
2833
+ @discardableResult
2834
+ public func saveBundleInfo(id: String, bundle: BundleInfo?) -> Bool {
2684
2835
  if bundle != nil && (bundle!.isBuiltin() || bundle!.isUnknown()) {
2685
2836
  logger.info("Not saving info for bundle [\(id)] \(bundle?.toString() ?? "")")
2686
- return
2837
+ return false
2687
2838
  }
2688
2839
  if bundle == nil {
2689
2840
  logger.info("Removing info for bundle [\(id)]")
2690
2841
  UserDefaults.standard.removeObject(forKey: "\(id)\(self.INFO_SUFFIX)")
2691
- } else {
2692
- let update = bundle!.setId(id: id)
2693
- logger.info("Storing info for bundle [\(id)] \(update.toString())")
2694
- do {
2695
- try UserDefaults.standard.setObj(update, forKey: "\(id)\(self.INFO_SUFFIX)")
2696
- } catch {
2697
- logger.error("Failed to save bundle info")
2698
- logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
2699
- }
2842
+ return true
2843
+ }
2844
+ let update = bundle!.setId(id: id)
2845
+ logger.info("Storing info for bundle [\(id)] \(update.toString())")
2846
+ do {
2847
+ try UserDefaults.standard.setObj(update, forKey: "\(id)\(self.INFO_SUFFIX)")
2848
+ return true
2849
+ } catch {
2850
+ logger.error("Failed to save bundle info")
2851
+ logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
2852
+ return false
2700
2853
  }
2701
2854
  }
2702
2855
 
2856
+
2703
2857
  private func setBundleStatus(id: String, status: BundleStatus) {
2704
2858
  logger.info("Setting status for bundle [\(id)] to \(status)")
2705
2859
  let info = self.getBundleInfo(id: id)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "8.51.0",
3
+ "version": "8.51.1",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",