@capgo/capacitor-updater 8.45.10 → 8.46.0

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.
@@ -47,8 +47,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
47
47
  CAPPluginMethod(name: "setMultiDelay", returnType: CAPPluginReturnPromise),
48
48
  CAPPluginMethod(name: "cancelDelay", returnType: CAPPluginReturnPromise),
49
49
  CAPPluginMethod(name: "getLatest", returnType: CAPPluginReturnPromise),
50
+ CAPPluginMethod(name: "triggerUpdateCheck", returnType: CAPPluginReturnPromise),
50
51
  CAPPluginMethod(name: "setChannel", returnType: CAPPluginReturnPromise),
51
52
  CAPPluginMethod(name: "unsetChannel", returnType: CAPPluginReturnPromise),
53
+ CAPPluginMethod(name: "reportWebViewError", returnType: CAPPluginReturnPromise),
52
54
  CAPPluginMethod(name: "getChannel", returnType: CAPPluginReturnPromise),
53
55
  CAPPluginMethod(name: "listChannels", returnType: CAPPluginReturnPromise),
54
56
  CAPPluginMethod(name: "setCustomId", returnType: CAPPluginReturnPromise),
@@ -64,6 +66,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
64
66
  CAPPluginMethod(name: "isShakeMenuEnabled", returnType: CAPPluginReturnPromise),
65
67
  CAPPluginMethod(name: "setShakeChannelSelector", returnType: CAPPluginReturnPromise),
66
68
  CAPPluginMethod(name: "isShakeChannelSelectorEnabled", returnType: CAPPluginReturnPromise),
69
+ CAPPluginMethod(name: "getAppId", returnType: CAPPluginReturnPromise),
70
+ CAPPluginMethod(name: "setAppId", returnType: CAPPluginReturnPromise),
67
71
  // App Store update methods
68
72
  CAPPluginMethod(name: "getAppUpdateInfo", returnType: CAPPluginReturnPromise),
69
73
  CAPPluginMethod(name: "openAppStore", returnType: CAPPluginReturnPromise),
@@ -72,7 +76,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
72
76
  CAPPluginMethod(name: "completeFlexibleUpdate", returnType: CAPPluginReturnPromise)
73
77
  ]
74
78
  public var implementation = CapgoUpdater()
75
- private let pluginVersion: String = "8.45.10"
79
+ private let pluginVersion: String = "8.46.0"
76
80
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
77
81
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
78
82
  static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
@@ -128,6 +132,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
128
132
  private var persistModifyUrl = false
129
133
  private var allowManualBundleError = false
130
134
  private var keepUrlPathFlagLastValue: Bool?
135
+ private var appHealthTracker: AppHealthTracker?
136
+ private var webViewStatsReporter: WebViewStatsReporter?
131
137
  public var shakeMenuEnabled = false
132
138
  public var shakeChannelSelectorEnabled = false
133
139
  let semaphoreReady = DispatchSemaphore(value: 0)
@@ -143,6 +149,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
143
149
  } else {
144
150
  logger.error("Failed to get webView for logging")
145
151
  }
152
+ let webViewStatsReporter = WebViewStatsReporter(implementation: implementation)
153
+ self.webViewStatsReporter = webViewStatsReporter
154
+ webViewStatsReporter.install(on: self.bridge?.webView)
146
155
  #if targetEnvironment(simulator)
147
156
  logger.info("::::: SIMULATOR :::::")
148
157
  logger.info("Application directory: \(NSHomeDirectory())")
@@ -223,17 +232,21 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
223
232
  resetWhenUpdate = getConfig().getBoolean("resetWhenUpdate", true)
224
233
  shakeMenuEnabled = getConfig().getBoolean("shakeMenu", false)
225
234
  shakeChannelSelectorEnabled = getConfig().getBoolean("allowShakeChannelSelector", false)
226
- let periodCheckDelayValue = getConfig().getInt("periodCheckDelay", 0)
227
- if periodCheckDelayValue >= 0 && periodCheckDelayValue > 600 {
228
- periodCheckDelay = 600
229
- } else {
230
- periodCheckDelay = periodCheckDelayValue
231
- }
235
+ periodCheckDelay = Self.normalizedPeriodCheckDelaySeconds(getConfig().getInt("periodCheckDelay", 0))
232
236
 
233
237
  implementation.setPublicKey(getConfig().getString("publicKey") ?? "")
234
238
  implementation.notifyDownloadRaw = notifyDownload
235
239
  implementation.notifyListeners = { [weak self] eventName, data in
236
- self?.notifyListeners(eventName, data: data)
240
+ let emit = {
241
+ self?.notifyListeners(eventName, data: data)
242
+ }
243
+ if Thread.isMainThread {
244
+ emit()
245
+ } else {
246
+ DispatchQueue.main.async {
247
+ emit()
248
+ }
249
+ }
237
250
  }
238
251
  implementation.pluginVersion = self.pluginVersion
239
252
 
@@ -278,12 +291,17 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
278
291
  implementation.defaultChannel = getConfig().getString("defaultChannel", "")!
279
292
  }
280
293
  self.implementation.autoReset()
294
+ let appHealthTracker = AppHealthTracker(implementation: self.implementation)
295
+ self.appHealthTracker = appHealthTracker
296
+ appHealthTracker.reportPreviousUncleanForegroundExit()
297
+ appHealthTracker.startSession()
281
298
 
282
- // Check if app was recently installed/updated BEFORE cleanupObsoleteVersions updates LatestVersionNative
299
+ // Check if app was recently installed/updated BEFORE cleanup updates the stored native build version.
283
300
  self.wasRecentlyInstalledOrUpdated = self.checkIfRecentlyInstalledOrUpdated()
284
301
 
285
302
  if resetWhenUpdate {
286
- self.cleanupObsoleteVersions()
303
+ let didResetCurrentBundle = self.resetCurrentBundleForNativeBuildChangeIfNeeded()
304
+ self.cleanupObsoleteVersions(didResetCurrentBundle: didResetCurrentBundle)
287
305
  }
288
306
 
289
307
  // Load the server
@@ -300,6 +318,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
300
318
  let nc = NotificationCenter.default
301
319
  nc.addObserver(self, selector: #selector(appMovedToBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
302
320
  nc.addObserver(self, selector: #selector(appMovedToForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
321
+ nc.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, object: nil)
322
+ nc.addObserver(self, selector: #selector(appDidReceiveMemoryWarning), name: UIApplication.didReceiveMemoryWarningNotification, object: nil)
303
323
 
304
324
  // Check for 'kill' delay condition on app launch
305
325
  // This handles cases where the app was killed (willTerminateNotification is not reliable for system kills)
@@ -356,6 +376,22 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
356
376
  }
357
377
  }
358
378
 
379
+ @objc private func appWillTerminate() {
380
+ appHealthTracker?.markForeground(false)
381
+ }
382
+
383
+ @objc private func appDidReceiveMemoryWarning() {
384
+ appHealthTracker?.reportMemoryWarning()
385
+ }
386
+
387
+ @objc func reportWebViewError(_ call: CAPPluginCall) {
388
+ guard let webViewStatsReporter = webViewStatsReporter else {
389
+ call.resolve()
390
+ return
391
+ }
392
+ webViewStatsReporter.reportError(call)
393
+ }
394
+
359
395
  private func initialLoad() -> Bool {
360
396
  guard let bridge = self.bridge else { return false }
361
397
  if keepUrlPathAfterReload {
@@ -399,7 +435,29 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
399
435
  semaphoreReady.signal()
400
436
  }
401
437
 
402
- private func cleanupObsoleteVersions() {
438
+ func storedNativeBuildVersion() -> String {
439
+ UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
440
+ }
441
+
442
+ func hasNativeBuildVersionChanged() -> Bool {
443
+ let previous = self.storedNativeBuildVersion()
444
+ return previous != "0" && self.currentBuildVersion != previous
445
+ }
446
+
447
+ @discardableResult
448
+ func resetCurrentBundleForNativeBuildChangeIfNeeded() -> Bool {
449
+ let previous = self.storedNativeBuildVersion()
450
+ guard previous != "0" && self.currentBuildVersion != previous else {
451
+ return false
452
+ }
453
+
454
+ // Reset startup state synchronously so initialLoad() boots from the builtin bundle.
455
+ self.logger.info("Native build version changed from \(previous) to \(self.currentBuildVersion). Resetting startup bundle to builtin.")
456
+ self.implementation.reset(isInternal: true)
457
+ return true
458
+ }
459
+
460
+ private func cleanupObsoleteVersions(didResetCurrentBundle: Bool = false) {
403
461
  cleanupThread = Thread {
404
462
  self.cleanupLock.lock()
405
463
  defer {
@@ -434,9 +492,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
434
492
  // 1. Write "LatestVersionNative" - this fixes the part 1 of this bug
435
493
  // 2. Compare both keys. If any is not equal to "currentBuildVersion", then revert to builtin version. This fixes the part 2 of this bug
436
494
 
437
- let previous = UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
495
+ let previous = self.storedNativeBuildVersion()
438
496
  if previous != "0" && self.currentBuildVersion != previous {
439
- _ = self._reset(toLastSuccessful: false, usePendingBundle: false)
497
+ if !didResetCurrentBundle {
498
+ self.logger.info("Native build version changed from \(previous) to \(self.currentBuildVersion). Resetting current bundle to builtin.")
499
+ self.implementation.reset(isInternal: true)
500
+ }
440
501
  let res = self.implementation.list()
441
502
  for version in res {
442
503
  // Check if thread was cancelled
@@ -496,11 +557,88 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
496
557
  logger.info("Cleanup finished, proceeding with download")
497
558
  }
498
559
 
560
+ private func resolveCall(_ call: CAPPluginCall, data: PluginCallResultData? = nil) {
561
+ let resolve = {
562
+ let savedCall = self.bridge?.savedCall(withID: call.callbackId)
563
+ let targetCall = savedCall ?? call
564
+
565
+ if let data {
566
+ targetCall.resolve(data)
567
+ } else {
568
+ targetCall.resolve()
569
+ }
570
+
571
+ if savedCall != nil {
572
+ self.bridge?.releaseCall(withID: call.callbackId)
573
+ }
574
+ }
575
+
576
+ if Thread.isMainThread {
577
+ resolve()
578
+ } else {
579
+ DispatchQueue.main.async {
580
+ resolve()
581
+ }
582
+ }
583
+ }
584
+
585
+ private func rejectCall(_ call: CAPPluginCall, message: String, code: String? = nil, error: Error? = nil, data: PluginCallResultData? = nil) {
586
+ let reject = {
587
+ let savedCall = self.bridge?.savedCall(withID: call.callbackId)
588
+ let targetCall = savedCall ?? call
589
+
590
+ targetCall.reject(message, code, error, data)
591
+
592
+ if savedCall != nil {
593
+ self.bridge?.releaseCall(withID: call.callbackId)
594
+ }
595
+ }
596
+
597
+ if Thread.isMainThread {
598
+ reject()
599
+ } else {
600
+ DispatchQueue.main.async {
601
+ reject()
602
+ }
603
+ }
604
+ }
605
+
606
+ private func saveCallForAsyncHandling(_ call: CAPPluginCall) {
607
+ bridge?.saveCall(call)
608
+ }
609
+
610
+ private func notifyListenersOnMain(_ eventName: String, data: JSObject) {
611
+ let notify = {
612
+ self.notifyListeners(eventName, data: data)
613
+ }
614
+
615
+ if Thread.isMainThread {
616
+ notify()
617
+ } else {
618
+ DispatchQueue.main.async {
619
+ notify()
620
+ }
621
+ }
622
+ }
623
+
624
+ private func bundlePayload(_ bundleInfo: BundleInfo) -> JSObject {
625
+ var payload: JSObject = [:]
626
+ for (key, value) in bundleInfo.toJSON() {
627
+ payload[key] = value
628
+ }
629
+ return payload
630
+ }
631
+
499
632
  @objc func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
500
633
  let bundleInfo = bundle ?? self.implementation.getBundleInfo(id: id)
501
- self.notifyListeners("download", data: ["percent": percent, "bundle": bundleInfo.toJSON()])
634
+ var downloadPayload: JSObject = [:]
635
+ downloadPayload["percent"] = percent
636
+ downloadPayload["bundle"] = bundlePayload(bundleInfo)
637
+ self.notifyListenersOnMain("download", data: downloadPayload)
502
638
  if percent == 100 {
503
- self.notifyListeners("downloadComplete", data: ["bundle": bundleInfo.toJSON()])
639
+ var downloadCompletePayload: JSObject = [:]
640
+ downloadCompletePayload["bundle"] = bundlePayload(bundleInfo)
641
+ self.notifyListenersOnMain("downloadComplete", data: downloadCompletePayload)
504
642
  self.implementation.sendStats(action: "download_complete", versionName: bundleInfo.getVersionName())
505
643
  } else if percent.isMultiple(of: 10) || ignoreMultipleOfTen {
506
644
  self.implementation.sendStats(action: "download_\(percent)", versionName: bundleInfo.getVersionName())
@@ -597,6 +735,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
597
735
  let manifestArray = call.getArray("manifest")
598
736
  let url = URL(string: urlString)
599
737
  logger.info("Downloading \(String(describing: url))")
738
+ self.saveCallForAsyncHandling(call)
600
739
  DispatchQueue.global(qos: .background).async {
601
740
  do {
602
741
  let next: BundleInfo
@@ -644,13 +783,17 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
644
783
  } else {
645
784
  self.logger.info("Good checksum \(next.getChecksum()) \(checksum)")
646
785
  }
647
- self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
648
- call.resolve(next.toJSON())
786
+ var updateAvailablePayload: JSObject = [:]
787
+ updateAvailablePayload["bundle"] = self.bundlePayload(next)
788
+ self.notifyListenersOnMain("updateAvailable", data: updateAvailablePayload)
789
+ self.resolveCall(call, data: next.toJSON())
649
790
  } catch {
650
791
  self.logger.error("Failed to download from: \(String(describing: url)) \(error.localizedDescription)")
651
- self.notifyListeners("downloadFailed", data: ["version": version])
792
+ var downloadFailedPayload: JSObject = [:]
793
+ downloadFailedPayload["version"] = version
794
+ self.notifyListenersOnMain("downloadFailed", data: downloadFailedPayload)
652
795
  self.implementation.sendStats(action: "download_fail")
653
- call.reject("Failed to download from: \(url!) - \(error.localizedDescription)")
796
+ self.rejectCall(call, message: "Failed to download from: \(url!) - \(error.localizedDescription)")
654
797
  }
655
798
  }
656
799
  }
@@ -876,25 +1019,68 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
876
1019
 
877
1020
  @objc func getLatest(_ call: CAPPluginCall) {
878
1021
  let channel = call.getString("channel")
879
- DispatchQueue.global(qos: .background).async {
1022
+ self.saveCallForAsyncHandling(call)
1023
+ runGetLatestWork {
880
1024
  let res = self.implementation.getLatest(url: URL(string: self.updateUrl)!, channel: channel)
881
- if res.error != nil {
882
- call.reject( res.error!)
883
- } else if res.message != nil {
884
- call.reject( res.message!)
1025
+ if let error = res.error, !error.isEmpty {
1026
+ let responseKind = self.updateResponseKind(kind: res.kind)
1027
+ res.kind = responseKind
1028
+ if responseKind == "failed" {
1029
+ self.rejectCall(call, message: error)
1030
+ } else {
1031
+ if res.version.isEmpty {
1032
+ res.version = self.implementation.getCurrentBundle().getVersionName()
1033
+ }
1034
+ self.resolveCall(call, data: res.toDict())
1035
+ }
1036
+ } else if let kind = res.kind, !kind.isEmpty {
1037
+ let responseKind = self.updateResponseKind(kind: kind)
1038
+ res.kind = responseKind
1039
+ if responseKind != "failed" {
1040
+ if res.version.isEmpty {
1041
+ res.version = self.implementation.getCurrentBundle().getVersionName()
1042
+ }
1043
+ self.resolveCall(call, data: res.toDict())
1044
+ } else {
1045
+ self.rejectCall(call, message: res.message ?? "server did not provide a message")
1046
+ }
1047
+ } else if let message = res.message, !message.isEmpty {
1048
+ self.rejectCall(call, message: message)
885
1049
  } else {
886
- call.resolve(res.toDict())
1050
+ self.resolveCall(call, data: res.toDict())
887
1051
  }
888
1052
  }
889
1053
  }
890
1054
 
1055
+ public func triggerBackgroundUpdateCheck() -> String {
1056
+ guard !self.updateUrl.isEmpty, URL(string: self.updateUrl) != nil else {
1057
+ logger.error("Error no url or wrong format")
1058
+ return "unavailable"
1059
+ }
1060
+ if self.isDownloadStuckOrTimedOut() {
1061
+ logger.info("Download already in progress, skipping duplicate download request")
1062
+ return "already_running"
1063
+ }
1064
+ self.backgroundDownload()
1065
+ return "queued"
1066
+ }
1067
+
1068
+ @objc func triggerUpdateCheck(_ call: CAPPluginCall) {
1069
+ let status = self.triggerBackgroundUpdateCheck()
1070
+ call.resolve([
1071
+ "status": status,
1072
+ "queued": status == "queued"
1073
+ ])
1074
+ }
1075
+
891
1076
  @objc func unsetChannel(_ call: CAPPluginCall) {
892
1077
  let triggerAutoUpdate = call.getBool("triggerAutoUpdate", false)
893
- DispatchQueue.global(qos: .background).async {
1078
+ self.saveCallForAsyncHandling(call)
1079
+ DispatchQueue.global(qos: .utility).async {
894
1080
  let configDefaultChannel = self.getConfig().getString("defaultChannel", "")!
895
1081
  let res = self.implementation.unsetChannel(defaultChannelKey: self.defaultChannelDefaultsKey, configDefaultChannel: configDefaultChannel)
896
1082
  if res.error != "" {
897
- call.reject(res.error, "UNSETCHANNEL_FAILED", nil, [
1083
+ self.rejectCall(call, message: res.error, code: "UNSETCHANNEL_FAILED", data: [
898
1084
  "message": res.error,
899
1085
  "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
900
1086
  ])
@@ -908,7 +1094,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
908
1094
  self.logger.info("Download already in progress, skipping duplicate download request")
909
1095
  }
910
1096
  }
911
- call.resolve(res.toDict())
1097
+ self.resolveCall(call, data: res.toDict())
912
1098
  }
913
1099
  }
914
1100
  }
@@ -923,17 +1109,18 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
923
1109
  return
924
1110
  }
925
1111
  let triggerAutoUpdate = call.getBool("triggerAutoUpdate") ?? false
926
- DispatchQueue.global(qos: .background).async {
1112
+ self.saveCallForAsyncHandling(call)
1113
+ DispatchQueue.global(qos: .utility).async {
927
1114
  let res = self.implementation.setChannel(channel: channel, defaultChannelKey: self.defaultChannelDefaultsKey, allowSetDefaultChannel: self.allowSetDefaultChannel)
928
1115
  if res.error != "" {
929
1116
  // Fire channelPrivate event if channel doesn't allow self-assignment
930
1117
  if res.error.contains("cannot_update_via_private_channel") || res.error.contains("channel_self_set_not_allowed") {
931
- self.notifyListeners("channelPrivate", data: [
1118
+ self.notifyListenersOnMain("channelPrivate", data: [
932
1119
  "channel": channel,
933
1120
  "message": res.error
934
1121
  ])
935
1122
  }
936
- call.reject(res.error, "SETCHANNEL_FAILED", nil, [
1123
+ self.rejectCall(call, message: res.error, code: "SETCHANNEL_FAILED", data: [
937
1124
  "message": res.error,
938
1125
  "error": res.error.contains("Channel URL") ? "missing_config" : (res.error.contains("cannot_update_via_private_channel") || res.error.contains("channel_self_set_not_allowed")) ? "channel_private" : "request_failed"
939
1126
  ])
@@ -947,35 +1134,39 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
947
1134
  self.logger.info("Download already in progress, skipping duplicate download request")
948
1135
  }
949
1136
  }
950
- call.resolve(res.toDict())
1137
+ self.resolveCall(call, data: res.toDict())
951
1138
  }
952
1139
  }
953
1140
  }
954
1141
 
955
1142
  @objc func getChannel(_ call: CAPPluginCall) {
956
- DispatchQueue.global(qos: .background).async {
1143
+ self.saveCallForAsyncHandling(call)
1144
+ DispatchQueue.global(qos: .utility).async {
957
1145
  let res = self.implementation.getChannel()
958
1146
  if res.error != "" {
959
- call.reject(res.error, "GETCHANNEL_FAILED", nil, [
1147
+ self.rejectCall(call, message: res.error, code: "GETCHANNEL_FAILED", data: [
960
1148
  "message": res.error,
961
1149
  "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
962
1150
  ])
963
1151
  } else {
964
- call.resolve(res.toDict())
1152
+ self.resolveCall(call, data: res.toDict())
965
1153
  }
966
1154
  }
967
1155
  }
968
1156
 
969
1157
  @objc func listChannels(_ call: CAPPluginCall) {
970
- DispatchQueue.global(qos: .background).async {
1158
+ self.saveCallForAsyncHandling(call)
1159
+ DispatchQueue.global(qos: .utility).async {
971
1160
  let res = self.implementation.listChannels()
972
1161
  if res.error != "" {
973
- call.reject(res.error, "LISTCHANNELS_FAILED", nil, [
1162
+ self.rejectCall(call, message: res.error, code: "LISTCHANNELS_FAILED", data: [
974
1163
  "message": res.error,
975
1164
  "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
976
1165
  ])
977
1166
  } else {
978
- call.resolve(res.toDict())
1167
+ var payload: JSObject = [:]
1168
+ payload["channels"] = res.channels
1169
+ self.resolveCall(call, data: payload)
979
1170
  }
980
1171
  }
981
1172
  }
@@ -1102,6 +1293,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1102
1293
  let bundle = self.implementation.getCurrentBundle()
1103
1294
  self.implementation.setSuccess(bundle: bundle, autoDeletePrevious: self.autoDeletePrevious)
1104
1295
  logger.info("Current bundle loaded successfully. [notifyAppReady was called] \(bundle.toString())")
1296
+
1105
1297
  call.resolve(["bundle": bundle.toJSON()])
1106
1298
  }
1107
1299
 
@@ -1191,7 +1383,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1191
1383
 
1192
1384
  logger.info("Current bundle is: \(current.toString())")
1193
1385
 
1194
- if BundleStatus.SUCCESS.localizedString != current.getStatus() {
1386
+ if BundleStatus.SUCCESS.storedValue != current.getStatus() {
1195
1387
  logger.error("notifyAppReady was not called, roll back current bundle: \(current.toString())")
1196
1388
  logger.error("Did you forget to call 'notifyAppReady()' in your Capacitor App code?")
1197
1389
  self.notifyListeners("updateFailed", data: [
@@ -1572,6 +1764,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1572
1764
  plannedDirectUpdate && directUpdateMode == "onLaunch"
1573
1765
  }
1574
1766
 
1767
+ static func normalizedPeriodCheckDelaySeconds(_ value: Int) -> Int {
1768
+ guard value > 0 else {
1769
+ return 0
1770
+ }
1771
+ return max(600, value)
1772
+ }
1773
+
1575
1774
  private func getOnLaunchDirectUpdateUsed() -> Bool {
1576
1775
  self.onLaunchDirectUpdateStateLock.lock()
1577
1776
  defer { self.onLaunchDirectUpdateStateLock.unlock() }
@@ -1601,6 +1800,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1601
1800
  self.updateUrl = updateUrl
1602
1801
  }
1603
1802
 
1803
+ func setCurrentBuildVersionForTesting(_ currentBuildVersion: String) {
1804
+ self.currentBuildVersion = currentBuildVersion
1805
+ }
1806
+
1604
1807
  func shouldUseDirectUpdateForTesting() -> Bool {
1605
1808
  self.shouldUseDirectUpdate()
1606
1809
  }
@@ -1618,6 +1821,56 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1618
1821
  self.notifyListeners("majorAvailable", data: payload)
1619
1822
  }
1620
1823
 
1824
+ static func normalizedUpdateResponseKind(kind: String?) -> String {
1825
+ if let kind, ["up_to_date", "blocked", "failed"].contains(kind) {
1826
+ return kind
1827
+ }
1828
+ return "failed"
1829
+ }
1830
+
1831
+ private func updateResponseKind(kind: String?) -> String {
1832
+ Self.normalizedUpdateResponseKind(kind: kind)
1833
+ }
1834
+
1835
+ private func endBackgroundDownloadAfterLatestError(
1836
+ backendError: String,
1837
+ res: AppVersion,
1838
+ current: BundleInfo,
1839
+ plannedDirectUpdate: Bool
1840
+ ) {
1841
+ let statusCode = res.statusCode
1842
+ let responseKind = self.updateResponseKind(kind: res.kind)
1843
+ let responseMessage = res.message?.isEmpty == false ? res.message : nil
1844
+ let message = responseMessage ?? (backendError.isEmpty ? "server did not provide a message" : backendError)
1845
+ let latestVersionName = res.version.isEmpty ? current.getVersionName() : res.version
1846
+ self.notifyListeners("updateCheckResult", data: [
1847
+ "kind": responseKind,
1848
+ "error": backendError,
1849
+ "message": message,
1850
+ "statusCode": statusCode,
1851
+ "version": latestVersionName,
1852
+ "bundle": current.toJSON()
1853
+ ])
1854
+
1855
+ if responseKind == "up_to_date" {
1856
+ self.logger.info("No new version available")
1857
+ } else if responseKind == "blocked" {
1858
+ self.logger.info("Update check blocked with error: \(backendError)")
1859
+ } else {
1860
+ self.logger.error("getLatest failed with error: \(backendError)")
1861
+ }
1862
+
1863
+ let isFailure = responseKind == "failed"
1864
+ self.endBackGroundTaskWithNotif(
1865
+ msg: message,
1866
+ latestVersionName: latestVersionName,
1867
+ current: current,
1868
+ error: isFailure,
1869
+ plannedDirectUpdate: plannedDirectUpdate,
1870
+ sendStats: isFailure
1871
+ )
1872
+ }
1873
+
1621
1874
  func endBackGroundTaskWithNotif(
1622
1875
  msg: String,
1623
1876
  latestVersionName: String,
@@ -1672,6 +1925,26 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1672
1925
  }
1673
1926
 
1674
1927
  func runBackgroundDownloadWork(_ work: @escaping () -> Void) {
1928
+ // Live update checks/downloads are user-visible work. Using `.background`
1929
+ // lets the scheduler starve them for minutes while the app is active.
1930
+ DispatchQueue.global(qos: .utility).async(execute: work)
1931
+ }
1932
+
1933
+ private func beginDownloadBackgroundTask() {
1934
+ let registerTask = {
1935
+ self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "Finish Download Tasks") {
1936
+ self.endBackGroundTask()
1937
+ }
1938
+ }
1939
+
1940
+ if Thread.isMainThread {
1941
+ registerTask()
1942
+ } else {
1943
+ DispatchQueue.main.sync(execute: registerTask)
1944
+ }
1945
+ }
1946
+
1947
+ func runGetLatestWork(_ work: @escaping () -> Void) {
1675
1948
  DispatchQueue.global(qos: .background).async(execute: work)
1676
1949
  }
1677
1950
 
@@ -1697,26 +1970,20 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1697
1970
  self.runBackgroundDownloadWork {
1698
1971
  // Wait for cleanup to complete before starting download
1699
1972
  self.waitForCleanupIfNeeded()
1700
- self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "Finish Download Tasks") {
1701
- // End the task if time expires.
1702
- self.endBackGroundTask()
1703
- }
1973
+ self.beginDownloadBackgroundTask()
1704
1974
  self.logger.info("Check for update via \(self.updateUrl)")
1705
1975
  let res = self.implementation.getLatest(url: url, channel: nil)
1706
1976
  let current = self.implementation.getCurrentBundle()
1707
1977
 
1708
1978
  // Handle network errors and other failures first
1709
- if let backendError = res.error, !backendError.isEmpty {
1710
- self.logger.error("getLatest failed with error: \(backendError)")
1711
- let statusCode = res.statusCode
1712
- let responseIsOk = statusCode >= 200 && statusCode < 300
1713
- self.endBackGroundTaskWithNotif(
1714
- msg: res.message ?? backendError,
1715
- latestVersionName: res.version,
1979
+ let backendError = res.error ?? ""
1980
+ let backendKind = res.kind ?? ""
1981
+ if !backendError.isEmpty || !backendKind.isEmpty {
1982
+ self.endBackgroundDownloadAfterLatestError(
1983
+ backendError: backendError,
1984
+ res: res,
1716
1985
  current: current,
1717
- error: true,
1718
- plannedDirectUpdate: plannedDirectUpdate,
1719
- sendStats: !responseIsOk
1986
+ plannedDirectUpdate: plannedDirectUpdate
1720
1987
  )
1721
1988
  return
1722
1989
  }
@@ -1942,6 +2209,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1942
2209
  }
1943
2210
 
1944
2211
  @objc func appMovedToForeground() {
2212
+ appHealthTracker?.markForeground(true)
1945
2213
  let current: BundleInfo = self.implementation.getCurrentBundle()
1946
2214
  self.implementation.sendStats(action: "app_moved_to_foreground", versionName: current.getVersionName())
1947
2215
  self.delayUpdateUtils.checkCancelDelay(source: .foreground)
@@ -1987,7 +2255,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1987
2255
  timer.invalidate()
1988
2256
  return
1989
2257
  }
1990
- DispatchQueue.global(qos: .background).async {
2258
+ DispatchQueue.global(qos: .utility).async {
1991
2259
  let res = self.implementation.getLatest(url: url, channel: nil)
1992
2260
  let current = self.implementation.getCurrentBundle()
1993
2261
 
@@ -2008,6 +2276,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2008
2276
  @objc func appMovedToBackground() {
2009
2277
  // Reset timeout flag at start of each background cycle
2010
2278
  self.autoSplashscreenTimedOut = false
2279
+ appHealthTracker?.markForeground(false)
2011
2280
 
2012
2281
  let current: BundleInfo = self.implementation.getCurrentBundle()
2013
2282
  self.implementation.sendStats(action: "app_moved_to_background", versionName: current.getVersionName())
@@ -2139,29 +2408,30 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2139
2408
 
2140
2409
  logger.info("Getting App Store update info for \(bundleId) in country \(country)")
2141
2410
 
2411
+ self.saveCallForAsyncHandling(call)
2142
2412
  DispatchQueue.global(qos: .background).async {
2143
2413
  let urlString = "https://itunes.apple.com/lookup?bundleId=\(bundleId)&country=\(country)"
2144
2414
  guard let url = URL(string: urlString) else {
2145
- call.reject("Invalid URL for App Store lookup")
2415
+ self.rejectCall(call, message: "Invalid URL for App Store lookup")
2146
2416
  return
2147
2417
  }
2148
2418
 
2149
2419
  let task = URLSession.shared.dataTask(with: url) { data, _, error in
2150
2420
  if let error = error {
2151
2421
  self.logger.error("App Store lookup failed: \(error.localizedDescription)")
2152
- call.reject("App Store lookup failed: \(error.localizedDescription)")
2422
+ self.rejectCall(call, message: "App Store lookup failed: \(error.localizedDescription)")
2153
2423
  return
2154
2424
  }
2155
2425
 
2156
2426
  guard let data = data else {
2157
- call.reject("No data received from App Store")
2427
+ self.rejectCall(call, message: "No data received from App Store")
2158
2428
  return
2159
2429
  }
2160
2430
 
2161
2431
  do {
2162
2432
  guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
2163
2433
  let resultCount = json["resultCount"] as? Int else {
2164
- call.reject("Invalid response from App Store")
2434
+ self.rejectCall(call, message: "Invalid response from App Store")
2165
2435
  return
2166
2436
  }
2167
2437
 
@@ -2218,10 +2488,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2218
2488
  self.logger.info("App not found in App Store for bundleId: \(bundleId)")
2219
2489
  }
2220
2490
 
2221
- call.resolve(result)
2491
+ self.resolveCall(call, data: result)
2222
2492
  } catch {
2223
2493
  self.logger.error("Failed to parse App Store response: \(error.localizedDescription)")
2224
- call.reject("Failed to parse App Store response: \(error.localizedDescription)")
2494
+ self.rejectCall(call, message: "Failed to parse App Store response: \(error.localizedDescription)")
2225
2495
  }
2226
2496
  }
2227
2497
  task.resume()
@@ -2230,37 +2500,48 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2230
2500
 
2231
2501
  @objc func openAppStore(_ call: CAPPluginCall) {
2232
2502
  let appId = call.getString("appId")
2503
+ let bundleId = implementation.appId
2504
+ self.saveCallForAsyncHandling(call)
2233
2505
 
2234
- if let appId = appId {
2235
- // Open App Store with provided app ID
2236
- let urlString = "https://apps.apple.com/app/id\(appId)"
2506
+ func openAppStorePage(urlString: String, invalidMessage: String = "Invalid App Store URL", failureMessage: String = "Failed to open App Store") {
2237
2507
  guard let url = URL(string: urlString) else {
2238
- call.reject("Invalid App Store URL")
2508
+ self.rejectCall(call, message: invalidMessage)
2239
2509
  return
2240
2510
  }
2241
2511
  DispatchQueue.main.async {
2242
2512
  UIApplication.shared.open(url) { success in
2243
2513
  if success {
2244
- call.resolve()
2514
+ self.resolveCall(call)
2245
2515
  } else {
2246
- call.reject("Failed to open App Store")
2516
+ self.rejectCall(call, message: failureMessage)
2247
2517
  }
2248
2518
  }
2249
2519
  }
2520
+ }
2521
+
2522
+ func openFallbackAppStorePage() {
2523
+ guard let encodedBundleId = bundleId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
2524
+ self.rejectCall(call, message: "Failed to build App Store fallback URL")
2525
+ return
2526
+ }
2527
+ openAppStorePage(urlString: "https://apps.apple.com/app/\(encodedBundleId)")
2528
+ }
2529
+
2530
+ if let appId = appId {
2531
+ openAppStorePage(urlString: "https://apps.apple.com/app/id\(appId)")
2250
2532
  } else {
2251
- // Look up app ID using bundle identifier
2252
- let bundleId = implementation.appId
2253
2533
  let lookupUrl = "https://itunes.apple.com/lookup?bundleId=\(bundleId)"
2254
2534
 
2255
2535
  DispatchQueue.global(qos: .background).async {
2256
2536
  guard let url = URL(string: lookupUrl) else {
2257
- call.reject("Invalid lookup URL")
2537
+ openFallbackAppStorePage()
2258
2538
  return
2259
2539
  }
2260
2540
 
2261
2541
  let task = URLSession.shared.dataTask(with: url) { data, _, error in
2262
2542
  if let error = error {
2263
- call.reject("Failed to lookup app: \(error.localizedDescription)")
2543
+ self.logger.error("App Store lookup failed: \(error.localizedDescription)")
2544
+ openFallbackAppStorePage()
2264
2545
  return
2265
2546
  }
2266
2547
 
@@ -2269,39 +2550,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2269
2550
  let results = json["results"] as? [[String: Any]],
2270
2551
  let appInfo = results.first,
2271
2552
  let trackId = appInfo["trackId"] as? Int else {
2272
- // If lookup fails, try opening the generic App Store app page using bundle ID
2273
- let fallbackUrlString = "https://apps.apple.com/app/\(bundleId)"
2274
- guard let fallbackUrl = URL(string: fallbackUrlString) else {
2275
- call.reject("Failed to find app in App Store and fallback URL is invalid")
2276
- return
2277
- }
2278
- DispatchQueue.main.async {
2279
- UIApplication.shared.open(fallbackUrl) { success in
2280
- if success {
2281
- call.resolve()
2282
- } else {
2283
- call.reject("Failed to open App Store")
2284
- }
2285
- }
2286
- }
2287
- return
2288
- }
2289
-
2290
- let appStoreUrl = "https://apps.apple.com/app/id\(trackId)"
2291
- guard let url = URL(string: appStoreUrl) else {
2292
- call.reject("Invalid App Store URL")
2553
+ openFallbackAppStorePage()
2293
2554
  return
2294
2555
  }
2295
2556
 
2296
- DispatchQueue.main.async {
2297
- UIApplication.shared.open(url) { success in
2298
- if success {
2299
- call.resolve()
2300
- } else {
2301
- call.reject("Failed to open App Store")
2302
- }
2303
- }
2304
- }
2557
+ openAppStorePage(urlString: "https://apps.apple.com/app/id\(trackId)")
2305
2558
  }
2306
2559
  task.resume()
2307
2560
  }