@capgo/capacitor-updater 7.42.9 → 7.45.10

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.
@@ -44,7 +44,6 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
44
44
  CAPPluginMethod(name: "current", returnType: CAPPluginReturnPromise),
45
45
  CAPPluginMethod(name: "reload", returnType: CAPPluginReturnPromise),
46
46
  CAPPluginMethod(name: "notifyAppReady", returnType: CAPPluginReturnPromise),
47
- CAPPluginMethod(name: "setDelay", returnType: CAPPluginReturnPromise),
48
47
  CAPPluginMethod(name: "setMultiDelay", returnType: CAPPluginReturnPromise),
49
48
  CAPPluginMethod(name: "cancelDelay", returnType: CAPPluginReturnPromise),
50
49
  CAPPluginMethod(name: "getLatest", returnType: CAPPluginReturnPromise),
@@ -63,6 +62,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
63
62
  CAPPluginMethod(name: "getFailedUpdate", returnType: CAPPluginReturnPromise),
64
63
  CAPPluginMethod(name: "setShakeMenu", returnType: CAPPluginReturnPromise),
65
64
  CAPPluginMethod(name: "isShakeMenuEnabled", returnType: CAPPluginReturnPromise),
65
+ CAPPluginMethod(name: "setShakeChannelSelector", returnType: CAPPluginReturnPromise),
66
+ CAPPluginMethod(name: "isShakeChannelSelectorEnabled", returnType: CAPPluginReturnPromise),
66
67
  // App Store update methods
67
68
  CAPPluginMethod(name: "getAppUpdateInfo", returnType: CAPPluginReturnPromise),
68
69
  CAPPluginMethod(name: "openAppStore", returnType: CAPPluginReturnPromise),
@@ -71,7 +72,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
71
72
  CAPPluginMethod(name: "completeFlexibleUpdate", returnType: CAPPluginReturnPromise)
72
73
  ]
73
74
  public var implementation = CapgoUpdater()
74
- private let pluginVersion: String = "7.42.9"
75
+ private let pluginVersion: String = "7.45.10"
75
76
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
76
77
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
77
78
  static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
@@ -101,14 +102,23 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
101
102
  private var autoSplashscreenTimeoutWorkItem: DispatchWorkItem?
102
103
  private var splashscreenLoaderView: UIActivityIndicatorView?
103
104
  private var splashscreenLoaderContainer: UIView?
105
+ private let splashscreenPluginName = "SplashScreen"
106
+ private let splashscreenRetryDelayMilliseconds = 100
107
+ private let splashscreenMaxRetries = 20
104
108
  private var autoSplashscreenTimedOut = false
109
+ private var splashscreenInvocationToken = 0
105
110
  private var autoDeleteFailed = false
106
111
  private var autoDeletePrevious = false
107
- private var allowSetDefaultChannel = true
112
+ var allowSetDefaultChannel = true
108
113
  private var keepUrlPathAfterReload = false
109
114
  private var backgroundWork: DispatchWorkItem?
110
115
  private var taskRunning = false
111
116
  private var periodCheckDelay = 0
117
+ private let downloadLock = NSLock()
118
+ private let onLaunchDirectUpdateStateLock = NSLock()
119
+ private var downloadInProgress = false
120
+ private var downloadStartTime: Date?
121
+ private let downloadTimeout: TimeInterval = 3600 // 1 hour timeout
112
122
 
113
123
  // Lock to ensure cleanup completes before downloads start
114
124
  private let cleanupLock = NSLock()
@@ -119,6 +129,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
119
129
  private var allowManualBundleError = false
120
130
  private var keepUrlPathFlagLastValue: Bool?
121
131
  public var shakeMenuEnabled = false
132
+ public var shakeChannelSelectorEnabled = false
122
133
  let semaphoreReady = DispatchSemaphore(value: 0)
123
134
 
124
135
  private var delayUpdateUtils: DelayUpdateUtils!
@@ -211,6 +222,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
211
222
  implementation.timeout = Double(getConfig().getInt("responseTimeout", 20))
212
223
  resetWhenUpdate = getConfig().getBoolean("resetWhenUpdate", true)
213
224
  shakeMenuEnabled = getConfig().getBoolean("shakeMenu", false)
225
+ shakeChannelSelectorEnabled = getConfig().getBoolean("allowShakeChannelSelector", false)
214
226
  let periodCheckDelayValue = getConfig().getInt("periodCheckDelay", 0)
215
227
  if periodCheckDelayValue >= 0 && periodCheckDelayValue > 600 {
216
228
  periodCheckDelay = 600
@@ -220,6 +232,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
220
232
 
221
233
  implementation.setPublicKey(getConfig().getString("publicKey") ?? "")
222
234
  implementation.notifyDownloadRaw = notifyDownload
235
+ implementation.notifyListeners = { [weak self] eventName, data in
236
+ self?.notifyListeners(eventName, data: data)
237
+ }
223
238
  implementation.pluginVersion = self.pluginVersion
224
239
 
225
240
  // Set logger for shared classes
@@ -264,11 +279,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
264
279
  }
265
280
  self.implementation.autoReset()
266
281
 
267
- // Check if app was recently installed/updated BEFORE cleanupObsoleteVersions updates LatestVersionNative
282
+ // Check if app was recently installed/updated BEFORE cleanup updates the stored native build version.
268
283
  self.wasRecentlyInstalledOrUpdated = self.checkIfRecentlyInstalledOrUpdated()
269
284
 
270
285
  if resetWhenUpdate {
271
- self.cleanupObsoleteVersions()
286
+ let didResetCurrentBundle = self.resetCurrentBundleForNativeBuildChangeIfNeeded()
287
+ self.cleanupObsoleteVersions(didResetCurrentBundle: didResetCurrentBundle)
272
288
  }
273
289
 
274
290
  // Load the server
@@ -384,7 +400,29 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
384
400
  semaphoreReady.signal()
385
401
  }
386
402
 
387
- private func cleanupObsoleteVersions() {
403
+ func storedNativeBuildVersion() -> String {
404
+ UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
405
+ }
406
+
407
+ func hasNativeBuildVersionChanged() -> Bool {
408
+ let previous = self.storedNativeBuildVersion()
409
+ return previous != "0" && self.currentBuildVersion != previous
410
+ }
411
+
412
+ @discardableResult
413
+ func resetCurrentBundleForNativeBuildChangeIfNeeded() -> Bool {
414
+ let previous = self.storedNativeBuildVersion()
415
+ guard previous != "0" && self.currentBuildVersion != previous else {
416
+ return false
417
+ }
418
+
419
+ // Reset startup state synchronously so initialLoad() boots from the builtin bundle.
420
+ self.logger.info("Native build version changed from \(previous) to \(self.currentBuildVersion). Resetting startup bundle to builtin.")
421
+ self.implementation.reset(isInternal: true)
422
+ return true
423
+ }
424
+
425
+ private func cleanupObsoleteVersions(didResetCurrentBundle: Bool = false) {
388
426
  cleanupThread = Thread {
389
427
  self.cleanupLock.lock()
390
428
  defer {
@@ -419,9 +457,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
419
457
  // 1. Write "LatestVersionNative" - this fixes the part 1 of this bug
420
458
  // 2. Compare both keys. If any is not equal to "currentBuildVersion", then revert to builtin version. This fixes the part 2 of this bug
421
459
 
422
- let previous = UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
460
+ let previous = self.storedNativeBuildVersion()
423
461
  if previous != "0" && self.currentBuildVersion != previous {
424
- _ = self._reset(toLastSuccessful: false)
462
+ if !didResetCurrentBundle {
463
+ self.logger.info("Native build version changed from \(previous) to \(self.currentBuildVersion). Resetting current bundle to builtin.")
464
+ self.implementation.reset(isInternal: true)
465
+ }
425
466
  let res = self.implementation.list()
426
467
  for version in res {
427
468
  // Check if thread was cancelled
@@ -511,6 +552,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
511
552
  call.resolve()
512
553
  }
513
554
 
555
+ func getUpdateUrl() -> String {
556
+ return updateUrl
557
+ }
558
+
514
559
  @objc func setStatsUrl(_ call: CAPPluginCall) {
515
560
  if !getConfig().getBoolean("allowModifyUrl", false) {
516
561
  logger.error("setStatsUrl called without allowModifyUrl")
@@ -636,47 +681,77 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
636
681
  }
637
682
  }
638
683
 
639
- public func _reload() -> Bool {
640
- guard let bridge = self.bridge else { return false }
641
- self.semaphoreUp()
684
+ private func currentReloadDestination() -> URL {
642
685
  let id = self.implementation.getCurrentBundleId()
643
- let dest: URL
644
686
  if BundleInfo.ID_BUILTIN == id {
645
- dest = Bundle.main.resourceURL!.appendingPathComponent("public")
687
+ return Bundle.main.resourceURL!.appendingPathComponent("public")
646
688
  } else {
647
- dest = self.implementation.getBundleDirectory(id: id)
689
+ return self.implementation.getBundleDirectory(id: id)
648
690
  }
691
+ }
692
+
693
+ private func applyCurrentBundleToBridge(_ bridge: CAPBridgeProtocol) -> Bool {
694
+ let id = self.implementation.getCurrentBundleId()
695
+ let dest = self.currentReloadDestination()
649
696
  logger.info("Reloading \(id)")
650
697
 
651
- let performReload: () -> Bool = {
652
- guard let vc = bridge.viewController as? CAPBridgeViewController else {
653
- self.logger.error("Cannot get viewController")
654
- return false
655
- }
656
- guard let capBridge = vc.bridge else {
657
- self.logger.error("Cannot get capBridge")
658
- return false
659
- }
660
- if self.keepUrlPathAfterReload {
661
- if let currentURL = vc.webView?.url {
662
- capBridge.setServerBasePath(dest.path)
663
- var urlComponents = URLComponents(url: capBridge.config.serverURL, resolvingAgainstBaseURL: false)!
664
- urlComponents.path = currentURL.path
665
- urlComponents.query = currentURL.query
666
- urlComponents.fragment = currentURL.fragment
667
- if let finalUrl = urlComponents.url {
668
- _ = vc.webView?.load(URLRequest(url: finalUrl))
669
- } else {
670
- self.logger.error("Unable to build final URL when keeping path after reload; falling back to base path")
671
- vc.setServerBasePath(path: dest.path)
672
- }
698
+ guard let vc = bridge.viewController as? CAPBridgeViewController else {
699
+ self.logger.error("Cannot get viewController")
700
+ return false
701
+ }
702
+ guard let capBridge = vc.bridge else {
703
+ self.logger.error("Cannot get capBridge")
704
+ return false
705
+ }
706
+ if self.keepUrlPathAfterReload {
707
+ if let currentURL = vc.webView?.url {
708
+ capBridge.setServerBasePath(dest.path)
709
+ var urlComponents = URLComponents(url: capBridge.config.serverURL, resolvingAgainstBaseURL: false)!
710
+ urlComponents.path = currentURL.path
711
+ urlComponents.query = currentURL.query
712
+ urlComponents.fragment = currentURL.fragment
713
+ if let finalUrl = urlComponents.url {
714
+ _ = vc.webView?.load(URLRequest(url: finalUrl))
673
715
  } else {
674
- self.logger.error("vc.webView?.url is null? Falling back to base path reload.")
716
+ self.logger.error("Unable to build final URL when keeping path after reload; falling back to base path")
675
717
  vc.setServerBasePath(path: dest.path)
676
718
  }
677
719
  } else {
720
+ self.logger.error("vc.webView?.url is null? Falling back to base path reload.")
678
721
  vc.setServerBasePath(path: dest.path)
679
722
  }
723
+ } else {
724
+ vc.setServerBasePath(path: dest.path)
725
+ }
726
+ return true
727
+ }
728
+
729
+ func restoreLiveBundleStateAfterFailedReload() {
730
+ guard let bridge = self.bridge else {
731
+ return
732
+ }
733
+
734
+ let restoreLiveState = {
735
+ _ = self.applyCurrentBundleToBridge(bridge)
736
+ }
737
+
738
+ if Thread.isMainThread {
739
+ restoreLiveState()
740
+ } else {
741
+ DispatchQueue.main.sync {
742
+ restoreLiveState()
743
+ }
744
+ }
745
+ }
746
+
747
+ public func _reload() -> Bool {
748
+ guard let bridge = self.bridge else { return false }
749
+ self.semaphoreUp()
750
+
751
+ let performReload: () -> Bool = {
752
+ guard self.applyCurrentBundleToBridge(bridge) else {
753
+ return false
754
+ }
680
755
  self.checkAppReady()
681
756
  self.notifyListeners("appReloaded", data: [:])
682
757
  return true
@@ -694,6 +769,38 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
694
769
  }
695
770
 
696
771
  @objc func reload(_ call: CAPPluginCall) {
772
+ let current: BundleInfo = self.implementation.getCurrentBundle()
773
+ let next: BundleInfo? = self.implementation.getNextBundle()
774
+
775
+ if let next = next, !next.isErrorStatus(), next.getId() != current.getId() {
776
+ let previousState = self.implementation.captureResetState()
777
+ let previousBundleName = self.implementation.getCurrentBundle().getVersionName()
778
+ logger.info("Applying pending bundle before reload: \(next.toString())")
779
+ let didApplyPendingBundle: Bool
780
+ if next.isBuiltin() {
781
+ self.implementation.prepareResetStateForTransition()
782
+ didApplyPendingBundle = true
783
+ } else {
784
+ didApplyPendingBundle = self.implementation.stagePendingReload(bundle: next)
785
+ }
786
+ if didApplyPendingBundle && self._reload() {
787
+ if next.isBuiltin() {
788
+ self.implementation.finalizeResetTransition(previousBundleName: previousBundleName, isInternal: false)
789
+ } else {
790
+ self.implementation.finalizePendingReload(bundle: next, previousBundleName: previousBundleName)
791
+ }
792
+ self.notifyBundleSet(next)
793
+ _ = self.implementation.setNextBundle(next: Optional<String>.none)
794
+ call.resolve()
795
+ return
796
+ }
797
+ self.implementation.restoreResetState(previousState)
798
+ self.restoreLiveBundleStateAfterFailedReload()
799
+ logger.error("Reload failed after applying pending bundle: \(next.toString())")
800
+ call.reject("Reload failed after applying pending bundle")
801
+ return
802
+ }
803
+
697
804
  if self._reload() {
698
805
  call.resolve()
699
806
  } else {
@@ -728,8 +835,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
728
835
  if !res {
729
836
  logger.info("Bundle successfully set to: \(id) ")
730
837
  call.reject("Update failed, id \(id) doesn't exist")
838
+ } else if !self._reload() {
839
+ call.reject("Reload failed after setting bundle \(id)")
731
840
  } else {
732
- self.reload(call)
841
+ self.notifyBundleSet(self.implementation.getBundleInfo(id: id))
842
+ call.resolve()
733
843
  }
734
844
  }
735
845
 
@@ -792,12 +902,32 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
792
902
 
793
903
  @objc func getLatest(_ call: CAPPluginCall) {
794
904
  let channel = call.getString("channel")
795
- DispatchQueue.global(qos: .background).async {
905
+ runGetLatestWork {
796
906
  let res = self.implementation.getLatest(url: URL(string: self.updateUrl)!, channel: channel)
797
- if res.error != nil {
798
- call.reject( res.error!)
799
- } else if res.message != nil {
800
- call.reject( res.message!)
907
+ if let error = res.error, !error.isEmpty {
908
+ let responseKind = self.updateResponseKind(kind: res.kind)
909
+ res.kind = responseKind
910
+ if responseKind == "failed" {
911
+ call.reject(error)
912
+ } else {
913
+ if res.version.isEmpty {
914
+ res.version = self.implementation.getCurrentBundle().getVersionName()
915
+ }
916
+ call.resolve(res.toDict())
917
+ }
918
+ } else if let kind = res.kind, !kind.isEmpty {
919
+ let responseKind = self.updateResponseKind(kind: kind)
920
+ res.kind = responseKind
921
+ if responseKind != "failed" {
922
+ if res.version.isEmpty {
923
+ res.version = self.implementation.getCurrentBundle().getVersionName()
924
+ }
925
+ call.resolve(res.toDict())
926
+ } else {
927
+ call.reject(res.message ?? "server did not provide a message")
928
+ }
929
+ } else if let message = res.message, !message.isEmpty {
930
+ call.reject(message)
801
931
  } else {
802
932
  call.resolve(res.toDict())
803
933
  }
@@ -817,7 +947,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
817
947
  } else {
818
948
  if self._isAutoUpdateEnabled() && triggerAutoUpdate {
819
949
  self.logger.info("Calling autoupdater after channel change!")
820
- self.backgroundDownload()
950
+ // Check if download is already in progress (with timeout protection)
951
+ if !self.isDownloadStuckOrTimedOut() {
952
+ self.backgroundDownload()
953
+ } else {
954
+ self.logger.info("Download already in progress, skipping duplicate download request")
955
+ }
821
956
  }
822
957
  call.resolve(res.toDict())
823
958
  }
@@ -851,7 +986,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
851
986
  } else {
852
987
  if self._isAutoUpdateEnabled() && triggerAutoUpdate {
853
988
  self.logger.info("Calling autoupdater after channel change!")
854
- self.backgroundDownload()
989
+ // Check if download is already in progress (with timeout protection)
990
+ if !self.isDownloadStuckOrTimedOut() {
991
+ self.backgroundDownload()
992
+ } else {
993
+ self.logger.info("Download already in progress, skipping duplicate download request")
994
+ }
855
995
  }
856
996
  call.resolve(res.toDict())
857
997
  }
@@ -904,32 +1044,90 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
904
1044
  call.resolve()
905
1045
  }
906
1046
 
907
- @objc func _reset(toLastSuccessful: Bool) -> Bool {
908
- guard let bridge = self.bridge else { return false }
1047
+ @objc func _reset(toLastSuccessful: Bool, usePendingBundle: Bool) -> Bool {
1048
+ self.performReset(toLastSuccessful: toLastSuccessful, usePendingBundle: usePendingBundle, isInternal: false)
1049
+ }
909
1050
 
910
- if (bridge.viewController as? CAPBridgeViewController) != nil {
911
- let fallback: BundleInfo = self.implementation.getFallbackBundle()
1051
+ func performReset(toLastSuccessful: Bool, usePendingBundle: Bool, isInternal: Bool) -> Bool {
1052
+ guard self.canPerformResetTransition() else { return false }
912
1053
 
913
- // If developer wants to reset to the last successful bundle, and that bundle is not
914
- // the built-in bundle, set it as the bundle to use and reload.
915
- if toLastSuccessful && !fallback.isBuiltin() {
916
- logger.info("Resetting to: \(fallback.toString())")
917
- return self.implementation.set(bundle: fallback) && self._reload()
918
- }
1054
+ let fallback: BundleInfo = self.implementation.getFallbackBundle()
1055
+ let pending: BundleInfo? = self.implementation.getNextBundle()
1056
+ let previousState = self.implementation.captureResetState()
1057
+ let previousBundleName = self.implementation.getCurrentBundle().getVersionName()
919
1058
 
920
- logger.info("Resetting to builtin version")
1059
+ if usePendingBundle {
1060
+ guard let pending = pending, !pending.isErrorStatus() else {
1061
+ logger.error("No pending bundle available to reset to")
1062
+ return false
1063
+ }
1064
+ guard self.implementation.canSet(bundle: pending) else {
1065
+ logger.error("Pending bundle is not installable")
1066
+ return false
1067
+ }
1068
+ self.implementation.prepareResetStateForTransition()
1069
+ logger.info("Resetting to pending bundle: \(pending.toString())")
1070
+ let didApplyPendingBundle: Bool
1071
+ if pending.isBuiltin() {
1072
+ didApplyPendingBundle = true
1073
+ } else {
1074
+ didApplyPendingBundle = self.implementation.set(bundle: pending)
1075
+ }
1076
+ if didApplyPendingBundle && self._reload() {
1077
+ self.implementation.finalizeResetTransition(previousBundleName: previousBundleName, isInternal: isInternal)
1078
+ self.notifyBundleSet(pending)
1079
+ _ = self.implementation.setNextBundle(next: Optional<String>.none)
1080
+ return true
1081
+ }
1082
+ self.implementation.restoreResetState(previousState)
1083
+ self.restoreLiveBundleStateAfterFailedReload()
1084
+ return false
1085
+ }
921
1086
 
922
- // Otherwise, reset back to the built-in bundle and reload.
923
- self.implementation.reset()
924
- return self._reload()
1087
+ // If developer wants to reset to the last successful bundle, and that bundle is not
1088
+ // the built-in bundle, set it as the bundle to use and reload.
1089
+ if toLastSuccessful && !fallback.isBuiltin() {
1090
+ if self.implementation.canSet(bundle: fallback) {
1091
+ self.implementation.prepareResetStateForTransition()
1092
+ logger.info("Resetting to: \(fallback.toString())")
1093
+ if self.implementation.set(bundle: fallback) && self._reload() {
1094
+ self.implementation.finalizeResetTransition(previousBundleName: previousBundleName, isInternal: isInternal)
1095
+ self.notifyBundleSet(fallback)
1096
+ return true
1097
+ }
1098
+ if !isInternal {
1099
+ self.implementation.restoreResetState(previousState)
1100
+ self.restoreLiveBundleStateAfterFailedReload()
1101
+ return false
1102
+ }
1103
+ logger.warn("Fallback reload failed during internal reset, resetting to builtin instead")
1104
+ } else {
1105
+ logger.warn("Fallback bundle is not installable, resetting to builtin instead")
1106
+ }
925
1107
  }
926
1108
 
1109
+ self.implementation.prepareResetStateForTransition()
1110
+ logger.info("Resetting to builtin version")
1111
+ if self._reload() {
1112
+ self.implementation.finalizeResetTransition(previousBundleName: previousBundleName, isInternal: isInternal)
1113
+ return true
1114
+ }
1115
+ if !isInternal {
1116
+ self.implementation.restoreResetState(previousState)
1117
+ self.restoreLiveBundleStateAfterFailedReload()
1118
+ }
927
1119
  return false
928
1120
  }
929
1121
 
1122
+ func canPerformResetTransition() -> Bool {
1123
+ guard let bridge = self.bridge else { return false }
1124
+ return (bridge.viewController as? CAPBridgeViewController) != nil
1125
+ }
1126
+
930
1127
  @objc func reset(_ call: CAPPluginCall) {
931
1128
  let toLastSuccessful = call.getBool("toLastSuccessful") ?? false
932
- if self._reset(toLastSuccessful: toLastSuccessful) {
1129
+ let usePendingBundle = call.getBool("usePendingBundle") ?? false
1130
+ if self._reset(toLastSuccessful: toLastSuccessful, usePendingBundle: usePendingBundle) {
933
1131
  call.resolve()
934
1132
  } else {
935
1133
  logger.error("Reset failed")
@@ -1048,7 +1246,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1048
1246
  self.persistLastFailedBundle(current)
1049
1247
  self.implementation.sendStats(action: "update_fail", versionName: current.getVersionName())
1050
1248
  self.implementation.setError(bundle: current)
1051
- _ = self._reset(toLastSuccessful: true)
1249
+ _ = self.performReset(toLastSuccessful: true, usePendingBundle: false, isInternal: true)
1052
1250
  if self.autoDeleteFailed && !current.isBuiltin() {
1053
1251
  logger.info("Deleting failing bundle: \(current.toString())")
1054
1252
  let res = self.implementation.delete(id: current.getId(), removeInfo: false)
@@ -1073,6 +1271,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1073
1271
  self.backgroundTaskID = UIBackgroundTaskIdentifier.invalid
1074
1272
  }
1075
1273
 
1274
+ private func notifyBundleSet(_ bundle: BundleInfo) {
1275
+ self.notifyListeners("set", data: ["bundle": bundle.toJSON()], retainUntilConsumed: true)
1276
+ }
1277
+
1076
1278
  func sendReadyToJs(current: BundleInfo, msg: String) {
1077
1279
  logger.info("sendReadyToJs")
1078
1280
  DispatchQueue.global().async {
@@ -1100,32 +1302,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1100
1302
  private func performHideSplashscreen() {
1101
1303
  self.cancelSplashscreenTimeout()
1102
1304
  self.removeSplashscreenLoader()
1103
-
1104
- guard let bridge = self.bridge else {
1105
- self.logger.warn("Bridge not available for hiding splashscreen with autoSplashscreen")
1106
- return
1107
- }
1108
-
1109
- // Create a plugin call for the hide method
1110
- let call = CAPPluginCall(callbackId: "autoHideSplashscreen", options: [:], success: { (_, _) in
1111
- self.logger.info("Splashscreen hidden automatically")
1112
- }, error: { (_) in
1113
- self.logger.error("Failed to auto-hide splashscreen")
1114
- })
1115
-
1116
- // Try to call the SplashScreen hide method directly through the bridge
1117
- if let splashScreenPlugin = bridge.plugin(withName: "SplashScreen") {
1118
- // Use runtime method invocation to call hide method
1119
- let selector = NSSelectorFromString("hide:")
1120
- if splashScreenPlugin.responds(to: selector) {
1121
- _ = splashScreenPlugin.perform(selector, with: call)
1122
- self.logger.info("Called SplashScreen hide method")
1123
- } else {
1124
- self.logger.warn("autoSplashscreen: SplashScreen plugin does not respond to hide: method. Make sure @capacitor/splash-screen plugin is properly installed.")
1125
- }
1126
- } else {
1127
- self.logger.warn("autoSplashscreen: SplashScreen plugin not found. Install @capacitor/splash-screen plugin.")
1128
- }
1305
+ self.splashscreenInvocationToken += 1
1306
+ self.invokeSplashscreenMethod(
1307
+ methodName: "hide",
1308
+ callbackId: "autoHideSplashscreen",
1309
+ options: self.splashscreenOptions(methodName: "hide"),
1310
+ retriesRemaining: self.splashscreenMaxRetries,
1311
+ requestToken: self.splashscreenInvocationToken
1312
+ )
1129
1313
  }
1130
1314
 
1131
1315
  private func showSplashscreen() {
@@ -1141,35 +1325,132 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1141
1325
  private func performShowSplashscreen() {
1142
1326
  self.cancelSplashscreenTimeout()
1143
1327
  self.autoSplashscreenTimedOut = false
1328
+ self.splashscreenInvocationToken += 1
1329
+ self.invokeSplashscreenMethod(
1330
+ methodName: "show",
1331
+ callbackId: "autoShowSplashscreen",
1332
+ options: self.splashscreenOptions(methodName: "show"),
1333
+ retriesRemaining: self.splashscreenMaxRetries,
1334
+ requestToken: self.splashscreenInvocationToken
1335
+ )
1336
+
1337
+ self.addSplashscreenLoaderIfNeeded()
1338
+ self.scheduleSplashscreenTimeout()
1339
+ }
1340
+
1341
+ private func splashscreenOptions(methodName: String) -> [String: Any] {
1342
+ methodName == "show" ? ["autoHide": false] : [:]
1343
+ }
1344
+
1345
+ private func splashscreenCompletedMessage(methodName: String) -> String {
1346
+ methodName == "show" ? "Splashscreen shown automatically" : "Splashscreen hidden automatically"
1347
+ }
1348
+
1349
+ func splashscreenOptionsForTesting(methodName: String) -> [String: Any] {
1350
+ self.splashscreenOptions(methodName: methodName)
1351
+ }
1352
+
1353
+ func isCurrentSplashscreenInvocationTokenForTesting(_ requestToken: Int) -> Bool {
1354
+ requestToken == self.splashscreenInvocationToken
1355
+ }
1356
+
1357
+ func advanceSplashscreenInvocationTokenForTesting() {
1358
+ self.splashscreenInvocationToken += 1
1359
+ }
1360
+
1361
+ private func makeSplashscreenCall(callbackId: String, options: [String: Any], methodName: String) -> CAPPluginCall {
1362
+ CAPPluginCall(callbackId: callbackId, options: options, success: { [weak self] (_, _) in
1363
+ guard let self = self else { return }
1364
+ self.logger.info(self.splashscreenCompletedMessage(methodName: methodName))
1365
+ }, error: { [weak self] (_) in
1366
+ guard let self = self else { return }
1367
+ self.logger.error("Failed to auto-\(methodName) splashscreen")
1368
+ })
1369
+ }
1370
+
1371
+ private func invokeSplashscreenMethod(
1372
+ methodName: String,
1373
+ callbackId: String,
1374
+ options: [String: Any],
1375
+ retriesRemaining: Int,
1376
+ requestToken: Int
1377
+ ) {
1378
+ guard requestToken == self.splashscreenInvocationToken else {
1379
+ return
1380
+ }
1144
1381
 
1145
1382
  guard let bridge = self.bridge else {
1146
- self.logger.warn("Bridge not available for showing splashscreen with autoSplashscreen")
1383
+ self.retrySplashscreenMethod(
1384
+ methodName: methodName,
1385
+ callbackId: callbackId,
1386
+ options: options,
1387
+ retriesRemaining: retriesRemaining,
1388
+ requestToken: requestToken,
1389
+ message: "Bridge not available for \(methodName == "show" ? "showing" : "hiding") splashscreen with autoSplashscreen"
1390
+ )
1147
1391
  return
1148
1392
  }
1149
1393
 
1150
- // Create a plugin call for the show method
1151
- let call = CAPPluginCall(callbackId: "autoShowSplashscreen", options: [:], success: { (_, _) in
1152
- self.logger.info("Splashscreen shown automatically")
1153
- }, error: { (_) in
1154
- self.logger.error("Failed to auto-show splashscreen")
1155
- })
1394
+ guard let splashScreenPlugin = bridge.plugin(withName: self.splashscreenPluginName) else {
1395
+ self.retrySplashscreenMethod(
1396
+ methodName: methodName,
1397
+ callbackId: callbackId,
1398
+ options: options,
1399
+ retriesRemaining: retriesRemaining,
1400
+ requestToken: requestToken,
1401
+ message: "autoSplashscreen: SplashScreen plugin not found. Install @capacitor/splash-screen plugin."
1402
+ )
1403
+ return
1404
+ }
1156
1405
 
1157
- // Try to call the SplashScreen show method directly through the bridge
1158
- if let splashScreenPlugin = bridge.plugin(withName: "SplashScreen") {
1159
- // Use runtime method invocation to call show method
1160
- let selector = NSSelectorFromString("show:")
1161
- if splashScreenPlugin.responds(to: selector) {
1162
- _ = splashScreenPlugin.perform(selector, with: call)
1163
- self.logger.info("Called SplashScreen show method")
1406
+ let selector = NSSelectorFromString("\(methodName):")
1407
+ guard splashScreenPlugin.responds(to: selector) else {
1408
+ self.retrySplashscreenMethod(
1409
+ methodName: methodName,
1410
+ callbackId: callbackId,
1411
+ options: options,
1412
+ retriesRemaining: retriesRemaining,
1413
+ requestToken: requestToken,
1414
+ message: "autoSplashscreen: SplashScreen plugin does not respond to \(methodName): method. Make sure @capacitor/splash-screen plugin is properly installed."
1415
+ )
1416
+ return
1417
+ }
1418
+
1419
+ let call = self.makeSplashscreenCall(callbackId: callbackId, options: options, methodName: methodName)
1420
+ _ = splashScreenPlugin.perform(selector, with: call)
1421
+ self.logger.info("Called SplashScreen \(methodName) method")
1422
+ }
1423
+
1424
+ private func retrySplashscreenMethod(
1425
+ methodName: String,
1426
+ callbackId: String,
1427
+ options: [String: Any],
1428
+ retriesRemaining: Int,
1429
+ requestToken: Int,
1430
+ message: String
1431
+ ) {
1432
+ guard retriesRemaining > 0 else {
1433
+ if methodName == "show" {
1434
+ self.logger.warn(message)
1164
1435
  } else {
1165
- self.logger.warn("autoSplashscreen: SplashScreen plugin does not respond to show: method. Make sure @capacitor/splash-screen plugin is properly installed.")
1436
+ self.logger.error(message)
1166
1437
  }
1167
- } else {
1168
- self.logger.warn("autoSplashscreen: SplashScreen plugin not found. Install @capacitor/splash-screen plugin.")
1438
+ return
1169
1439
  }
1170
1440
 
1171
- self.addSplashscreenLoaderIfNeeded()
1172
- self.scheduleSplashscreenTimeout()
1441
+ self.logger.info("\(message). Retrying.")
1442
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.splashscreenRetryDelayMilliseconds)) { [weak self] in
1443
+ guard let self = self, requestToken == self.splashscreenInvocationToken else {
1444
+ return
1445
+ }
1446
+ self.invokeSplashscreenMethod(
1447
+ methodName: methodName,
1448
+ callbackId: callbackId,
1449
+ options: options,
1450
+ retriesRemaining: retriesRemaining - 1,
1451
+ requestToken: requestToken
1452
+ )
1453
+ }
1173
1454
  }
1174
1455
 
1175
1456
  private func addSplashscreenLoaderIfNeeded() {
@@ -1323,7 +1604,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1323
1604
  }
1324
1605
  return false
1325
1606
  case "onLaunch":
1326
- if !self.onLaunchDirectUpdateUsed {
1607
+ if !self.getOnLaunchDirectUpdateUsed() {
1327
1608
  return true
1328
1609
  }
1329
1610
  return false
@@ -1333,6 +1614,51 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1333
1614
  }
1334
1615
  }
1335
1616
 
1617
+ static func shouldConsumeOnLaunchDirectUpdate(directUpdateMode: String, plannedDirectUpdate: Bool) -> Bool {
1618
+ plannedDirectUpdate && directUpdateMode == "onLaunch"
1619
+ }
1620
+
1621
+ private func getOnLaunchDirectUpdateUsed() -> Bool {
1622
+ self.onLaunchDirectUpdateStateLock.lock()
1623
+ defer { self.onLaunchDirectUpdateStateLock.unlock() }
1624
+ return self.onLaunchDirectUpdateUsed
1625
+ }
1626
+
1627
+ private func setOnLaunchDirectUpdateUsed(_ used: Bool) {
1628
+ self.onLaunchDirectUpdateStateLock.lock()
1629
+ self.onLaunchDirectUpdateUsed = used
1630
+ self.onLaunchDirectUpdateStateLock.unlock()
1631
+ }
1632
+
1633
+ private func consumeOnLaunchDirectUpdateAttempt(plannedDirectUpdate: Bool) {
1634
+ guard Self.shouldConsumeOnLaunchDirectUpdate(directUpdateMode: self.directUpdateMode, plannedDirectUpdate: plannedDirectUpdate) else {
1635
+ return
1636
+ }
1637
+
1638
+ self.setOnLaunchDirectUpdateUsed(true)
1639
+ }
1640
+
1641
+ func configureDirectUpdateModeForTesting(_ directUpdateMode: String, onLaunchDirectUpdateUsed: Bool = false) {
1642
+ self.directUpdateMode = directUpdateMode
1643
+ self.setOnLaunchDirectUpdateUsed(onLaunchDirectUpdateUsed)
1644
+ }
1645
+
1646
+ func setUpdateUrlForTesting(_ updateUrl: String) {
1647
+ self.updateUrl = updateUrl
1648
+ }
1649
+
1650
+ func setCurrentBuildVersionForTesting(_ currentBuildVersion: String) {
1651
+ self.currentBuildVersion = currentBuildVersion
1652
+ }
1653
+
1654
+ func shouldUseDirectUpdateForTesting() -> Bool {
1655
+ self.shouldUseDirectUpdate()
1656
+ }
1657
+
1658
+ var hasConsumedOnLaunchDirectUpdateForTesting: Bool {
1659
+ self.getOnLaunchDirectUpdateUsed()
1660
+ }
1661
+
1336
1662
  private func notifyBreakingEvents(version: String) {
1337
1663
  guard !version.isEmpty else {
1338
1664
  return
@@ -1342,15 +1668,71 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1342
1668
  self.notifyListeners("majorAvailable", data: payload)
1343
1669
  }
1344
1670
 
1671
+ private func updateResponseKind(kind: String?) -> String {
1672
+ if let kind, ["up_to_date", "blocked", "failed"].contains(kind) {
1673
+ return kind
1674
+ }
1675
+ return "failed"
1676
+ }
1677
+
1678
+ private func endBackgroundDownloadAfterLatestError(
1679
+ backendError: String,
1680
+ res: AppVersion,
1681
+ current: BundleInfo,
1682
+ plannedDirectUpdate: Bool
1683
+ ) {
1684
+ let statusCode = res.statusCode
1685
+ let responseKind = self.updateResponseKind(kind: res.kind)
1686
+ let responseMessage = res.message?.isEmpty == false ? res.message : nil
1687
+ let message = responseMessage ?? (backendError.isEmpty ? "server did not provide a message" : backendError)
1688
+ let latestVersionName = res.version.isEmpty ? current.getVersionName() : res.version
1689
+ self.notifyListeners("updateCheckResult", data: [
1690
+ "kind": responseKind,
1691
+ "error": backendError,
1692
+ "message": message,
1693
+ "statusCode": statusCode,
1694
+ "version": latestVersionName,
1695
+ "bundle": current.toJSON()
1696
+ ])
1697
+
1698
+ if responseKind == "up_to_date" {
1699
+ self.logger.info("No new version available")
1700
+ } else if responseKind == "blocked" {
1701
+ self.logger.info("Update check blocked with error: \(backendError)")
1702
+ } else {
1703
+ self.logger.error("getLatest failed with error: \(backendError)")
1704
+ }
1705
+
1706
+ let isFailure = responseKind == "failed"
1707
+ self.endBackGroundTaskWithNotif(
1708
+ msg: message,
1709
+ latestVersionName: latestVersionName,
1710
+ current: current,
1711
+ error: isFailure,
1712
+ plannedDirectUpdate: plannedDirectUpdate,
1713
+ sendStats: isFailure
1714
+ )
1715
+ }
1716
+
1345
1717
  func endBackGroundTaskWithNotif(
1346
1718
  msg: String,
1347
1719
  latestVersionName: String,
1348
1720
  current: BundleInfo,
1349
1721
  error: Bool = true,
1722
+ plannedDirectUpdate: Bool = false,
1350
1723
  failureAction: String = "download_fail",
1351
1724
  failureEvent: String = "downloadFailed",
1352
1725
  sendStats: Bool = true
1353
1726
  ) {
1727
+ // Clear download in progress flag - this is called at the end of every download attempt
1728
+ // whether it succeeds, fails, or is skipped (e.g., already up to date)
1729
+ downloadLock.lock()
1730
+ defer { downloadLock.unlock() }
1731
+ downloadInProgress = false
1732
+ downloadStartTime = nil
1733
+
1734
+ self.consumeOnLaunchDirectUpdateAttempt(plannedDirectUpdate: plannedDirectUpdate)
1735
+
1354
1736
  if error {
1355
1737
  if sendStats {
1356
1738
  self.implementation.sendStats(action: failureAction, versionName: current.getVersionName())
@@ -1363,14 +1745,56 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1363
1745
  self.endBackGroundTask()
1364
1746
  }
1365
1747
 
1748
+ private func isDownloadStuckOrTimedOut() -> Bool {
1749
+ downloadLock.lock()
1750
+ defer { downloadLock.unlock() }
1751
+
1752
+ guard downloadInProgress else {
1753
+ return false
1754
+ }
1755
+
1756
+ // Check if download has timed out
1757
+ if let startTime = downloadStartTime {
1758
+ let elapsed = Date().timeIntervalSince(startTime)
1759
+ if elapsed > downloadTimeout {
1760
+ self.logger.warn("Download has been in progress for \(elapsed) seconds, exceeding timeout of \(downloadTimeout) seconds. Clearing stuck state.")
1761
+ downloadInProgress = false
1762
+ downloadStartTime = nil
1763
+ return false // Now it's not stuck anymore, caller can proceed
1764
+ }
1765
+ }
1766
+
1767
+ return true
1768
+ }
1769
+
1770
+ func runBackgroundDownloadWork(_ work: @escaping () -> Void) {
1771
+ DispatchQueue.global(qos: .background).async(execute: work)
1772
+ }
1773
+
1774
+ func runGetLatestWork(_ work: @escaping () -> Void) {
1775
+ DispatchQueue.global(qos: .background).async(execute: work)
1776
+ }
1777
+
1366
1778
  func backgroundDownload() {
1779
+ // Set download in progress flag (thread-safe)
1780
+ downloadLock.lock()
1781
+ downloadInProgress = true
1782
+ downloadStartTime = Date()
1783
+ downloadLock.unlock()
1784
+
1367
1785
  let plannedDirectUpdate = self.shouldUseDirectUpdate()
1368
1786
  let messageUpdate = plannedDirectUpdate ? "Update will occur now." : "Update will occur next time app moves to background."
1369
1787
  guard let url = URL(string: self.updateUrl) else {
1370
1788
  logger.error("Error no url or wrong format")
1789
+ // Clear the flag if we return early
1790
+ downloadLock.lock()
1791
+ defer { downloadLock.unlock() }
1792
+ downloadInProgress = false
1793
+ downloadStartTime = nil
1371
1794
  return
1372
1795
  }
1373
- DispatchQueue.global(qos: .background).async {
1796
+
1797
+ self.runBackgroundDownloadWork {
1374
1798
  // Wait for cleanup to complete before starting download
1375
1799
  self.waitForCleanupIfNeeded()
1376
1800
  self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "Finish Download Tasks") {
@@ -1382,16 +1806,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1382
1806
  let current = self.implementation.getCurrentBundle()
1383
1807
 
1384
1808
  // Handle network errors and other failures first
1385
- if let backendError = res.error, !backendError.isEmpty {
1386
- self.logger.error("getLatest failed with error: \(backendError)")
1387
- let statusCode = res.statusCode
1388
- let responseIsOk = statusCode >= 200 && statusCode < 300
1389
- self.endBackGroundTaskWithNotif(
1390
- msg: res.message ?? backendError,
1391
- latestVersionName: res.version,
1809
+ let backendError = res.error ?? ""
1810
+ let backendKind = res.kind ?? ""
1811
+ if !backendError.isEmpty || !backendKind.isEmpty {
1812
+ self.endBackgroundDownloadAfterLatestError(
1813
+ backendError: backendError,
1814
+ res: res,
1392
1815
  current: current,
1393
- error: true,
1394
- sendStats: !responseIsOk
1816
+ plannedDirectUpdate: plannedDirectUpdate
1395
1817
  )
1396
1818
  return
1397
1819
  }
@@ -1400,26 +1822,39 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1400
1822
  let directUpdateAllowed = plannedDirectUpdate && !self.autoSplashscreenTimedOut
1401
1823
  if directUpdateAllowed {
1402
1824
  self.logger.info("Direct update to builtin version")
1403
- if self.directUpdateMode == "onLaunch" {
1404
- self.onLaunchDirectUpdateUsed = true
1405
- self.directUpdate = false
1406
- }
1407
- _ = self._reset(toLastSuccessful: false)
1408
- self.endBackGroundTaskWithNotif(msg: "Updated to builtin version", latestVersionName: res.version, current: self.implementation.getCurrentBundle(), error: false)
1825
+ _ = self._reset(toLastSuccessful: false, usePendingBundle: false)
1826
+ self.endBackGroundTaskWithNotif(
1827
+ msg: "Updated to builtin version",
1828
+ latestVersionName: res.version,
1829
+ current: self.implementation.getCurrentBundle(),
1830
+ error: false,
1831
+ plannedDirectUpdate: plannedDirectUpdate
1832
+ )
1409
1833
  } else {
1410
1834
  if plannedDirectUpdate && !directUpdateAllowed {
1411
1835
  self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will apply later.")
1412
1836
  }
1413
1837
  self.logger.info("Setting next bundle to builtin")
1414
1838
  _ = self.implementation.setNextBundle(next: BundleInfo.ID_BUILTIN)
1415
- self.endBackGroundTaskWithNotif(msg: "Next update will be to builtin version", latestVersionName: res.version, current: current, error: false)
1839
+ self.endBackGroundTaskWithNotif(
1840
+ msg: "Next update will be to builtin version",
1841
+ latestVersionName: res.version,
1842
+ current: current,
1843
+ error: false,
1844
+ plannedDirectUpdate: plannedDirectUpdate
1845
+ )
1416
1846
  }
1417
1847
  return
1418
1848
  }
1419
1849
  let sessionKey = res.sessionKey ?? ""
1420
1850
  guard let downloadUrl = URL(string: res.url) else {
1421
1851
  self.logger.error("Error no url or wrong format")
1422
- self.endBackGroundTaskWithNotif(msg: "Error no url or wrong format", latestVersionName: res.version, current: current)
1852
+ self.endBackGroundTaskWithNotif(
1853
+ msg: "Error no url or wrong format",
1854
+ latestVersionName: res.version,
1855
+ current: current,
1856
+ plannedDirectUpdate: plannedDirectUpdate
1857
+ )
1423
1858
  return
1424
1859
  }
1425
1860
  let latestVersionName = res.version
@@ -1437,6 +1872,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1437
1872
  self.logger.error("Failed to delete failed bundle: \(nextImpl!.toString())")
1438
1873
  }
1439
1874
  }
1875
+ self.consumeOnLaunchDirectUpdateAttempt(plannedDirectUpdate: plannedDirectUpdate)
1440
1876
  if res.manifest != nil {
1441
1877
  nextImpl = try self.implementation.downloadManifest(manifest: res.manifest!, version: latestVersionName, sessionKey: sessionKey, link: res.link, comment: res.comment)
1442
1878
  } else {
@@ -1445,12 +1881,22 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1445
1881
  }
1446
1882
  guard let next = nextImpl else {
1447
1883
  self.logger.error("Error downloading file")
1448
- self.endBackGroundTaskWithNotif(msg: "Error downloading file", latestVersionName: latestVersionName, current: current)
1884
+ self.endBackGroundTaskWithNotif(
1885
+ msg: "Error downloading file",
1886
+ latestVersionName: latestVersionName,
1887
+ current: current,
1888
+ plannedDirectUpdate: plannedDirectUpdate
1889
+ )
1449
1890
  return
1450
1891
  }
1451
1892
  if next.isErrorStatus() {
1452
1893
  self.logger.error("Latest bundle already exists and is in error state. Aborting update.")
1453
- self.endBackGroundTaskWithNotif(msg: "Latest version is in error state. Aborting update.", latestVersionName: latestVersionName, current: current)
1894
+ self.endBackGroundTaskWithNotif(
1895
+ msg: "Latest version is in error state. Aborting update.",
1896
+ latestVersionName: latestVersionName,
1897
+ current: current,
1898
+ plannedDirectUpdate: plannedDirectUpdate
1899
+ )
1454
1900
  return
1455
1901
  }
1456
1902
  res.checksum = try CryptoCipher.decryptChecksum(checksum: res.checksum, publicKey: self.implementation.publicKey)
@@ -1464,7 +1910,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1464
1910
  if !resDel {
1465
1911
  self.logger.error("Delete failed, id \(id) doesn't exist")
1466
1912
  }
1467
- self.endBackGroundTaskWithNotif(msg: "Error checksum", latestVersionName: latestVersionName, current: current)
1913
+ self.endBackGroundTaskWithNotif(
1914
+ msg: "Error checksum",
1915
+ latestVersionName: latestVersionName,
1916
+ current: current,
1917
+ plannedDirectUpdate: plannedDirectUpdate
1918
+ )
1468
1919
  return
1469
1920
  }
1470
1921
  let directUpdateAllowed = plannedDirectUpdate && !self.autoSplashscreenTimedOut
@@ -1477,34 +1928,67 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1477
1928
  }
1478
1929
  if !delayConditionList.isEmpty {
1479
1930
  self.logger.info("Update delayed until delay conditions met")
1480
- self.endBackGroundTaskWithNotif(msg: "Update delayed until delay conditions met", latestVersionName: latestVersionName, current: next, error: false)
1931
+ self.endBackGroundTaskWithNotif(
1932
+ msg: "Update delayed until delay conditions met",
1933
+ latestVersionName: latestVersionName,
1934
+ current: next,
1935
+ error: false,
1936
+ plannedDirectUpdate: plannedDirectUpdate
1937
+ )
1481
1938
  return
1482
1939
  }
1483
- if self.directUpdateMode == "onLaunch" {
1484
- self.onLaunchDirectUpdateUsed = true
1485
- self.directUpdate = false
1940
+ if self.implementation.set(bundle: next) && self._reload() {
1941
+ self.notifyBundleSet(next)
1942
+ self.endBackGroundTaskWithNotif(
1943
+ msg: "update installed",
1944
+ latestVersionName: latestVersionName,
1945
+ current: next,
1946
+ error: false,
1947
+ plannedDirectUpdate: plannedDirectUpdate
1948
+ )
1949
+ } else {
1950
+ self.endBackGroundTaskWithNotif(
1951
+ msg: "Update install failed",
1952
+ latestVersionName: latestVersionName,
1953
+ current: next,
1954
+ plannedDirectUpdate: plannedDirectUpdate
1955
+ )
1486
1956
  }
1487
- _ = self.implementation.set(bundle: next)
1488
- _ = self._reload()
1489
- self.endBackGroundTaskWithNotif(msg: "update installed", latestVersionName: latestVersionName, current: next, error: false)
1490
1957
  } else {
1491
1958
  if plannedDirectUpdate && !directUpdateAllowed {
1492
1959
  self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will install on next app background.")
1493
1960
  }
1494
1961
  self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
1495
1962
  _ = self.implementation.setNextBundle(next: next.getId())
1496
- self.endBackGroundTaskWithNotif(msg: "update downloaded, will install next background", latestVersionName: latestVersionName, current: current, error: false)
1963
+ self.endBackGroundTaskWithNotif(
1964
+ msg: "update downloaded, will install next background",
1965
+ latestVersionName: latestVersionName,
1966
+ current: current,
1967
+ error: false,
1968
+ plannedDirectUpdate: plannedDirectUpdate
1969
+ )
1497
1970
  }
1498
1971
  return
1499
1972
  } catch {
1500
1973
  self.logger.error("Error downloading file \(error.localizedDescription)")
1501
1974
  let current: BundleInfo = self.implementation.getCurrentBundle()
1502
- self.endBackGroundTaskWithNotif(msg: "Error downloading file", latestVersionName: latestVersionName, current: current)
1975
+ self.endBackGroundTaskWithNotif(
1976
+ msg: "Error downloading file",
1977
+ latestVersionName: latestVersionName,
1978
+ current: current,
1979
+ plannedDirectUpdate: plannedDirectUpdate
1980
+ )
1503
1981
  return
1504
1982
  }
1505
1983
  } else {
1506
1984
  self.logger.info("No need to update, \(current.getId()) is the latest bundle.")
1507
- self.endBackGroundTaskWithNotif(msg: "No need to update, \(current.getId()) is the latest bundle.", latestVersionName: latestVersionName, current: current, error: false)
1985
+ self.endBackGroundTaskWithNotif(
1986
+ msg: "No need to update, \(current.getId()) is the latest bundle.",
1987
+ latestVersionName: latestVersionName,
1988
+ current: current,
1989
+ error: false,
1990
+ plannedDirectUpdate: plannedDirectUpdate
1991
+ )
1508
1992
  return
1509
1993
  }
1510
1994
  }
@@ -1528,6 +2012,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1528
2012
  logger.info("Next bundle is: \(next!.toString())")
1529
2013
  if self.implementation.set(bundle: next!) && self._reload() {
1530
2014
  logger.info("Updated to bundle: \(next!.toString())")
2015
+ self.notifyBundleSet(next!)
1531
2016
  _ = self.implementation.setNextBundle(next: Optional<String>.none)
1532
2017
  } else {
1533
2018
  logger.error("Update to bundle: \(next!.toString()) Failed!")
@@ -1563,7 +2048,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1563
2048
  logger.info("Background Timer Task canceled, Activity resumed before timer completes")
1564
2049
  }
1565
2050
  if self._isAutoUpdateEnabled() {
1566
- self.backgroundDownload()
2051
+ // Check if download is already in progress (with timeout protection)
2052
+ if !isDownloadStuckOrTimedOut() {
2053
+ self.backgroundDownload()
2054
+ } else {
2055
+ logger.info("Download already in progress, skipping duplicate download request")
2056
+ }
1567
2057
  } else {
1568
2058
  let instanceDescriptor = (self.bridge?.viewController as? CAPBridgeViewController)?.instanceDescriptor()
1569
2059
  if instanceDescriptor?.serverURL != nil {
@@ -1600,7 +2090,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1600
2090
 
1601
2091
  if res.version != current.getVersionName() {
1602
2092
  self.logger.info("New version found: \(res.version)")
1603
- self.backgroundDownload()
2093
+ // Check if download is already in progress (with timeout protection)
2094
+ if !self.isDownloadStuckOrTimedOut() {
2095
+ self.backgroundDownload()
2096
+ } else {
2097
+ self.logger.info("Download already in progress, skipping duplicate download request")
2098
+ }
1604
2099
  }
1605
2100
  }
1606
2101
  }
@@ -1686,6 +2181,24 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1686
2181
  ])
1687
2182
  }
1688
2183
 
2184
+ @objc func setShakeChannelSelector(_ call: CAPPluginCall) {
2185
+ guard let enabled = call.getBool("enabled") else {
2186
+ logger.error("setShakeChannelSelector called without enabled parameter")
2187
+ call.reject("setShakeChannelSelector called without enabled parameter")
2188
+ return
2189
+ }
2190
+
2191
+ self.shakeChannelSelectorEnabled = enabled
2192
+ logger.info("Shake channel selector \(enabled ? "enabled" : "disabled")")
2193
+ call.resolve()
2194
+ }
2195
+
2196
+ @objc func isShakeChannelSelectorEnabled(_ call: CAPPluginCall) {
2197
+ call.resolve([
2198
+ "enabled": self.shakeChannelSelectorEnabled
2199
+ ])
2200
+ }
2201
+
1689
2202
  @objc func getAppId(_ call: CAPPluginCall) {
1690
2203
  call.resolve([
1691
2204
  "appId": implementation.appId