@capgo/capacitor-updater 8.48.0 → 8.49.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,6 +38,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
38
38
  CAPPluginMethod(name: "setChannelUrl", returnType: CAPPluginReturnPromise),
39
39
  CAPPluginMethod(name: "set", returnType: CAPPluginReturnPromise),
40
40
  CAPPluginMethod(name: "startPreviewSession", returnType: CAPPluginReturnPromise),
41
+ CAPPluginMethod(name: "listPreviews", returnType: CAPPluginReturnPromise),
42
+ CAPPluginMethod(name: "setPreview", returnType: CAPPluginReturnPromise),
43
+ CAPPluginMethod(name: "resetPreview", returnType: CAPPluginReturnPromise),
44
+ CAPPluginMethod(name: "deletePreview", returnType: CAPPluginReturnPromise),
45
+ CAPPluginMethod(name: "checkPreviewUpdate", returnType: CAPPluginReturnPromise),
46
+ CAPPluginMethod(name: "updatePreview", returnType: CAPPluginReturnPromise),
41
47
  CAPPluginMethod(name: "list", returnType: CAPPluginReturnPromise),
42
48
  CAPPluginMethod(name: "delete", returnType: CAPPluginReturnPromise),
43
49
  CAPPluginMethod(name: "setBundleError", returnType: CAPPluginReturnPromise),
@@ -79,7 +85,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
79
85
  CAPPluginMethod(name: "completeFlexibleUpdate", returnType: CAPPluginReturnPromise)
80
86
  ]
81
87
  public var implementation = CapgoUpdater()
82
- private let pluginVersion: String = "8.48.0"
88
+ private let pluginVersion: String = "8.49.1"
83
89
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
84
90
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
85
91
  static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
@@ -108,6 +114,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
108
114
  private let previewPreviousDefaultChannelWasSetDefaultsKey = "CapacitorUpdater.previewPreviousDefaultChannelWasSet"
109
115
  private let previewAppIdDefaultsKey = "CapacitorUpdater.previewAppId"
110
116
  private let previewPayloadUrlDefaultsKey = "CapacitorUpdater.previewPayloadUrl"
117
+ private let previewNameDefaultsKey = "CapacitorUpdater.previewName"
118
+ private let previewSourceDefaultsKey = "CapacitorUpdater.previewSource"
119
+ private let previewSessionsDefaultsKey = "CapacitorUpdater.previewSessions"
111
120
  private let previewSessionAlertPendingDefaultsKey = "CapacitorUpdater.previewSessionAlertPending"
112
121
  private let previewDeepLinkScheme = "capgo"
113
122
  private let previewDeepLinkRootComponent = "preview"
@@ -170,7 +179,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
170
179
  public var shakeMenuEnabled = false
171
180
  public var shakeChannelSelectorEnabled = false
172
181
  public var shakeMenuGesture = CapacitorUpdaterPlugin.shakeMenuGestureShake
173
- var shakeMenuPinchGestureRecognizer: UIPinchGestureRecognizer?
182
+ var shakeMenuPinchGestureRecognizer: ThreeFingerPinchGestureRecognizer?
174
183
  var shakeMenuPinchGestureTriggered = false
175
184
  public var previewSessionEnabled = false
176
185
  private var previewSessionAlertPending = false
@@ -822,6 +831,226 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
822
831
  let error: String?
823
832
  }
824
833
 
834
+ private func normalizedPreviewMetadataValue(_ rawValue: String?) -> String? {
835
+ guard let rawValue else {
836
+ return nil
837
+ }
838
+
839
+ let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
840
+ guard !value.isEmpty else {
841
+ return nil
842
+ }
843
+
844
+ let lowercased = value.lowercased()
845
+ guard lowercased != "undefined", lowercased != "null" else {
846
+ return nil
847
+ }
848
+
849
+ return value
850
+ }
851
+
852
+ private func previewSessions() -> [String: [String: Any]] {
853
+ guard let rawSessions = UserDefaults.standard.dictionary(forKey: self.previewSessionsDefaultsKey) else {
854
+ return [:]
855
+ }
856
+
857
+ var sessions: [String: [String: Any]] = [:]
858
+ for (id, rawValue) in rawSessions {
859
+ if let session = rawValue as? [String: Any] {
860
+ sessions[id] = session
861
+ }
862
+ }
863
+ return sessions
864
+ }
865
+
866
+ private func savePreviewSessions(_ sessions: [String: [String: Any]]) {
867
+ UserDefaults.standard.set(sessions, forKey: self.previewSessionsDefaultsKey)
868
+ UserDefaults.standard.synchronize()
869
+ }
870
+
871
+ private func metadataString(_ metadata: [String: Any], _ key: String) -> String? {
872
+ self.normalizedPreviewMetadataValue(metadata[key] as? String)
873
+ }
874
+
875
+ private func currentPreviewMetadataValue(forKey key: String) -> String? {
876
+ self.normalizedPreviewMetadataValue(UserDefaults.standard.string(forKey: key))
877
+ }
878
+
879
+ private func previewInfo(
880
+ id: String,
881
+ metadata: [String: Any],
882
+ availableBundleIds: Set<String>,
883
+ currentBundleId: String
884
+ ) -> [String: Any]? {
885
+ let bundle = self.implementation.getBundleInfo(id: id)
886
+ if !bundle.isBuiltin() && !availableBundleIds.contains(id) {
887
+ return nil
888
+ }
889
+ if bundle.isDeleted() || bundle.isErrorStatus() {
890
+ return nil
891
+ }
892
+
893
+ var info: [String: Any] = [
894
+ "id": id,
895
+ "bundle": bundle.toJSON(),
896
+ "createdAt": self.metadataString(metadata, "createdAt") ?? Date().iso8601withFractionalSeconds,
897
+ "updatedAt": self.metadataString(metadata, "updatedAt") ?? Date().iso8601withFractionalSeconds,
898
+ "lastUsedAt": self.metadataString(metadata, "lastUsedAt") ?? Date().iso8601withFractionalSeconds,
899
+ "isActive": self.previewSessionEnabled && currentBundleId == id
900
+ ]
901
+
902
+ for key in ["name", "source", "appId", "payloadUrl"] {
903
+ if let value = self.metadataString(metadata, key) {
904
+ info[key] = value
905
+ }
906
+ }
907
+
908
+ return info
909
+ }
910
+
911
+ private func listPreviewInfos(cleanup: Bool = true) -> [[String: Any]] {
912
+ let availableBundleIds = Set(self.implementation.list().map { $0.getId() })
913
+ let currentBundleId = self.implementation.getCurrentBundleId()
914
+ var sessions = self.previewSessions()
915
+ var previews: [[String: Any]] = []
916
+ var staleIds: [String] = []
917
+
918
+ for (id, metadata) in sessions {
919
+ if let info = self.previewInfo(
920
+ id: id,
921
+ metadata: metadata,
922
+ availableBundleIds: availableBundleIds,
923
+ currentBundleId: currentBundleId
924
+ ) {
925
+ previews.append(info)
926
+ } else {
927
+ staleIds.append(id)
928
+ }
929
+ }
930
+
931
+ if cleanup && !staleIds.isEmpty {
932
+ for id in staleIds {
933
+ sessions.removeValue(forKey: id)
934
+ }
935
+ self.savePreviewSessions(sessions)
936
+ }
937
+
938
+ return previews.sorted { first, second in
939
+ let firstUsed = first["lastUsedAt"] as? String ?? ""
940
+ let secondUsed = second["lastUsedAt"] as? String ?? ""
941
+ return firstUsed > secondUsed
942
+ }
943
+ }
944
+
945
+ private func storedPreviewInfo(id: String) -> [String: Any]? {
946
+ let sessions = self.previewSessions()
947
+ guard let metadata = sessions[id] else {
948
+ return nil
949
+ }
950
+ let availableBundleIds = Set(self.implementation.list().map { $0.getId() })
951
+ return self.previewInfo(
952
+ id: id,
953
+ metadata: metadata,
954
+ availableBundleIds: availableBundleIds,
955
+ currentBundleId: self.implementation.getCurrentBundleId()
956
+ )
957
+ }
958
+
959
+ @discardableResult
960
+ private func recordPreviewBundle(_ bundle: BundleInfo, replacing oldId: String? = nil) -> [String: Any] {
961
+ let now = Date().iso8601withFractionalSeconds
962
+ var sessions = self.previewSessions()
963
+ let id = bundle.getId()
964
+ let replacingPreview = oldId.map { $0 != id } ?? false
965
+ var metadata = sessions[id] ?? (replacingPreview ? sessions[oldId ?? ""] ?? [:] : [:])
966
+
967
+ if metadata["createdAt"] == nil {
968
+ metadata["createdAt"] = now
969
+ }
970
+ metadata["updatedAt"] = now
971
+ if metadata["lastUsedAt"] == nil || self.implementation.getCurrentBundleId() == id {
972
+ metadata["lastUsedAt"] = now
973
+ }
974
+ metadata["version"] = bundle.getVersionName()
975
+
976
+ if !replacingPreview {
977
+ if let appId = self.currentPreviewMetadataValue(forKey: self.previewAppIdDefaultsKey) {
978
+ metadata["appId"] = appId
979
+ } else {
980
+ metadata.removeValue(forKey: "appId")
981
+ }
982
+
983
+ if let payloadUrl = self.currentPreviewMetadataValue(forKey: self.previewPayloadUrlDefaultsKey) {
984
+ metadata["payloadUrl"] = payloadUrl
985
+ } else {
986
+ metadata.removeValue(forKey: "payloadUrl")
987
+ }
988
+ }
989
+
990
+ if !replacingPreview {
991
+ if let name = self.currentPreviewMetadataValue(forKey: self.previewNameDefaultsKey) {
992
+ metadata["name"] = name
993
+ } else {
994
+ metadata.removeValue(forKey: "name")
995
+ }
996
+ }
997
+ if self.metadataString(metadata, "name") == nil {
998
+ metadata["name"] = bundle.getVersionName()
999
+ }
1000
+
1001
+ if !replacingPreview {
1002
+ if let source = self.currentPreviewMetadataValue(forKey: self.previewSourceDefaultsKey) {
1003
+ metadata["source"] = source
1004
+ } else {
1005
+ metadata.removeValue(forKey: "source")
1006
+ }
1007
+ }
1008
+
1009
+ if let oldId, oldId != id {
1010
+ sessions.removeValue(forKey: oldId)
1011
+ }
1012
+ sessions[id] = metadata
1013
+ self.savePreviewSessions(sessions)
1014
+
1015
+ return self.storedPreviewInfo(id: id) ?? [
1016
+ "id": id,
1017
+ "bundle": bundle.toJSON(),
1018
+ "createdAt": now,
1019
+ "updatedAt": now,
1020
+ "lastUsedAt": now,
1021
+ "isActive": self.previewSessionEnabled && self.implementation.getCurrentBundleId() == id
1022
+ ]
1023
+ }
1024
+
1025
+ private func updateCurrentPreviewSessionMetadata(from preview: [String: Any]) {
1026
+ if let appId = self.metadataString(preview, "appId") {
1027
+ self.implementation.appId = appId
1028
+ UserDefaults.standard.set(appId, forKey: self.previewAppIdDefaultsKey)
1029
+ } else {
1030
+ self.restorePreviewPreviousAppId()
1031
+ UserDefaults.standard.removeObject(forKey: self.previewAppIdDefaultsKey)
1032
+ }
1033
+
1034
+ if let payloadUrl = self.metadataString(preview, "payloadUrl") {
1035
+ UserDefaults.standard.set(payloadUrl, forKey: self.previewPayloadUrlDefaultsKey)
1036
+ } else {
1037
+ UserDefaults.standard.removeObject(forKey: self.previewPayloadUrlDefaultsKey)
1038
+ }
1039
+
1040
+ if let name = self.metadataString(preview, "name") {
1041
+ UserDefaults.standard.set(name, forKey: self.previewNameDefaultsKey)
1042
+ } else {
1043
+ UserDefaults.standard.removeObject(forKey: self.previewNameDefaultsKey)
1044
+ }
1045
+
1046
+ if let source = self.metadataString(preview, "source") {
1047
+ UserDefaults.standard.set(source, forKey: self.previewSourceDefaultsKey)
1048
+ } else {
1049
+ UserDefaults.standard.removeObject(forKey: self.previewSourceDefaultsKey)
1050
+ }
1051
+ UserDefaults.standard.synchronize()
1052
+ }
1053
+
825
1054
  private func makePreviewError(_ message: String) -> NSError {
826
1055
  NSError(domain: "CapacitorUpdaterPreview", code: 0, userInfo: [NSLocalizedDescriptionKey: message])
827
1056
  }
@@ -1113,6 +1342,16 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1113
1342
  if !res {
1114
1343
  logger.info("Bundle successfully set to: \(id) ")
1115
1344
  call.reject("Update failed, id \(id) doesn't exist")
1345
+ } else if self.previewSessionEnabled {
1346
+ let bundle = self.implementation.getBundleInfo(id: id)
1347
+ _ = self.recordPreviewBundle(bundle)
1348
+ if !self.reloadWithoutWaitingForAppReady() {
1349
+ call.reject("Reload failed after setting preview bundle \(id)")
1350
+ return
1351
+ }
1352
+ self.notifyBundleSet(bundle)
1353
+ self.showPreviewSessionNoticeIfNeeded()
1354
+ call.resolve()
1116
1355
  } else if !self._reload() {
1117
1356
  call.reject("Reload failed after setting bundle \(id)")
1118
1357
  } else {
@@ -1153,6 +1392,52 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1153
1392
  DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.appReadyTimeout), execute: workItem)
1154
1393
  }
1155
1394
 
1395
+ private func preparePreviewFallbackIfNeeded() -> Bool {
1396
+ if self.previewSessionEnabled {
1397
+ return true
1398
+ }
1399
+
1400
+ let current = self.implementation.getCurrentBundle()
1401
+ guard self.implementation.setPreviewFallbackBundle(fallback: current.getId()) else {
1402
+ logger.error("Could not save current bundle as preview fallback")
1403
+ return false
1404
+ }
1405
+
1406
+ if let previousNext = self.implementation.getNextBundle(),
1407
+ !previousNext.isDeleted(),
1408
+ !previousNext.isErrorStatus() {
1409
+ UserDefaults.standard.set(previousNext.getId(), forKey: self.previewPreviousNextBundleDefaultsKey)
1410
+ } else {
1411
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousNextBundleDefaultsKey)
1412
+ }
1413
+
1414
+ UserDefaults.standard.set(self.implementation.appId, forKey: self.previewPreviousAppIdDefaultsKey)
1415
+ if let previousDefaultChannel = UserDefaults.standard.object(forKey: self.defaultChannelDefaultsKey) as? String {
1416
+ UserDefaults.standard.set(previousDefaultChannel, forKey: self.previewPreviousDefaultChannelDefaultsKey)
1417
+ UserDefaults.standard.set(true, forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
1418
+ } else {
1419
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelDefaultsKey)
1420
+ UserDefaults.standard.set(false, forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
1421
+ }
1422
+ UserDefaults.standard.set(self.shakeMenuEnabled, forKey: self.previewPreviousShakeMenuDefaultsKey)
1423
+ UserDefaults.standard.set(self.shakeChannelSelectorEnabled, forKey: self.previewPreviousShakeChannelSelectorDefaultsKey)
1424
+ logger.info("Preview session started with fallback bundle: \(current.toString())")
1425
+ return true
1426
+ }
1427
+
1428
+ private func activatePreviewSessionState() {
1429
+ self.clearIncomingPreviewTransition()
1430
+ self.hidePreviewTransitionLoader(reason: "preview-session-started")
1431
+ self.previewSessionEnabled = true
1432
+ self.previewSessionAlertPending = true
1433
+ self.implementation.previewSession = true
1434
+ self.shakeMenuEnabled = true
1435
+ self.syncShakeMenuGestureRecognizer()
1436
+ UserDefaults.standard.set(true, forKey: self.previewSessionDefaultsKey)
1437
+ UserDefaults.standard.set(true, forKey: self.previewSessionAlertPendingDefaultsKey)
1438
+ UserDefaults.standard.synchronize()
1439
+ }
1440
+
1156
1441
  @objc func startPreviewSession(_ call: CAPPluginCall) {
1157
1442
  guard self.allowPreview else {
1158
1443
  self.hidePreviewTransitionLoader(reason: "preview-session-not-allowed")
@@ -1170,34 +1455,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1170
1455
  return
1171
1456
  }
1172
1457
 
1173
- if !self.previewSessionEnabled {
1174
- let current = self.implementation.getCurrentBundle()
1175
- guard self.implementation.setPreviewFallbackBundle(fallback: current.getId()) else {
1176
- self.hidePreviewTransitionLoader(reason: "preview-session-fallback-failed")
1177
- logger.error("Could not save current bundle as preview fallback")
1178
- call.reject("Could not save current bundle as preview fallback")
1179
- return
1180
- }
1181
-
1182
- if let previousNext = self.implementation.getNextBundle(),
1183
- !previousNext.isDeleted(),
1184
- !previousNext.isErrorStatus() {
1185
- UserDefaults.standard.set(previousNext.getId(), forKey: self.previewPreviousNextBundleDefaultsKey)
1186
- } else {
1187
- UserDefaults.standard.removeObject(forKey: self.previewPreviousNextBundleDefaultsKey)
1188
- }
1189
-
1190
- UserDefaults.standard.set(self.implementation.appId, forKey: self.previewPreviousAppIdDefaultsKey)
1191
- if let previousDefaultChannel = UserDefaults.standard.object(forKey: self.defaultChannelDefaultsKey) as? String {
1192
- UserDefaults.standard.set(previousDefaultChannel, forKey: self.previewPreviousDefaultChannelDefaultsKey)
1193
- UserDefaults.standard.set(true, forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
1194
- } else {
1195
- UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelDefaultsKey)
1196
- UserDefaults.standard.set(false, forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
1197
- }
1198
- UserDefaults.standard.set(self.shakeMenuEnabled, forKey: self.previewPreviousShakeMenuDefaultsKey)
1199
- UserDefaults.standard.set(self.shakeChannelSelectorEnabled, forKey: self.previewPreviousShakeChannelSelectorDefaultsKey)
1200
- logger.info("Preview session started with fallback bundle: \(current.toString())")
1458
+ guard self.preparePreviewFallbackIfNeeded() else {
1459
+ self.hidePreviewTransitionLoader(reason: "preview-session-fallback-failed")
1460
+ call.reject("Could not save current bundle as preview fallback")
1461
+ return
1201
1462
  }
1202
1463
 
1203
1464
  if let previewAppId = previewAppId, !previewAppId.isEmpty {
@@ -1213,22 +1474,251 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1213
1474
  UserDefaults.standard.removeObject(forKey: self.previewPayloadUrlDefaultsKey)
1214
1475
  }
1215
1476
 
1216
- self.clearIncomingPreviewTransition()
1217
- self.hidePreviewTransitionLoader(reason: "preview-session-started")
1218
- self.previewSessionEnabled = true
1219
- self.previewSessionAlertPending = true
1220
- self.implementation.previewSession = true
1221
- self.shakeMenuEnabled = true
1222
- self.syncShakeMenuGestureRecognizer()
1223
- UserDefaults.standard.set(true, forKey: self.previewSessionDefaultsKey)
1224
- UserDefaults.standard.set(true, forKey: self.previewSessionAlertPendingDefaultsKey)
1225
- UserDefaults.standard.synchronize()
1477
+ if let previewName = self.normalizedPreviewMetadataValue(call.getString("name")) {
1478
+ UserDefaults.standard.set(previewName, forKey: self.previewNameDefaultsKey)
1479
+ } else {
1480
+ UserDefaults.standard.removeObject(forKey: self.previewNameDefaultsKey)
1481
+ }
1482
+
1483
+ if let previewSource = self.normalizedPreviewMetadataValue(call.getString("source")) {
1484
+ UserDefaults.standard.set(previewSource, forKey: self.previewSourceDefaultsKey)
1485
+ } else {
1486
+ UserDefaults.standard.removeObject(forKey: self.previewSourceDefaultsKey)
1487
+ }
1488
+
1489
+ self.activatePreviewSessionState()
1226
1490
  call.resolve()
1227
1491
  }
1228
1492
 
1229
- func leavePreviewSessionFromShakeMenu() -> Bool {
1230
- let previewBundle = self.implementation.getCurrentBundle()
1493
+ @objc func listPreviews(_ call: CAPPluginCall) {
1494
+ guard self.allowPreview else {
1495
+ call.reject("listPreviews not allowed. Set allowPreview to true in your config to enable it.")
1496
+ return
1497
+ }
1498
+
1499
+ let previews = self.listPreviewInfos()
1500
+ var result: [String: Any] = [
1501
+ "previews": previews,
1502
+ "currentBundle": self.implementation.getCurrentBundle().toJSON()
1503
+ ]
1504
+ if let currentPreview = previews.first(where: { ($0["isActive"] as? Bool) == true }) {
1505
+ result["current"] = currentPreview
1506
+ }
1507
+ if let liveBundle = self.implementation.getPreviewFallbackBundle() {
1508
+ result["liveBundle"] = liveBundle.toJSON()
1509
+ }
1510
+ call.resolve(result)
1511
+ }
1512
+
1513
+ @objc func setPreview(_ call: CAPPluginCall) {
1514
+ guard self.allowPreview else {
1515
+ call.reject("setPreview not allowed. Set allowPreview to true in your config to enable it.")
1516
+ return
1517
+ }
1518
+ guard let id = call.getString("id"), !id.isEmpty else {
1519
+ call.reject("setPreview called without id")
1520
+ return
1521
+ }
1522
+ guard let preview = self.storedPreviewInfo(id: id) else {
1523
+ call.reject("Preview \(id) is not available locally")
1524
+ return
1525
+ }
1526
+
1527
+ self.showPreviewTransitionLoader(reason: "set-preview")
1528
+ DispatchQueue.global(qos: .userInitiated).async {
1529
+ guard self.preparePreviewFallbackIfNeeded() else {
1530
+ self.hidePreviewTransitionLoader(reason: "set-preview-fallback-failed")
1531
+ call.reject("Could not save current bundle as preview fallback")
1532
+ return
1533
+ }
1534
+
1535
+ guard self.implementation.set(id: id) else {
1536
+ self.hidePreviewTransitionLoader(reason: "set-preview-failed")
1537
+ call.reject("Preview \(id) cannot be applied")
1538
+ return
1539
+ }
1540
+
1541
+ let bundle = self.implementation.getBundleInfo(id: id)
1542
+ self.updateCurrentPreviewSessionMetadata(from: preview)
1543
+ self.activatePreviewSessionState()
1544
+ _ = self.recordPreviewBundle(bundle)
1545
+ guard self.reloadWithoutWaitingForAppReady() else {
1546
+ self.hidePreviewTransitionLoader(reason: "set-preview-reload-failed")
1547
+ call.reject("Reload failed after setting preview \(id)")
1548
+ return
1549
+ }
1550
+
1551
+ self.notifyBundleSet(bundle)
1552
+ self.showPreviewSessionNoticeIfNeeded()
1553
+ call.resolve()
1554
+ }
1555
+ }
1556
+
1557
+ func previewMenuPreviews() -> [[String: Any]] {
1558
+ self.listPreviewInfos()
1559
+ }
1560
+
1561
+ func setPreviewFromShakeMenu(id: String) -> Bool {
1562
+ guard self.allowPreview, let preview = self.storedPreviewInfo(id: id) else {
1563
+ return false
1564
+ }
1565
+
1566
+ self.showPreviewTransitionLoader(reason: "set-preview-menu")
1567
+ guard self.preparePreviewFallbackIfNeeded() else {
1568
+ self.hidePreviewTransitionLoader(reason: "set-preview-menu-fallback-failed")
1569
+ return false
1570
+ }
1571
+
1572
+ guard self.implementation.set(id: id) else {
1573
+ self.hidePreviewTransitionLoader(reason: "set-preview-menu-failed")
1574
+ return false
1575
+ }
1576
+
1577
+ let bundle = self.implementation.getBundleInfo(id: id)
1578
+ self.updateCurrentPreviewSessionMetadata(from: preview)
1579
+ self.activatePreviewSessionState()
1580
+ _ = self.recordPreviewBundle(bundle)
1581
+ guard self.reloadWithoutWaitingForAppReady() else {
1582
+ self.hidePreviewTransitionLoader(reason: "set-preview-menu-reload-failed")
1583
+ return false
1584
+ }
1585
+
1586
+ self.notifyBundleSet(bundle)
1587
+ self.showPreviewSessionNoticeIfNeeded()
1588
+ return true
1589
+ }
1590
+
1591
+ @objc func resetPreview(_ call: CAPPluginCall) {
1592
+ guard self.previewSessionEnabled else {
1593
+ call.resolve()
1594
+ return
1595
+ }
1596
+ DispatchQueue.global(qos: .userInitiated).async {
1597
+ if self.leavePreviewSessionFromShakeMenu() {
1598
+ call.resolve()
1599
+ } else {
1600
+ call.reject("Could not leave preview session")
1601
+ }
1602
+ }
1603
+ }
1604
+
1605
+ @objc func deletePreview(_ call: CAPPluginCall) {
1606
+ guard self.allowPreview else {
1607
+ call.reject("deletePreview not allowed. Set allowPreview to true in your config to enable it.")
1608
+ return
1609
+ }
1610
+ guard let id = call.getString("id"), !id.isEmpty else {
1611
+ call.reject("deletePreview called without id")
1612
+ return
1613
+ }
1614
+ if self.previewSessionEnabled && self.implementation.getCurrentBundleId() == id {
1615
+ call.reject("Cannot delete the active preview")
1616
+ return
1617
+ }
1618
+
1619
+ var sessions = self.previewSessions()
1620
+ let removed = sessions.removeValue(forKey: id) != nil
1621
+ self.savePreviewSessions(sessions)
1622
+
1623
+ var deleted = false
1624
+ let fallbackId = self.implementation.getPreviewFallbackBundle()?.getId()
1625
+ let nextId = self.implementation.getNextBundle()?.getId()
1626
+ if removed, id != fallbackId, id != nextId, id != BundleInfo.ID_BUILTIN {
1627
+ deleted = self.implementation.delete(id: id, removeInfo: false)
1628
+ }
1231
1629
 
1630
+ call.resolve(["removed": removed, "deleted": deleted])
1631
+ }
1632
+
1633
+ @objc func checkPreviewUpdate(_ call: CAPPluginCall) {
1634
+ self.handlePreviewUpdate(call, shouldDownload: false)
1635
+ }
1636
+
1637
+ @objc func updatePreview(_ call: CAPPluginCall) {
1638
+ self.handlePreviewUpdate(call, shouldDownload: true)
1639
+ }
1640
+
1641
+ private func handlePreviewUpdate(_ call: CAPPluginCall, shouldDownload: Bool) {
1642
+ guard self.allowPreview else {
1643
+ call.reject("Preview updates not allowed. Set allowPreview to true in your config to enable it.")
1644
+ return
1645
+ }
1646
+ guard let id = call.getString("id"), !id.isEmpty else {
1647
+ call.reject("Preview update called without id")
1648
+ return
1649
+ }
1650
+ guard let preview = self.storedPreviewInfo(id: id),
1651
+ let payloadUrlString = preview["payloadUrl"] as? String,
1652
+ let payloadUrl = self.normalizedPreviewPayloadUrl(payloadUrlString) else {
1653
+ call.reject("Preview \(id) has no payloadUrl to update from")
1654
+ return
1655
+ }
1656
+
1657
+ DispatchQueue.global(qos: .userInitiated).async {
1658
+ do {
1659
+ let payload = try self.fetchPreviewPayload(payloadUrl)
1660
+ guard let version = payload.version, !version.isEmpty else {
1661
+ throw self.makePreviewError("Preview payload is missing a version")
1662
+ }
1663
+
1664
+ let currentPreviewBundle = self.implementation.getBundleInfo(id: id)
1665
+ let upToDate = currentPreviewBundle.getVersionName() == version
1666
+ if upToDate || !shouldDownload {
1667
+ call.resolve([
1668
+ "preview": preview,
1669
+ "latestVersion": version,
1670
+ "upToDate": upToDate,
1671
+ "updated": false,
1672
+ "bundle": currentPreviewBundle.toJSON()
1673
+ ])
1674
+ return
1675
+ }
1676
+
1677
+ guard payload.url != nil || payload.manifest?.isEmpty == false else {
1678
+ throw self.makePreviewError("Preview payload is missing download information")
1679
+ }
1680
+
1681
+ let next = try self.downloadBundle(
1682
+ // Fallback URL is only provided when payload.url is missing; when manifestEntries is present,
1683
+ // downloadBundle routes through downloadManifest and ignores urlString.
1684
+ urlString: payload.url ?? "https://404.capgo.app/no.zip",
1685
+ version: version,
1686
+ sessionKey: payload.sessionKey ?? "",
1687
+ checksum: payload.checksum ?? "",
1688
+ manifestEntries: payload.manifest
1689
+ )
1690
+
1691
+ let wasActive = self.previewSessionEnabled && self.implementation.getCurrentBundleId() == id
1692
+ if wasActive {
1693
+ guard self.implementation.set(id: next.getId()) else {
1694
+ throw self.makePreviewError("Downloaded preview bundle cannot be applied")
1695
+ }
1696
+ }
1697
+
1698
+ let savedPreview = self.recordPreviewBundle(next, replacing: id)
1699
+ if wasActive {
1700
+ guard self.reloadWithoutWaitingForAppReady() else {
1701
+ throw self.makePreviewError("Reload failed after updating preview")
1702
+ }
1703
+ self.notifyBundleSet(next)
1704
+ self.showPreviewSessionNoticeIfNeeded()
1705
+ }
1706
+
1707
+ call.resolve([
1708
+ "preview": savedPreview,
1709
+ "latestVersion": version,
1710
+ "upToDate": false,
1711
+ "updated": true,
1712
+ "bundle": next.toJSON()
1713
+ ])
1714
+ } catch {
1715
+ self.logger.error("Could not update preview: \(error.localizedDescription)")
1716
+ call.reject("Could not update preview: \(error.localizedDescription)")
1717
+ }
1718
+ }
1719
+ }
1720
+
1721
+ func leavePreviewSessionFromShakeMenu() -> Bool {
1232
1722
  self.showPreviewTransitionLoader(reason: "leave-preview-session")
1233
1723
  let didReset = self.resetToPreviewFallbackBundle()
1234
1724
  guard didReset else {
@@ -1236,10 +1726,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1236
1726
  return false
1237
1727
  }
1238
1728
 
1239
- let previewFallbackBundle = self.implementation.getPreviewFallbackBundle()
1240
1729
  self.endPreviewSession(keepPreviewGuard: true)
1241
- let restoredNextBundle = self.implementation.getNextBundle()
1242
- self.deletePreviewBundleIfUnused(previewBundle, previewFallbackBundle: previewFallbackBundle, restoredNextBundle: restoredNextBundle)
1243
1730
  return true
1244
1731
  }
1245
1732
 
@@ -1262,7 +1749,6 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1262
1749
  }
1263
1750
 
1264
1751
  private func leavePreviewSessionWithoutReload(keepPreviewGuard: Bool = false) -> Bool {
1265
- let previewBundle = self.implementation.getCurrentBundle()
1266
1752
  guard let previewFallbackBundle = self.resolvePreviewFallbackBundle(reason: "preview deeplink launch") else {
1267
1753
  return false
1268
1754
  }
@@ -1272,14 +1758,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1272
1758
  }
1273
1759
 
1274
1760
  self.endPreviewSession(keepPreviewGuard: keepPreviewGuard)
1275
- let restoredNextBundle = self.implementation.getNextBundle()
1276
- self.deletePreviewBundleIfUnused(previewBundle, previewFallbackBundle: previewFallbackBundle, restoredNextBundle: restoredNextBundle)
1277
1761
  return true
1278
1762
  }
1279
1763
 
1280
1764
  private func leavePreviewSessionForIncomingPreviewLink() -> Bool {
1281
1765
  self.showPreviewTransitionLoader(reason: "incoming-preview-deeplink")
1282
- let previewBundle = self.implementation.getCurrentBundle()
1283
1766
  guard let previewFallbackBundle = self.resolvePreviewFallbackBundle(reason: "incoming preview deeplink") else {
1284
1767
  self.clearIncomingPreviewTransition()
1285
1768
  self.hidePreviewTransitionLoader(reason: "incoming-preview-deeplink-failed")
@@ -1297,12 +1780,6 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1297
1780
  let didReload = self.reloadWithoutWaitingForAppReady()
1298
1781
  if didReload {
1299
1782
  self.endPreviewSession(keepPreviewGuard: true)
1300
- let restoredNextBundle = self.implementation.getNextBundle()
1301
- self.deletePreviewBundleIfUnused(
1302
- previewBundle,
1303
- previewFallbackBundle: previewFallbackBundle,
1304
- restoredNextBundle: restoredNextBundle
1305
- )
1306
1783
  self.scheduleIncomingPreviewTransitionFallbackClear()
1307
1784
  } else {
1308
1785
  self.implementation.restoreResetState(previousState)
@@ -1313,18 +1790,6 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1313
1790
  return didReload
1314
1791
  }
1315
1792
 
1316
- private func deletePreviewBundleIfUnused(
1317
- _ previewBundle: BundleInfo,
1318
- previewFallbackBundle: BundleInfo?,
1319
- restoredNextBundle: BundleInfo?
1320
- ) {
1321
- if !previewBundle.isBuiltin() &&
1322
- previewFallbackBundle?.getId() != previewBundle.getId() &&
1323
- restoredNextBundle?.getId() != previewBundle.getId() {
1324
- _ = self.implementation.delete(id: previewBundle.getId(), removeInfo: false)
1325
- }
1326
- }
1327
-
1328
1793
  func reloadPreviewSessionFromShakeMenu() -> Bool {
1329
1794
  self.showPreviewTransitionLoader(reason: "reload-preview-session")
1330
1795
  let didReload: Bool
@@ -1460,6 +1925,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1460
1925
  UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
1461
1926
  UserDefaults.standard.removeObject(forKey: self.previewAppIdDefaultsKey)
1462
1927
  UserDefaults.standard.removeObject(forKey: self.previewPayloadUrlDefaultsKey)
1928
+ UserDefaults.standard.removeObject(forKey: self.previewNameDefaultsKey)
1929
+ UserDefaults.standard.removeObject(forKey: self.previewSourceDefaultsKey)
1463
1930
  UserDefaults.standard.removeObject(forKey: self.previewSessionAlertPendingDefaultsKey)
1464
1931
  UserDefaults.standard.synchronize()
1465
1932
  }
@@ -1644,6 +2111,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1644
2111
  throw makePreviewError("Downloaded preview bundle cannot be applied")
1645
2112
  }
1646
2113
 
2114
+ _ = self.recordPreviewBundle(next, replacing: current.getId())
1647
2115
  self.notifyBundleSet(next)
1648
2116
  return self.reloadWithoutWaitingForAppReady()
1649
2117
  } catch {
@@ -1653,7 +2121,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1653
2121
  }
1654
2122
 
1655
2123
  private func clearPreviewSessionForNativeBuildChange() {
1656
- guard self.previewSessionEnabled || self.implementation.getPreviewFallbackBundle() != nil else {
2124
+ guard self.previewSessionEnabled || self.implementation.getPreviewFallbackBundle() != nil || !self.previewSessions().isEmpty else {
1657
2125
  return
1658
2126
  }
1659
2127
  logger.info("Native build changed; clearing preview session state")
@@ -1670,6 +2138,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1670
2138
  _ = self.implementation.setPreviewFallbackBundle(fallback: nil)
1671
2139
  _ = self.implementation.setNextBundle(next: Optional<String>.none)
1672
2140
  self.clearPreviewSessionPreferences()
2141
+ UserDefaults.standard.removeObject(forKey: self.previewSessionsDefaultsKey)
2142
+ UserDefaults.standard.synchronize()
1673
2143
  }
1674
2144
 
1675
2145
  private func restorePreviewPreviousNextBundle() {
@@ -3520,15 +3990,6 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3520
3990
  return
3521
3991
  }
3522
3992
 
3523
- if let gesture = call.getString("gesture") {
3524
- guard Self.isSupportedShakeMenuGesture(gesture) else {
3525
- logger.error("Unsupported shake menu gesture: \(gesture)")
3526
- call.reject("Unsupported shake menu gesture. Use \"shake\" or \"threeFingerPinch\".")
3527
- return
3528
- }
3529
- self.shakeMenuGesture = Self.normalizedShakeMenuGesture(gesture)
3530
- }
3531
-
3532
3993
  self.shakeMenuEnabled = enabled
3533
3994
  self.syncShakeMenuGestureRecognizer()
3534
3995
  logger.info("Shake menu \(enabled ? "enabled" : "disabled") with \(self.shakeMenuGesture) gesture")