@capgo/capacitor-updater 8.47.3 → 8.47.5

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.
@@ -79,7 +79,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
79
79
  CAPPluginMethod(name: "completeFlexibleUpdate", returnType: CAPPluginReturnPromise)
80
80
  ]
81
81
  public var implementation = CapgoUpdater()
82
- private let pluginVersion: String = "8.47.3"
82
+ private let pluginVersion: String = "8.47.5"
83
83
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
84
84
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
85
85
  static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
@@ -101,7 +101,16 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
101
101
  private let previewPreviousShakeChannelSelectorDefaultsKey = "CapacitorUpdater.previewPreviousShakeChannelSelector"
102
102
  private let previewPreviousNextBundleDefaultsKey = "CapacitorUpdater.previewPreviousNextBundle"
103
103
  private let previewPreviousAppIdDefaultsKey = "CapacitorUpdater.previewPreviousAppId"
104
+ private let previewPreviousDefaultChannelDefaultsKey = "CapacitorUpdater.previewPreviousDefaultChannel"
105
+ private let previewPreviousDefaultChannelWasSetDefaultsKey = "CapacitorUpdater.previewPreviousDefaultChannelWasSet"
104
106
  private let previewAppIdDefaultsKey = "CapacitorUpdater.previewAppId"
107
+ private let previewPayloadUrlDefaultsKey = "CapacitorUpdater.previewPayloadUrl"
108
+ private let previewSessionAlertPendingDefaultsKey = "CapacitorUpdater.previewSessionAlertPending"
109
+ private let previewDeepLinkScheme = "capgo"
110
+ private let previewDeepLinkRootComponent = "preview"
111
+ private let previewDeepLinkChannelComponent = "channel"
112
+ private let previewDeepLinkBundleComponent = "bundle"
113
+ private let previewPathSeparator = Character(UnicodeScalar(UInt8(47)))
105
114
  // Note: DELAY_CONDITION_PREFERENCES is now defined in DelayUpdateUtils.DELAY_CONDITION_PREFERENCES
106
115
  private var updateUrl = ""
107
116
  private var backgroundTaskID: UIBackgroundTaskIdentifier = UIBackgroundTaskIdentifier.invalid
@@ -155,6 +164,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
155
164
  public var shakeChannelSelectorEnabled = false
156
165
  public var previewSessionEnabled = false
157
166
  private var previewSessionAlertPending = false
167
+ private var isLeavingPreviewForIncomingLink = false
158
168
  let semaphoreReady = DispatchSemaphore(value: 0)
159
169
 
160
170
  private var delayUpdateUtils: DelayUpdateUtils!
@@ -230,6 +240,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
230
240
  previewSessionEnabled = allowPreview && storedPreviewSessionEnabled
231
241
  implementation.previewSession = previewSessionEnabled
232
242
  if previewSessionEnabled {
243
+ previewSessionAlertPending = UserDefaults.standard.object(forKey: previewSessionAlertPendingDefaultsKey) as? Bool ?? true
233
244
  shakeMenuEnabled = true
234
245
  shakeChannelSelectorEnabled = false
235
246
  }
@@ -312,6 +323,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
312
323
  if nativeBuildVersionChanged {
313
324
  self.clearPreviewSessionForNativeBuildChange()
314
325
  }
326
+ self.leavePreviewSessionForLaunchURLIfNeeded()
315
327
 
316
328
  if resetWhenUpdate {
317
329
  let didResetCurrentBundle = self.resetCurrentBundleForNativeBuildChangeIfNeeded()
@@ -329,11 +341,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
329
341
  logger.error("unable to force reload, the plugin might fallback to the builtin version")
330
342
  }
331
343
 
332
- let nc = NotificationCenter.default
333
- nc.addObserver(self, selector: #selector(appMovedToBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
334
- nc.addObserver(self, selector: #selector(appMovedToForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
335
- nc.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, object: nil)
336
- nc.addObserver(self, selector: #selector(appDidReceiveMemoryWarning), name: UIApplication.didReceiveMemoryWarningNotification, object: nil)
344
+ self.registerNotificationObservers()
337
345
 
338
346
  // Check for 'kill' delay condition on app launch
339
347
  // This handles cases where the app was killed (willTerminateNotification is not reliable for system kills)
@@ -341,6 +349,47 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
341
349
 
342
350
  self.appMovedToForeground()
343
351
  self.checkForUpdateAfterDelay()
352
+ self.showPreviewSessionNoticeIfNeeded()
353
+ }
354
+
355
+ private func registerNotificationObservers() {
356
+ let notificationCenter = NotificationCenter.default
357
+ notificationCenter.addObserver(
358
+ self,
359
+ selector: #selector(appMovedToBackground),
360
+ name: UIApplication.didEnterBackgroundNotification,
361
+ object: nil
362
+ )
363
+ notificationCenter.addObserver(
364
+ self,
365
+ selector: #selector(appMovedToForeground),
366
+ name: UIApplication.willEnterForegroundNotification,
367
+ object: nil
368
+ )
369
+ notificationCenter.addObserver(
370
+ self,
371
+ selector: #selector(appWillTerminate),
372
+ name: UIApplication.willTerminateNotification,
373
+ object: nil
374
+ )
375
+ notificationCenter.addObserver(
376
+ self,
377
+ selector: #selector(appDidReceiveMemoryWarning),
378
+ name: UIApplication.didReceiveMemoryWarningNotification,
379
+ object: nil
380
+ )
381
+ notificationCenter.addObserver(
382
+ self,
383
+ selector: #selector(handleOpenURLForPreviewSession(notification:)),
384
+ name: Notification.Name.capacitorOpenURL,
385
+ object: nil
386
+ )
387
+ notificationCenter.addObserver(
388
+ self,
389
+ selector: #selector(handleOpenURLForPreviewSession(notification:)),
390
+ name: Notification.Name.capacitorOpenUniversalLink,
391
+ object: nil
392
+ )
344
393
  }
345
394
 
346
395
  private func syncKeepUrlPathFlag(enabled: Bool) {
@@ -749,6 +798,62 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
749
798
  return manifestEntries
750
799
  }
751
800
 
801
+ private struct PreviewPayload: Decodable {
802
+ let version: String?
803
+ let url: String?
804
+ let checksum: String?
805
+ let sessionKey: String?
806
+ let manifest: [ManifestEntry]?
807
+ let message: String?
808
+ let error: String?
809
+ }
810
+
811
+ private func makePreviewError(_ message: String) -> NSError {
812
+ NSError(domain: "CapacitorUpdaterPreview", code: 0, userInfo: [NSLocalizedDescriptionKey: message])
813
+ }
814
+
815
+ private func downloadBundle(urlString: String, version: String, sessionKey: String, checksum rawChecksum: String, manifestEntries: [ManifestEntry]?) throws -> BundleInfo {
816
+ guard let url = URL(string: urlString) else {
817
+ throw makePreviewError("Invalid download URL")
818
+ }
819
+
820
+ var checksum = rawChecksum
821
+ let next: BundleInfo
822
+ if let manifestEntries = manifestEntries {
823
+ next = try self.implementation.downloadManifest(manifest: manifestEntries, version: version, sessionKey: sessionKey)
824
+ } else {
825
+ next = try self.implementation.download(url: url, version: version, sessionKey: sessionKey)
826
+ }
827
+
828
+ if self.implementation.publicKey != "" && checksum == "" {
829
+ self.logger.error("Public key present but no checksum provided")
830
+ self.implementation.sendStats(action: "checksum_required", versionName: next.getVersionName())
831
+ let id = next.getId()
832
+ let resDel = self.implementation.delete(id: id)
833
+ if !resDel {
834
+ self.logger.error("Delete failed, id \(id) doesn't exist")
835
+ }
836
+ throw ObjectSavableError.checksum
837
+ }
838
+
839
+ checksum = try CryptoCipher.decryptChecksum(checksum: checksum, publicKey: self.implementation.publicKey)
840
+ CryptoCipher.logChecksumInfo(label: "Bundle checksum", hexChecksum: next.getChecksum())
841
+ CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: checksum)
842
+ if (checksum != "" || self.implementation.publicKey != "") && next.getChecksum() != checksum {
843
+ self.logger.error("Error checksum \(next.getChecksum()) \(checksum)")
844
+ self.implementation.sendStats(action: "checksum_fail", versionName: next.getVersionName())
845
+ let id = next.getId()
846
+ let resDel = self.implementation.delete(id: id)
847
+ if !resDel {
848
+ self.logger.error("Delete failed, id \(id) doesn't exist")
849
+ }
850
+ throw ObjectSavableError.checksum
851
+ }
852
+
853
+ self.logger.info("Good checksum \(next.getChecksum()) \(checksum)")
854
+ return next
855
+ }
856
+
752
857
  @objc func download(_ call: CAPPluginCall) {
753
858
  guard let urlString = call.getString("url") else {
754
859
  logger.error("Download called without url")
@@ -762,57 +867,30 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
762
867
  }
763
868
 
764
869
  let sessionKey = call.getString("sessionKey", "")
765
- var checksum = call.getString("checksum", "")
870
+ let checksum = call.getString("checksum", "")
766
871
  let manifestArray = call.getArray("manifest")
767
- let url = URL(string: urlString)
768
- logger.info("Downloading \(String(describing: url))")
872
+ logger.info("Downloading \(urlString)")
769
873
  self.saveCallForAsyncHandling(call)
770
874
  self.runBackgroundDownloadWork {
771
875
  do {
772
- let next: BundleInfo
773
- if let manifestEntries = self.manifestEntries(from: manifestArray) {
774
- next = try self.implementation.downloadManifest(manifest: manifestEntries, version: version, sessionKey: sessionKey)
775
- } else {
776
- next = try self.implementation.download(url: url!, version: version, sessionKey: sessionKey)
777
- }
778
- // If public key is present but no checksum provided, refuse installation
779
- if self.implementation.publicKey != "" && checksum == "" {
780
- self.logger.error("Public key present but no checksum provided")
781
- self.implementation.sendStats(action: "checksum_required", versionName: next.getVersionName())
782
- let id = next.getId()
783
- let resDel = self.implementation.delete(id: id)
784
- if !resDel {
785
- self.logger.error("Delete failed, id \(id) doesn't exist")
786
- }
787
- throw ObjectSavableError.checksum
788
- }
789
-
790
- checksum = try CryptoCipher.decryptChecksum(checksum: checksum, publicKey: self.implementation.publicKey)
791
- CryptoCipher.logChecksumInfo(label: "Bundle checksum", hexChecksum: next.getChecksum())
792
- CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: checksum)
793
- if (checksum != "" || self.implementation.publicKey != "") && next.getChecksum() != checksum {
794
- self.logger.error("Error checksum \(next.getChecksum()) \(checksum)")
795
- self.implementation.sendStats(action: "checksum_fail", versionName: next.getVersionName())
796
- let id = next.getId()
797
- let resDel = self.implementation.delete(id: id)
798
- if !resDel {
799
- self.logger.error("Delete failed, id \(id) doesn't exist")
800
- }
801
- throw ObjectSavableError.checksum
802
- } else {
803
- self.logger.info("Good checksum \(next.getChecksum()) \(checksum)")
804
- }
876
+ let next = try self.downloadBundle(
877
+ urlString: urlString,
878
+ version: version,
879
+ sessionKey: sessionKey,
880
+ checksum: checksum,
881
+ manifestEntries: self.manifestEntries(from: manifestArray)
882
+ )
805
883
  var updateAvailablePayload: JSObject = [:]
806
884
  updateAvailablePayload["bundle"] = self.bundlePayload(next)
807
885
  self.notifyListenersOnMain("updateAvailable", data: updateAvailablePayload)
808
886
  self.resolveCall(call, data: next.toJSON())
809
887
  } catch {
810
- self.logger.error("Failed to download from: \(String(describing: url)) \(error.localizedDescription)")
888
+ self.logger.error("Failed to download from: \(urlString) \(error.localizedDescription)")
811
889
  var downloadFailedPayload: JSObject = [:]
812
890
  downloadFailedPayload["version"] = version
813
891
  self.notifyListenersOnMain("downloadFailed", data: downloadFailedPayload)
814
892
  self.implementation.sendStats(action: "download_fail")
815
- self.rejectCall(call, message: "Failed to download from: \(url!) - \(error.localizedDescription)")
893
+ self.rejectCall(call, message: "Failed to download from: \(urlString) - \(error.localizedDescription)")
816
894
  }
817
895
  }
818
896
  }
@@ -989,6 +1067,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
989
1067
  return
990
1068
  }
991
1069
  let previewAppId = self.normalizedPreviewAppId(call.getString("appId"))
1070
+ let rawPayloadUrl = call.getString("payloadUrl")
1071
+ let previewPayloadUrl = self.normalizedPreviewPayloadUrl(rawPayloadUrl)
1072
+ if let rawPayloadUrl = rawPayloadUrl, !rawPayloadUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, previewPayloadUrl == nil {
1073
+ logger.error("startPreviewSession called with invalid payloadUrl")
1074
+ call.reject("Invalid preview payloadUrl")
1075
+ return
1076
+ }
992
1077
 
993
1078
  if !self.previewSessionEnabled {
994
1079
  let current = self.implementation.getCurrentBundle()
@@ -1007,6 +1092,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1007
1092
  }
1008
1093
 
1009
1094
  UserDefaults.standard.set(self.implementation.appId, forKey: self.previewPreviousAppIdDefaultsKey)
1095
+ if let previousDefaultChannel = UserDefaults.standard.object(forKey: self.defaultChannelDefaultsKey) as? String {
1096
+ UserDefaults.standard.set(previousDefaultChannel, forKey: self.previewPreviousDefaultChannelDefaultsKey)
1097
+ UserDefaults.standard.set(true, forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
1098
+ } else {
1099
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelDefaultsKey)
1100
+ UserDefaults.standard.set(false, forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
1101
+ }
1010
1102
  UserDefaults.standard.set(self.shakeMenuEnabled, forKey: self.previewPreviousShakeMenuDefaultsKey)
1011
1103
  UserDefaults.standard.set(self.shakeChannelSelectorEnabled, forKey: self.previewPreviousShakeChannelSelectorDefaultsKey)
1012
1104
  logger.info("Preview session started with fallback bundle: \(current.toString())")
@@ -1018,39 +1110,94 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1018
1110
  logger.info("Preview session using appId: \(previewAppId)")
1019
1111
  }
1020
1112
 
1113
+ if let previewPayloadUrl = previewPayloadUrl {
1114
+ UserDefaults.standard.set(previewPayloadUrl.absoluteString, forKey: self.previewPayloadUrlDefaultsKey)
1115
+ logger.info("Preview session using payload URL")
1116
+ } else {
1117
+ UserDefaults.standard.removeObject(forKey: self.previewPayloadUrlDefaultsKey)
1118
+ }
1119
+
1021
1120
  self.previewSessionEnabled = true
1022
1121
  self.previewSessionAlertPending = true
1023
1122
  self.implementation.previewSession = true
1024
1123
  self.shakeMenuEnabled = true
1025
1124
  self.shakeChannelSelectorEnabled = false
1026
1125
  UserDefaults.standard.set(true, forKey: self.previewSessionDefaultsKey)
1126
+ UserDefaults.standard.set(true, forKey: self.previewSessionAlertPendingDefaultsKey)
1027
1127
  UserDefaults.standard.synchronize()
1028
1128
  call.resolve()
1029
1129
  }
1030
1130
 
1031
1131
  func leavePreviewSessionFromShakeMenu() -> Bool {
1032
1132
  let previewBundle = self.implementation.getCurrentBundle()
1033
- let configDefaultChannel = self.getConfig().getString("defaultChannel", "")!
1034
1133
 
1035
1134
  let didReset = self.resetToPreviewFallbackBundle()
1036
1135
  guard didReset else {
1037
1136
  return false
1038
1137
  }
1039
1138
 
1040
- _ = self.implementation.unsetChannel(defaultChannelKey: self.defaultChannelDefaultsKey, configDefaultChannel: configDefaultChannel)
1041
1139
  let previewFallbackBundle = self.implementation.getPreviewFallbackBundle()
1042
1140
  self.endPreviewSession()
1043
1141
  let restoredNextBundle = self.implementation.getNextBundle()
1142
+ self.deletePreviewBundleIfUnused(previewBundle, previewFallbackBundle: previewFallbackBundle, restoredNextBundle: restoredNextBundle)
1143
+ return true
1144
+ }
1145
+
1146
+ private func leavePreviewSessionForLaunchURLIfNeeded() {
1147
+ guard self.previewSessionEnabled,
1148
+ !self.isLeavingPreviewForIncomingLink,
1149
+ let launchUrl = ApplicationDelegateProxy.shared.lastURL,
1150
+ self.isPreviewDeepLink(launchUrl) else {
1151
+ return
1152
+ }
1153
+
1154
+ self.isLeavingPreviewForIncomingLink = true
1155
+ logger.info("Preview deeplink launch detected while preview session is active; restoring fallback before initial load")
1156
+ if !self.leavePreviewSessionWithoutReload() {
1157
+ logger.error("Could not leave preview session before initial preview deeplink routing")
1158
+ self.isLeavingPreviewForIncomingLink = false
1159
+ }
1160
+ }
1161
+
1162
+ private func leavePreviewSessionWithoutReload() -> Bool {
1163
+ let previewBundle = self.implementation.getCurrentBundle()
1164
+ guard let previewFallbackBundle = self.implementation.getPreviewFallbackBundle(), !previewFallbackBundle.isErrorStatus() else {
1165
+ logger.error("No preview fallback bundle available")
1166
+ return false
1167
+ }
1168
+ guard self.implementation.canSet(bundle: previewFallbackBundle) else {
1169
+ logger.error("Preview fallback bundle is not installable")
1170
+ return false
1171
+ }
1172
+ guard self.implementation.stagePreviewFallbackReload(bundle: previewFallbackBundle) else {
1173
+ logger.error("Could not stage preview fallback bundle")
1174
+ return false
1175
+ }
1176
+
1177
+ self.endPreviewSession()
1178
+ let restoredNextBundle = self.implementation.getNextBundle()
1179
+ self.deletePreviewBundleIfUnused(previewBundle, previewFallbackBundle: previewFallbackBundle, restoredNextBundle: restoredNextBundle)
1180
+ return true
1181
+ }
1182
+
1183
+ private func deletePreviewBundleIfUnused(
1184
+ _ previewBundle: BundleInfo,
1185
+ previewFallbackBundle: BundleInfo?,
1186
+ restoredNextBundle: BundleInfo?
1187
+ ) {
1044
1188
  if !previewBundle.isBuiltin() &&
1045
1189
  previewFallbackBundle?.getId() != previewBundle.getId() &&
1046
1190
  restoredNextBundle?.getId() != previewBundle.getId() {
1047
1191
  _ = self.implementation.delete(id: previewBundle.getId(), removeInfo: false)
1048
1192
  }
1049
- return true
1050
1193
  }
1051
1194
 
1052
1195
  func reloadPreviewSessionFromShakeMenu() -> Bool {
1053
- self._reload()
1196
+ if let payloadUrl = self.storedPreviewPayloadUrl() {
1197
+ return self.refreshPreviewSessionFromPayloadUrl(payloadUrl)
1198
+ }
1199
+
1200
+ return self._reload()
1054
1201
  }
1055
1202
 
1056
1203
  func hasActivePreviewSession() -> Bool {
@@ -1088,9 +1235,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1088
1235
  ?? getConfig().getBoolean("allowShakeChannelSelector", false)
1089
1236
  self.restorePreviewPreviousNextBundle()
1090
1237
  self.restorePreviewPreviousAppId()
1238
+ self.restorePreviewPreviousDefaultChannel()
1091
1239
 
1092
1240
  self.previewSessionEnabled = false
1093
1241
  self.previewSessionAlertPending = false
1242
+ self.isLeavingPreviewForIncomingLink = false
1094
1243
  self.implementation.previewSession = false
1095
1244
  self.shakeMenuEnabled = previousShakeMenuEnabled
1096
1245
  self.shakeChannelSelectorEnabled = previousShakeChannelSelectorEnabled
@@ -1117,8 +1266,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1117
1266
 
1118
1267
  self.restorePreviewPreviousNextBundle()
1119
1268
  self.restorePreviewPreviousAppId()
1269
+ self.restorePreviewPreviousDefaultChannel()
1120
1270
  self.previewSessionEnabled = false
1121
1271
  self.previewSessionAlertPending = false
1272
+ self.isLeavingPreviewForIncomingLink = false
1122
1273
  self.implementation.previewSession = false
1123
1274
  self.shakeMenuEnabled = getConfig().getBoolean("shakeMenu", false)
1124
1275
  self.shakeChannelSelectorEnabled = getConfig().getBoolean("allowShakeChannelSelector", false)
@@ -1132,7 +1283,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1132
1283
  UserDefaults.standard.removeObject(forKey: self.previewPreviousShakeChannelSelectorDefaultsKey)
1133
1284
  UserDefaults.standard.removeObject(forKey: self.previewPreviousNextBundleDefaultsKey)
1134
1285
  UserDefaults.standard.removeObject(forKey: self.previewPreviousAppIdDefaultsKey)
1286
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelDefaultsKey)
1287
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
1135
1288
  UserDefaults.standard.removeObject(forKey: self.previewAppIdDefaultsKey)
1289
+ UserDefaults.standard.removeObject(forKey: self.previewPayloadUrlDefaultsKey)
1290
+ UserDefaults.standard.removeObject(forKey: self.previewSessionAlertPendingDefaultsKey)
1136
1291
  UserDefaults.standard.synchronize()
1137
1292
  }
1138
1293
 
@@ -1145,6 +1300,23 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1145
1300
  logger.info("Restored appId after preview: \(previousAppId)")
1146
1301
  }
1147
1302
 
1303
+ private func restorePreviewPreviousDefaultChannel() {
1304
+ let configDefaultChannel = self.getConfig().getString("defaultChannel", "")!
1305
+ let hadPreviousDefaultChannel = UserDefaults.standard.object(forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey) as? Bool ?? false
1306
+
1307
+ guard hadPreviousDefaultChannel,
1308
+ let previousDefaultChannel = UserDefaults.standard.string(forKey: self.previewPreviousDefaultChannelDefaultsKey) else {
1309
+ UserDefaults.standard.removeObject(forKey: self.defaultChannelDefaultsKey)
1310
+ self.implementation.defaultChannel = configDefaultChannel
1311
+ logger.info("Restored defaultChannel after preview to config value")
1312
+ return
1313
+ }
1314
+
1315
+ UserDefaults.standard.set(previousDefaultChannel, forKey: self.defaultChannelDefaultsKey)
1316
+ self.implementation.defaultChannel = previousDefaultChannel
1317
+ logger.info("Restored defaultChannel after preview")
1318
+ }
1319
+
1148
1320
  private func normalizedPreviewAppId(_ rawAppId: String?) -> String? {
1149
1321
  guard let rawAppId else {
1150
1322
  return nil
@@ -1163,6 +1335,148 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1163
1335
  return appId
1164
1336
  }
1165
1337
 
1338
+ private func normalizedPreviewPayloadUrl(_ rawPayloadUrl: String?) -> URL? {
1339
+ guard let rawPayloadUrl else {
1340
+ return nil
1341
+ }
1342
+
1343
+ let payloadUrl = rawPayloadUrl.trimmingCharacters(in: .whitespacesAndNewlines)
1344
+ guard !payloadUrl.isEmpty,
1345
+ let url = URL(string: payloadUrl),
1346
+ url.scheme == "https" || url.scheme == "http" else {
1347
+ return nil
1348
+ }
1349
+
1350
+ return url
1351
+ }
1352
+
1353
+ private func storedPreviewPayloadUrl() -> URL? {
1354
+ normalizedPreviewPayloadUrl(UserDefaults.standard.string(forKey: self.previewPayloadUrlDefaultsKey))
1355
+ }
1356
+
1357
+ private func previewPath(from url: URL) -> String {
1358
+ if url.scheme == self.previewDeepLinkScheme {
1359
+ var components: [String] = []
1360
+ if let host = url.host, !host.isEmpty {
1361
+ components.append(host)
1362
+ }
1363
+ components.append(contentsOf: url.path.split(separator: self.previewPathSeparator).map(String.init))
1364
+ return self.normalizedPreviewPath(components)
1365
+ }
1366
+
1367
+ return url.path
1368
+ }
1369
+
1370
+ private func normalizedPreviewPath(_ components: [String]) -> String {
1371
+ let separator = String(self.previewPathSeparator)
1372
+ return separator + components.filter { !$0.isEmpty }.joined(separator: separator)
1373
+ }
1374
+
1375
+ private func previewDeepLinkPath(_ leafComponent: String) -> String {
1376
+ self.normalizedPreviewPath([self.previewDeepLinkRootComponent, leafComponent])
1377
+ }
1378
+
1379
+ private func isPreviewDeepLink(_ url: URL) -> Bool {
1380
+ let path = self.previewPath(from: url)
1381
+ return path == self.previewDeepLinkPath(self.previewDeepLinkChannelComponent) ||
1382
+ path == self.previewDeepLinkPath(self.previewDeepLinkBundleComponent)
1383
+ }
1384
+
1385
+ @objc private func handleOpenURLForPreviewSession(notification: NSNotification) {
1386
+ let rawUrl = (notification.object as? [String: Any])?["url"]
1387
+ let url = rawUrl as? URL ?? (rawUrl as? NSURL).map { $0 as URL }
1388
+ guard self.previewSessionEnabled,
1389
+ !self.isLeavingPreviewForIncomingLink,
1390
+ let url,
1391
+ self.isPreviewDeepLink(url) else {
1392
+ return
1393
+ }
1394
+
1395
+ self.isLeavingPreviewForIncomingLink = true
1396
+ logger.info("Preview deeplink received while preview session is active; restoring fallback before routing")
1397
+ DispatchQueue.global(qos: .userInitiated).async {
1398
+ let didLeave = self.leavePreviewSessionFromShakeMenu()
1399
+ if !didLeave {
1400
+ self.logger.error("Could not leave preview session before routing incoming preview deeplink")
1401
+ self.isLeavingPreviewForIncomingLink = false
1402
+ }
1403
+ }
1404
+ }
1405
+
1406
+ private func fetchPreviewPayload(_ payloadUrl: URL) throws -> PreviewPayload {
1407
+ var request = URLRequest(url: payloadUrl)
1408
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
1409
+
1410
+ let semaphore = DispatchSemaphore(value: 0)
1411
+ var responseData: Data?
1412
+ var response: URLResponse?
1413
+ var responseError: Error?
1414
+
1415
+ URLSession.shared.dataTask(with: request) { data, urlResponse, error in
1416
+ responseData = data
1417
+ response = urlResponse
1418
+ responseError = error
1419
+ semaphore.signal()
1420
+ }.resume()
1421
+
1422
+ if semaphore.wait(timeout: .now() + 60) == .timedOut {
1423
+ throw makePreviewError("Preview payload request timed out")
1424
+ }
1425
+
1426
+ if let responseError = responseError {
1427
+ throw responseError
1428
+ }
1429
+
1430
+ let data = responseData ?? Data()
1431
+ if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
1432
+ if let payload = try? JSONDecoder().decode(PreviewPayload.self, from: data) {
1433
+ throw makePreviewError(payload.message ?? payload.error ?? "Preview payload request failed with HTTP \(httpResponse.statusCode)")
1434
+ }
1435
+ let message = String(data: data, encoding: .utf8) ?? "Preview payload request failed with HTTP \(httpResponse.statusCode)"
1436
+ throw makePreviewError(message)
1437
+ }
1438
+
1439
+ return try JSONDecoder().decode(PreviewPayload.self, from: data)
1440
+ }
1441
+
1442
+ private func refreshPreviewSessionFromPayloadUrl(_ payloadUrl: URL) -> Bool {
1443
+ do {
1444
+ let payload = try self.fetchPreviewPayload(payloadUrl)
1445
+ guard let version = payload.version, !version.isEmpty else {
1446
+ throw makePreviewError("Preview payload is missing a version")
1447
+ }
1448
+ guard payload.url != nil || payload.manifest?.isEmpty == false else {
1449
+ throw makePreviewError("Preview payload is missing download information")
1450
+ }
1451
+
1452
+ let current = self.implementation.getCurrentBundle()
1453
+ if current.getVersionName() == version {
1454
+ self.logger.info("Preview payload unchanged, reloading current bundle")
1455
+ return self._reload()
1456
+ }
1457
+
1458
+ let next = try self.downloadBundle(
1459
+ // Fallback URL is only provided when payload.url is missing; when manifestEntries is present,
1460
+ // downloadBundle routes through downloadManifest and ignores urlString.
1461
+ urlString: payload.url ?? "https://404.capgo.app/no.zip",
1462
+ version: version,
1463
+ sessionKey: payload.sessionKey ?? "",
1464
+ checksum: payload.checksum ?? "",
1465
+ manifestEntries: payload.manifest
1466
+ )
1467
+
1468
+ guard self.implementation.set(id: next.getId()) else {
1469
+ throw makePreviewError("Downloaded preview bundle cannot be applied")
1470
+ }
1471
+
1472
+ self.notifyBundleSet(next)
1473
+ return self._reload()
1474
+ } catch {
1475
+ self.logger.error("Could not refresh preview session: \(error.localizedDescription)")
1476
+ return false
1477
+ }
1478
+ }
1479
+
1166
1480
  private func clearPreviewSessionForNativeBuildChange() {
1167
1481
  guard self.previewSessionEnabled || self.implementation.getPreviewFallbackBundle() != nil else {
1168
1482
  return
@@ -1170,14 +1484,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1170
1484
  logger.info("Native build changed; clearing preview session state")
1171
1485
  self.previewSessionEnabled = false
1172
1486
  self.previewSessionAlertPending = false
1487
+ self.isLeavingPreviewForIncomingLink = false
1173
1488
  self.implementation.previewSession = false
1174
1489
  self.shakeMenuEnabled = getConfig().getBoolean("shakeMenu", false)
1175
1490
  self.shakeChannelSelectorEnabled = getConfig().getBoolean("allowShakeChannelSelector", false)
1176
1491
  self.restorePreviewPreviousAppId()
1492
+ self.restorePreviewPreviousDefaultChannel()
1177
1493
  _ = self.implementation.setPreviewFallbackBundle(fallback: nil)
1178
1494
  _ = self.implementation.setNextBundle(next: Optional<String>.none)
1179
- let configDefaultChannel = self.getConfig().getString("defaultChannel", "")!
1180
- _ = self.implementation.unsetChannel(defaultChannelKey: self.defaultChannelDefaultsKey, configDefaultChannel: configDefaultChannel)
1181
1495
  self.clearPreviewSessionPreferences()
1182
1496
  }
1183
1497
 
@@ -1198,6 +1512,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1198
1512
  return
1199
1513
  }
1200
1514
  self.previewSessionAlertPending = false
1515
+ UserDefaults.standard.set(false, forKey: self.previewSessionAlertPendingDefaultsKey)
1516
+ UserDefaults.standard.synchronize()
1201
1517
 
1202
1518
  DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(600)) {
1203
1519
  guard self.previewSessionEnabled else {
@@ -1206,6 +1522,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1206
1522
  if let topVC = UIApplication.topViewController(),
1207
1523
  topVC.isKind(of: UIAlertController.self) {
1208
1524
  self.previewSessionAlertPending = true
1525
+ UserDefaults.standard.set(true, forKey: self.previewSessionAlertPendingDefaultsKey)
1526
+ UserDefaults.standard.synchronize()
1209
1527
  return
1210
1528
  }
1211
1529
 
@@ -1217,6 +1535,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1217
1535
  alert.addAction(UIAlertAction(title: "Got it", style: .default))
1218
1536
  if let topVC = UIApplication.topViewController() {
1219
1537
  topVC.present(alert, animated: true)
1538
+ } else {
1539
+ self.previewSessionAlertPending = true
1540
+ UserDefaults.standard.set(true, forKey: self.previewSessionAlertPendingDefaultsKey)
1541
+ UserDefaults.standard.synchronize()
1220
1542
  }
1221
1543
  }
1222
1544
  }
@@ -29,7 +29,7 @@ extension UIWindow {
29
29
  // Find the CapacitorUpdaterPlugin instance
30
30
  guard let bridgeViewController = rootViewController as? CAPBridgeViewController,
31
31
  let bridge = bridgeViewController.bridge,
32
- let plugin = bridge.plugin(withName: "CapacitorUpdaterPlugin") as? CapacitorUpdaterPlugin else {
32
+ let plugin = bridge.plugin(withName: "CapacitorUpdater") as? CapacitorUpdaterPlugin else {
33
33
  return
34
34
  }
35
35
 
@@ -81,9 +81,11 @@ extension UIWindow {
81
81
  })
82
82
 
83
83
  alertShake.addAction(UIAlertAction(title: reloadButtonTitle, style: .default) { _ in
84
- DispatchQueue.main.async {
84
+ DispatchQueue.global(qos: .userInitiated).async {
85
85
  if !plugin.reloadPreviewSessionFromShakeMenu() {
86
- self.showError(message: "Could not reload the test app.", plugin: plugin)
86
+ DispatchQueue.main.async {
87
+ self.showError(message: "Could not reload the test app.", plugin: plugin)
88
+ }
87
89
  }
88
90
  }
89
91
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "8.47.3",
3
+ "version": "8.47.5",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",