@capgo/capacitor-updater 8.51.1 → 8.51.2

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.
@@ -16,6 +16,13 @@ import Version
16
16
  */
17
17
  @objc(CapacitorUpdaterPlugin)
18
18
  public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
19
+ enum DefaultChannelPreviewSnapshot: Equatable {
20
+ case missing
21
+ case invalidated
22
+ case snapshot(String?)
23
+ case unreadable
24
+ }
25
+
19
26
  lazy var logger: Logger = {
20
27
  // Default to true for OS logging. In test environments without a bridge,
21
28
  // this will default to true. In production, it reads from config.
@@ -85,7 +92,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
85
92
  CAPPluginMethod(name: "completeFlexibleUpdate", returnType: CAPPluginReturnPromise)
86
93
  ]
87
94
  public var implementation = CapgoUpdater()
88
- private let pluginVersion: String = "8.51.1"
95
+ private let pluginVersion: String = "8.51.2"
89
96
  private let launchStartedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
90
97
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
91
98
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
@@ -124,6 +131,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
124
131
  private let previewSourceDefaultsKey = "CapacitorUpdater.previewSource"
125
132
  private let previewSessionsDefaultsKey = "CapacitorUpdater.previewSessions"
126
133
  private let previewSessionAlertPendingDefaultsKey = "CapacitorUpdater.previewSessionAlertPending"
134
+ private let defaultChannelInstallMarkerDefaultsKey = "CapacitorUpdater.defaultChannelInstallMarkerCreated"
135
+ private let defaultChannelInstallMarkerFilename = "CapacitorUpdater.defaultChannelInstallMarker"
136
+ private let defaultChannelStateFilename = "CapacitorUpdater.defaultChannelState"
137
+ private let defaultChannelPreviewSnapshotFilename = "CapacitorUpdater.defaultChannelPreviewSnapshot"
127
138
  private let previewDeepLinkScheme = "capgo"
128
139
  private let previewDeepLinkRootComponent = "preview"
129
140
  private let previewDeepLinkChannelComponent = "channel"
@@ -163,6 +174,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
163
174
  private var autoDeleteFailed = false
164
175
  private var autoDeletePrevious = false
165
176
  var allowSetDefaultChannel = true
177
+ var persistDefaultChannelOnReinstall = true
166
178
  private var keepUrlPathAfterReload = false
167
179
  private var backgroundWork: DispatchWorkItem?
168
180
  private var taskRunning = false
@@ -175,9 +187,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
175
187
 
176
188
  // Lock to ensure cleanup completes before downloads start
177
189
  private let cleanupLock = NSLock()
190
+ private let defaultChannelStateLock = NSLock()
178
191
  private let cleanupGroup = DispatchGroup()
179
192
  private var cleanupComplete = false
180
193
  private var cleanupThread: Thread?
194
+ private var defaultChannelCleanupMustRetry = false
181
195
  private var persistCustomId = false
182
196
  private var persistModifyUrl = false
183
197
  private var allowManualBundleError = false
@@ -223,6 +237,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
223
237
  self.implementation.deviceID = DeviceIdHelper.getOrCreateDeviceId()
224
238
  persistCustomId = getConfig().getBoolean("persistCustomId", false)
225
239
  allowSetDefaultChannel = getConfig().getBoolean("allowSetDefaultChannel", true)
240
+ persistDefaultChannelOnReinstall = getConfig().getBoolean("persistDefaultChannelOnReinstall", true)
226
241
  if persistCustomId {
227
242
  let storedCustomId = UserDefaults.standard.string(forKey: customIdDefaultsKey) ?? ""
228
243
  if !storedCustomId.isEmpty {
@@ -339,12 +354,57 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
339
354
  }
340
355
  }
341
356
 
342
- // Load defaultChannel: first try from persistent storage (set via setChannel), then fall back to config
343
- if let storedDefaultChannel = UserDefaults.standard.object(forKey: defaultChannelDefaultsKey) as? String {
357
+ let nativeBuildVersionChanged = self.hasNativeBuildVersionChanged()
358
+ let defaultChannelPersistenceDisabled = !persistDefaultChannelOnReinstall
359
+ let restoredReinstall = defaultChannelPersistenceDisabled && self.isRestoredReinstall()
360
+ var installMarkerCanBePrepared = true
361
+ if shouldClearPersistedDefaultChannel(
362
+ nativeBuildVersionChanged: nativeBuildVersionChanged,
363
+ resetWhenUpdate: resetWhenUpdate,
364
+ restoredReinstall: restoredReinstall
365
+ ) {
366
+ installMarkerCanBePrepared = clearPersistedDefaultChannel()
367
+ if installMarkerCanBePrepared {
368
+ logger.info("Cleared persisted defaultChannel because reinstall persistence is disabled")
369
+ } else {
370
+ logger.warn("Cannot durably clear persisted defaultChannel")
371
+ self.defaultChannelCleanupMustRetry = true
372
+ self.invalidateDefaultChannelInstallMarker()
373
+ }
374
+ }
375
+ if defaultChannelPersistenceDisabled && installMarkerCanBePrepared {
376
+ self.prepareDefaultChannelInstallMarker()
377
+ }
378
+ if !previewSessionEnabled,
379
+ !defaultChannelCleanupMustRetry,
380
+ self.hasPendingDefaultChannelPreviewSnapshot(),
381
+ !self.restorePreviewPreviousDefaultChannel() {
382
+ logger.warn("Default channel preview restore remains pending")
383
+ }
384
+
385
+ let configDefaultChannel = getConfig().getString("defaultChannel", "")!
386
+ let storedDefaultChannel = UserDefaults.standard.string(forKey: defaultChannelDefaultsKey)
387
+ let stateFile = self.defaultChannelStateFile()
388
+ let state = stateFile.map { self.defaultChannelState(file: $0) }
389
+ if defaultChannelCleanupMustRetry {
390
+ implementation.defaultChannel = configDefaultChannel
391
+ logger.warn("Using configured defaultChannel until persisted cleanup can retry")
392
+ } else if let state = state, state.exists, state.isReadable {
393
+ self.reconcileDefaultChannelDefaults(with: state.channel)
394
+ implementation.defaultChannel = state.channel ?? configDefaultChannel
395
+ logger.info("Loaded persisted defaultChannel from local state")
396
+ } else if defaultChannelPersistenceDisabled, state?.exists == true {
397
+ implementation.defaultChannel = configDefaultChannel
398
+ logger.warn("Ignoring unreadable persisted defaultChannel while reinstall persistence is disabled")
399
+ } else if let storedDefaultChannel = storedDefaultChannel {
344
400
  implementation.defaultChannel = storedDefaultChannel
345
401
  logger.info("Loaded persisted defaultChannel from setChannel()")
346
402
  } else {
347
- implementation.defaultChannel = getConfig().getString("defaultChannel", "")!
403
+ implementation.defaultChannel = configDefaultChannel
404
+ }
405
+ if !defaultChannelCleanupMustRetry,
406
+ state?.exists != true || (!defaultChannelPersistenceDisabled && state?.isReadable != true) {
407
+ _ = self.persistDefaultChannelStateFromDefaults()
348
408
  }
349
409
  self.reportAppLaunchStart()
350
410
  self.implementation.autoReset()
@@ -355,7 +415,6 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
355
415
 
356
416
  // Check if app was recently installed/updated BEFORE cleanup updates the stored native build version.
357
417
  self.wasRecentlyInstalledOrUpdated = self.checkIfRecentlyInstalledOrUpdated()
358
- let nativeBuildVersionChanged = self.hasNativeBuildVersionChanged()
359
418
  if nativeBuildVersionChanged {
360
419
  self.clearPreviewSessionForNativeBuildChange()
361
420
  }
@@ -551,6 +610,327 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
551
610
  UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
552
611
  }
553
612
 
613
+ func shouldClearPersistedDefaultChannel(
614
+ nativeBuildVersionChanged: Bool,
615
+ resetWhenUpdate: Bool,
616
+ restoredReinstall: Bool
617
+ ) -> Bool {
618
+ !persistDefaultChannelOnReinstall && (restoredReinstall || (resetWhenUpdate && nativeBuildVersionChanged))
619
+ }
620
+
621
+ func clearPersistedDefaultChannel() -> Bool {
622
+ guard let stateFile = self.defaultChannelStateFile(),
623
+ let previewSnapshotFile = self.defaultChannelPreviewSnapshotFile() else {
624
+ return false
625
+ }
626
+ return self.clearPersistedDefaultChannel(
627
+ stateFile: stateFile,
628
+ previewSnapshotFile: previewSnapshotFile
629
+ )
630
+ }
631
+
632
+ func clearPersistedDefaultChannel(stateFile: URL, previewSnapshotFile: URL) -> Bool {
633
+ defaultChannelStateLock.lock()
634
+ defer { defaultChannelStateLock.unlock() }
635
+ do {
636
+ try self.persistDefaultChannelStateWithoutLock(channel: nil, file: stateFile)
637
+ try self.persistDefaultChannelPreviewSnapshotWithoutLock(
638
+ channel: nil,
639
+ isValid: false,
640
+ file: previewSnapshotFile
641
+ )
642
+ UserDefaults.standard.removeObject(forKey: defaultChannelDefaultsKey)
643
+ UserDefaults.standard.removeObject(forKey: previewPreviousDefaultChannelDefaultsKey)
644
+ UserDefaults.standard.removeObject(forKey: previewPreviousDefaultChannelWasSetDefaultsKey)
645
+ return true
646
+ } catch {
647
+ logger.warn("Cannot persist cleared default channel state: \(error.localizedDescription)")
648
+ return false
649
+ }
650
+ }
651
+
652
+ private func defaultChannelStateFile() -> URL? {
653
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
654
+ .appendingPathComponent(defaultChannelStateFilename)
655
+ }
656
+
657
+ private func defaultChannelPreviewSnapshotFile() -> URL? {
658
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
659
+ .appendingPathComponent(defaultChannelPreviewSnapshotFilename)
660
+ }
661
+
662
+ func defaultChannelState(file: URL) -> (exists: Bool, channel: String?, isReadable: Bool) {
663
+ defaultChannelStateLock.lock()
664
+ defer { defaultChannelStateLock.unlock() }
665
+ return self.defaultChannelStateWithoutLock(file: file)
666
+ }
667
+
668
+ private func defaultChannelStateWithoutLock(file: URL) -> (exists: Bool, channel: String?, isReadable: Bool) {
669
+ let fileManager = FileManager.default
670
+ guard fileManager.fileExists(atPath: file.path) else {
671
+ return (false, nil, true)
672
+ }
673
+ do {
674
+ let data = try Data(contentsOf: file)
675
+ guard !data.isEmpty else {
676
+ return (true, nil, true)
677
+ }
678
+ guard let channel = String(data: data, encoding: .utf8) else {
679
+ logger.warn("Cannot decode persisted default channel state")
680
+ return (true, nil, false)
681
+ }
682
+ return (true, channel, true)
683
+ } catch {
684
+ logger.warn("Cannot read persisted default channel state: \(error.localizedDescription)")
685
+ return (true, nil, false)
686
+ }
687
+ }
688
+
689
+ func persistDefaultChannelState(channel: String?, file: URL) throws {
690
+ defaultChannelStateLock.lock()
691
+ defer { defaultChannelStateLock.unlock() }
692
+ try self.persistDefaultChannelStateWithoutLock(channel: channel, file: file)
693
+ }
694
+
695
+ private func persistDefaultChannelStateWithoutLock(channel: String?, file: URL) throws {
696
+ let data = channel.map { Data($0.utf8) } ?? Data()
697
+ try self.persistBackupExcludedFile(file: file, data: data, overwrite: true)
698
+ }
699
+
700
+ func defaultChannelPreviewSnapshot(file: URL) -> DefaultChannelPreviewSnapshot {
701
+ defaultChannelStateLock.lock()
702
+ defer { defaultChannelStateLock.unlock() }
703
+ return self.defaultChannelPreviewSnapshotWithoutLock(file: file)
704
+ }
705
+
706
+ private func defaultChannelPreviewSnapshotWithoutLock(file: URL) -> DefaultChannelPreviewSnapshot {
707
+ guard FileManager.default.fileExists(atPath: file.path) else {
708
+ return .missing
709
+ }
710
+ do {
711
+ let data = try Data(contentsOf: file)
712
+ guard let kind = data.first else {
713
+ return .unreadable
714
+ }
715
+ switch kind {
716
+ case 0 where data.count == 1:
717
+ return .invalidated
718
+ case 1 where data.count == 1:
719
+ return .snapshot(nil)
720
+ case 2:
721
+ guard let channel = String(data: Data(data.dropFirst()), encoding: .utf8) else {
722
+ return .unreadable
723
+ }
724
+ return .snapshot(channel)
725
+ default:
726
+ return .unreadable
727
+ }
728
+ } catch {
729
+ logger.warn("Cannot read default channel preview snapshot: \(error.localizedDescription)")
730
+ return .unreadable
731
+ }
732
+ }
733
+
734
+ func persistDefaultChannelPreviewSnapshot(channel: String?, file: URL) throws {
735
+ defaultChannelStateLock.lock()
736
+ defer { defaultChannelStateLock.unlock() }
737
+ try self.persistDefaultChannelPreviewSnapshotWithoutLock(
738
+ channel: channel,
739
+ isValid: true,
740
+ file: file
741
+ )
742
+ }
743
+
744
+ func invalidateDefaultChannelPreviewSnapshot(file: URL) throws {
745
+ defaultChannelStateLock.lock()
746
+ defer { defaultChannelStateLock.unlock() }
747
+ try self.persistDefaultChannelPreviewSnapshotWithoutLock(
748
+ channel: nil,
749
+ isValid: false,
750
+ file: file
751
+ )
752
+ }
753
+
754
+ private func persistDefaultChannelPreviewSnapshotWithoutLock(
755
+ channel: String?,
756
+ isValid: Bool,
757
+ file: URL
758
+ ) throws {
759
+ var data = Data()
760
+ if !isValid {
761
+ data.append(0)
762
+ } else if let channel = channel {
763
+ data.append(2)
764
+ data.append(contentsOf: channel.utf8)
765
+ } else {
766
+ data.append(1)
767
+ }
768
+ try self.persistBackupExcludedFile(file: file, data: data, overwrite: true)
769
+ }
770
+
771
+ private func persistDefaultChannelPreviewSnapshot(channel: String?) -> Bool {
772
+ guard let file = self.defaultChannelPreviewSnapshotFile() else {
773
+ return false
774
+ }
775
+ do {
776
+ try self.persistDefaultChannelPreviewSnapshot(channel: channel, file: file)
777
+ return true
778
+ } catch {
779
+ logger.warn("Cannot persist default channel preview snapshot: \(error.localizedDescription)")
780
+ return false
781
+ }
782
+ }
783
+
784
+ private func hasPendingDefaultChannelPreviewSnapshot() -> Bool {
785
+ guard let file = self.defaultChannelPreviewSnapshotFile() else {
786
+ return false
787
+ }
788
+ switch self.defaultChannelPreviewSnapshot(file: file) {
789
+ case .snapshot, .unreadable:
790
+ return true
791
+ case .missing, .invalidated:
792
+ return false
793
+ }
794
+ }
795
+
796
+ @discardableResult
797
+ func persistDefaultChannelStateFromDefaults() -> Bool {
798
+ guard let file = self.defaultChannelStateFile() else {
799
+ logger.warn("Cannot locate default channel state file")
800
+ return false
801
+ }
802
+ return self.persistDefaultChannelStateFromDefaults(stateFile: file)
803
+ }
804
+
805
+ @discardableResult
806
+ func persistDefaultChannelStateFromDefaults(stateFile: URL) -> Bool {
807
+ defaultChannelStateLock.lock()
808
+ defer { defaultChannelStateLock.unlock() }
809
+ do {
810
+ try self.persistDefaultChannelStateWithoutLock(
811
+ channel: UserDefaults.standard.string(forKey: defaultChannelDefaultsKey),
812
+ file: stateFile
813
+ )
814
+ return true
815
+ } catch {
816
+ do {
817
+ if FileManager.default.fileExists(atPath: stateFile.path) {
818
+ try FileManager.default.removeItem(at: stateFile)
819
+ }
820
+ logger.warn("Cannot persist default channel state; falling back to UserDefaults: \(error.localizedDescription)")
821
+ return true
822
+ } catch let invalidationError {
823
+ logger.warn("Cannot persist or invalidate default channel state: \(invalidationError.localizedDescription)")
824
+ return false
825
+ }
826
+ }
827
+ }
828
+
829
+ private func persistedDefaultChannel() -> String? {
830
+ guard let file = self.defaultChannelStateFile() else {
831
+ return UserDefaults.standard.string(forKey: defaultChannelDefaultsKey)
832
+ }
833
+ return self.persistedDefaultChannel(stateFile: file)
834
+ }
835
+
836
+ func persistedDefaultChannel(stateFile: URL) -> String? {
837
+ let state = self.defaultChannelState(file: stateFile)
838
+ if state.exists {
839
+ if state.isReadable {
840
+ return state.channel
841
+ }
842
+ if !persistDefaultChannelOnReinstall {
843
+ return nil
844
+ }
845
+ }
846
+ return UserDefaults.standard.string(forKey: defaultChannelDefaultsKey)
847
+ }
848
+
849
+ private func reconcileDefaultChannelDefaults(with channel: String?) {
850
+ if let channel = channel {
851
+ UserDefaults.standard.set(channel, forKey: defaultChannelDefaultsKey)
852
+ } else {
853
+ UserDefaults.standard.removeObject(forKey: defaultChannelDefaultsKey)
854
+ }
855
+ }
856
+
857
+ private func defaultChannelInstallMarker() -> URL? {
858
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
859
+ .appendingPathComponent(defaultChannelInstallMarkerFilename)
860
+ }
861
+
862
+ private func invalidateDefaultChannelInstallMarker() {
863
+ guard let marker = self.defaultChannelInstallMarker() else {
864
+ return
865
+ }
866
+ do {
867
+ try self.invalidateDefaultChannelInstallMarker(marker: marker)
868
+ } catch {
869
+ logger.warn("Cannot invalidate default channel install marker for cleanup retry: \(error.localizedDescription)")
870
+ }
871
+ }
872
+
873
+ func invalidateDefaultChannelInstallMarker(marker: URL) throws {
874
+ let fileManager = FileManager.default
875
+ if fileManager.fileExists(atPath: marker.path) {
876
+ try fileManager.removeItem(at: marker)
877
+ }
878
+ }
879
+
880
+ func isRestoredReinstall() -> Bool {
881
+ guard let marker = self.defaultChannelInstallMarker() else {
882
+ return false
883
+ }
884
+ return self.isRestoredReinstall(
885
+ marker: marker,
886
+ markerWasCreated: UserDefaults.standard.bool(forKey: defaultChannelInstallMarkerDefaultsKey)
887
+ )
888
+ }
889
+
890
+ func isRestoredReinstall(marker: URL, markerWasCreated: Bool) -> Bool {
891
+ markerWasCreated && !FileManager.default.fileExists(atPath: marker.path)
892
+ }
893
+
894
+ private func prepareDefaultChannelInstallMarker() {
895
+ guard let marker = self.defaultChannelInstallMarker() else {
896
+ return
897
+ }
898
+ self.prepareDefaultChannelInstallMarker(
899
+ marker: marker,
900
+ markerWasCreated: UserDefaults.standard.bool(forKey: defaultChannelInstallMarkerDefaultsKey)
901
+ )
902
+ }
903
+
904
+ func prepareDefaultChannelInstallMarker(marker: URL, markerWasCreated: Bool) {
905
+ do {
906
+ try self.persistBackupExcludedFile(file: marker, data: Data())
907
+ if !markerWasCreated {
908
+ UserDefaults.standard.set(true, forKey: defaultChannelInstallMarkerDefaultsKey)
909
+ UserDefaults.standard.synchronize()
910
+ }
911
+ } catch {
912
+ try? FileManager.default.removeItem(at: marker)
913
+ UserDefaults.standard.set(false, forKey: defaultChannelInstallMarkerDefaultsKey)
914
+ UserDefaults.standard.synchronize()
915
+ logger.warn("Cannot prepare default channel install marker: \(error.localizedDescription)")
916
+ }
917
+ }
918
+
919
+ private func persistBackupExcludedFile(file: URL, data: Data, overwrite: Bool = false) throws {
920
+ let fileManager = FileManager.default
921
+ if overwrite || !fileManager.fileExists(atPath: file.path) {
922
+ try fileManager.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
923
+ try data.write(to: file, options: .atomic)
924
+ }
925
+ let currentResourceValues = try file.resourceValues(forKeys: [.isExcludedFromBackupKey])
926
+ if currentResourceValues.isExcludedFromBackup != true {
927
+ var resourceValues = URLResourceValues()
928
+ resourceValues.isExcludedFromBackup = true
929
+ var mutableFile = file
930
+ try mutableFile.setResourceValues(resourceValues)
931
+ }
932
+ }
933
+
554
934
  func hasNativeBuildVersionChanged() -> Bool {
555
935
  let previous = self.storedNativeBuildVersion()
556
936
  return previous != "0" && self.currentBuildVersion != previous
@@ -730,8 +1110,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
730
1110
  self.implementation.cleanupDownloadDirectories(allowedIds: allowedIds)
731
1111
  self.implementation.cleanupOrphanedTempFolders(threadToCheck: nil)
732
1112
 
733
- UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
734
- UserDefaults.standard.synchronize()
1113
+ if self.defaultChannelCleanupMustRetry {
1114
+ self.logger.warn("Keeping the previous native build version so default channel cleanup retries")
1115
+ } else {
1116
+ UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
1117
+ UserDefaults.standard.synchronize()
1118
+ }
735
1119
  }
736
1120
  cleanupThread?.start()
737
1121
  }
@@ -1517,8 +1901,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1517
1901
  UserDefaults.standard.removeObject(forKey: self.previewPreviousNextBundleDefaultsKey)
1518
1902
  }
1519
1903
 
1904
+ let previousDefaultChannel = self.persistedDefaultChannel()
1905
+ guard self.persistDefaultChannelPreviewSnapshot(channel: previousDefaultChannel) else {
1906
+ logger.error("Could not durably save the default channel preview snapshot")
1907
+ return false
1908
+ }
1520
1909
  UserDefaults.standard.set(self.implementation.appId, forKey: self.previewPreviousAppIdDefaultsKey)
1521
- if let previousDefaultChannel = UserDefaults.standard.object(forKey: self.defaultChannelDefaultsKey) as? String {
1910
+ if let previousDefaultChannel = previousDefaultChannel {
1522
1911
  UserDefaults.standard.set(previousDefaultChannel, forKey: self.previewPreviousDefaultChannelDefaultsKey)
1523
1912
  UserDefaults.standard.set(true, forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
1524
1913
  } else {
@@ -1966,7 +2355,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1966
2355
  ?? self.getBooleanConfig("allowShakeChannelSelector", defaultValue: false)
1967
2356
  self.restorePreviewPreviousNextBundle()
1968
2357
  self.restorePreviewPreviousAppId()
1969
- self.restorePreviewPreviousDefaultChannel()
2358
+ if !self.restorePreviewPreviousDefaultChannel() {
2359
+ logger.warn("Default channel preview restore will retry on next launch")
2360
+ }
1970
2361
 
1971
2362
  self.previewSessionEnabled = false
1972
2363
  self.previewSessionAlertPending = false
@@ -1993,7 +2384,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1993
2384
 
1994
2385
  self.restorePreviewPreviousNextBundle()
1995
2386
  self.restorePreviewPreviousAppId()
1996
- self.restorePreviewPreviousDefaultChannel()
2387
+ if !self.restorePreviewPreviousDefaultChannel() {
2388
+ logger.warn("Default channel preview restore will retry on next launch")
2389
+ }
1997
2390
  self.previewSessionEnabled = false
1998
2391
  self.previewSessionAlertPending = false
1999
2392
  self.isLeavingPreviewForIncomingLink = false
@@ -2021,14 +2414,17 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2021
2414
  }
2022
2415
 
2023
2416
  private func clearPreviewSessionPreferences() {
2417
+ let keepDefaultChannelSnapshotFallback = self.hasPendingDefaultChannelPreviewSnapshot()
2024
2418
  _ = self.implementation.setPreviewFallbackBundle(fallback: nil)
2025
2419
  UserDefaults.standard.removeObject(forKey: self.previewSessionDefaultsKey)
2026
2420
  UserDefaults.standard.removeObject(forKey: self.previewPreviousShakeMenuDefaultsKey)
2027
2421
  UserDefaults.standard.removeObject(forKey: self.previewPreviousShakeChannelSelectorDefaultsKey)
2028
2422
  UserDefaults.standard.removeObject(forKey: self.previewPreviousNextBundleDefaultsKey)
2029
2423
  UserDefaults.standard.removeObject(forKey: self.previewPreviousAppIdDefaultsKey)
2030
- UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelDefaultsKey)
2031
- UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
2424
+ if !keepDefaultChannelSnapshotFallback {
2425
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelDefaultsKey)
2426
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
2427
+ }
2032
2428
  UserDefaults.standard.removeObject(forKey: self.previewAppIdDefaultsKey)
2033
2429
  UserDefaults.standard.removeObject(forKey: self.previewPayloadUrlDefaultsKey)
2034
2430
  UserDefaults.standard.removeObject(forKey: self.previewNameDefaultsKey)
@@ -2046,21 +2442,95 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2046
2442
  logger.info("Restored appId after preview: \(previousAppId)")
2047
2443
  }
2048
2444
 
2049
- private func restorePreviewPreviousDefaultChannel() {
2050
- let configDefaultChannel = self.getStringConfig("defaultChannel", defaultValue: "")
2051
- let hadPreviousDefaultChannel = UserDefaults.standard.object(forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey) as? Bool ?? false
2445
+ private func restorePreviewPreviousDefaultChannel() -> Bool {
2446
+ guard !defaultChannelCleanupMustRetry else {
2447
+ logger.warn("Skipping default channel preview restore until cleanup retries")
2448
+ return false
2449
+ }
2450
+ guard let stateFile = self.defaultChannelStateFile(),
2451
+ let previewSnapshotFile = self.defaultChannelPreviewSnapshotFile() else {
2452
+ return false
2453
+ }
2052
2454
 
2053
- guard hadPreviousDefaultChannel,
2054
- let previousDefaultChannel = UserDefaults.standard.string(forKey: self.previewPreviousDefaultChannelDefaultsKey) else {
2455
+ let previousDefaultChannel: String?
2456
+ switch self.defaultChannelPreviewSnapshot(file: previewSnapshotFile) {
2457
+ case .invalidated:
2458
+ return true
2459
+ case let .snapshot(channel):
2460
+ previousDefaultChannel = channel
2461
+ case .missing:
2462
+ let hadPreviousDefaultChannel = UserDefaults.standard.object(
2463
+ forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey
2464
+ ) as? Bool ?? false
2465
+ previousDefaultChannel = hadPreviousDefaultChannel
2466
+ ? UserDefaults.standard.string(forKey: self.previewPreviousDefaultChannelDefaultsKey)
2467
+ : nil
2468
+ do {
2469
+ try self.persistDefaultChannelPreviewSnapshot(
2470
+ channel: previousDefaultChannel,
2471
+ file: previewSnapshotFile
2472
+ )
2473
+ } catch {
2474
+ logger.warn("Cannot migrate default channel preview snapshot: \(error.localizedDescription)")
2475
+ return false
2476
+ }
2477
+ case .unreadable:
2478
+ logger.warn("Cannot restore default channel from an unreadable preview snapshot")
2479
+ return false
2480
+ }
2481
+
2482
+ return self.persistRestoredDefaultChannelFromPreview(
2483
+ channel: previousDefaultChannel,
2484
+ stateFile: stateFile,
2485
+ previewSnapshotFile: previewSnapshotFile
2486
+ )
2487
+ }
2488
+
2489
+ private func applyRestoredDefaultChannelFromPreview(_ channel: String?) {
2490
+ let configDefaultChannel = self.getStringConfig("defaultChannel", defaultValue: "")
2491
+ if let channel = channel {
2492
+ UserDefaults.standard.set(channel, forKey: self.defaultChannelDefaultsKey)
2493
+ self.implementation.defaultChannel = channel
2494
+ logger.info("Restored defaultChannel after preview")
2495
+ } else {
2055
2496
  UserDefaults.standard.removeObject(forKey: self.defaultChannelDefaultsKey)
2056
2497
  self.implementation.defaultChannel = configDefaultChannel
2057
2498
  logger.info("Restored defaultChannel after preview to config value")
2058
- return
2059
2499
  }
2500
+ }
2060
2501
 
2061
- UserDefaults.standard.set(previousDefaultChannel, forKey: self.defaultChannelDefaultsKey)
2062
- self.implementation.defaultChannel = previousDefaultChannel
2063
- logger.info("Restored defaultChannel after preview")
2502
+ private func persistRestoredDefaultChannelFromPreview(
2503
+ channel: String?,
2504
+ stateFile: URL,
2505
+ previewSnapshotFile: URL
2506
+ ) -> Bool {
2507
+ defaultChannelStateLock.lock()
2508
+ defer { defaultChannelStateLock.unlock() }
2509
+ let didPersist: Bool
2510
+ do {
2511
+ try self.persistDefaultChannelStateWithoutLock(channel: channel, file: stateFile)
2512
+ try self.persistDefaultChannelPreviewSnapshotWithoutLock(
2513
+ channel: nil,
2514
+ isValid: false,
2515
+ file: previewSnapshotFile
2516
+ )
2517
+ didPersist = true
2518
+ } catch {
2519
+ let persistedState = self.defaultChannelStateWithoutLock(file: stateFile)
2520
+ let persistedSnapshot = self.defaultChannelPreviewSnapshotWithoutLock(file: previewSnapshotFile)
2521
+ if persistedState.exists,
2522
+ persistedState.isReadable,
2523
+ persistedState.channel == channel,
2524
+ persistedSnapshot == .invalidated {
2525
+ logger.warn("Default channel preview restore committed before metadata update failed")
2526
+ didPersist = true
2527
+ } else {
2528
+ logger.warn("Cannot durably restore default channel after preview: \(error.localizedDescription)")
2529
+ didPersist = false
2530
+ }
2531
+ }
2532
+ self.applyRestoredDefaultChannelFromPreview(channel)
2533
+ return didPersist
2064
2534
  }
2065
2535
 
2066
2536
  private func normalizedPreviewAppId(_ rawAppId: String?) -> String? {
@@ -2240,7 +2710,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2240
2710
  self.shakeMenuGesture = Self.normalizedShakeMenuGesture(self.getStringConfig("shakeMenuGesture", defaultValue: Self.shakeMenuGestureShake))
2241
2711
  self.syncShakeMenuGestureRecognizer()
2242
2712
  self.restorePreviewPreviousAppId()
2243
- self.restorePreviewPreviousDefaultChannel()
2713
+ if !self.restorePreviewPreviousDefaultChannel() {
2714
+ logger.warn("Default channel preview restore will retry on next launch")
2715
+ }
2244
2716
  _ = self.implementation.setPreviewFallbackBundle(fallback: nil)
2245
2717
  _ = self.implementation.setNextBundle(next: Optional<String>.none)
2246
2718
  self.clearPreviewSessionPreferences()
@@ -2485,6 +2957,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2485
2957
  "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
2486
2958
  ])
2487
2959
  } else {
2960
+ guard self.persistDefaultChannelStateFromDefaults() else {
2961
+ self.rejectCall(call, message: "Channel override removed but local persistence failed", code: "UNSETCHANNEL_PERSISTENCE_FAILED")
2962
+ return
2963
+ }
2488
2964
  if self._isAutoUpdateEnabled() && triggerAutoUpdate {
2489
2965
  self.logger.info("Calling autoupdater after channel change!")
2490
2966
  // Check if download is already in progress (with timeout protection)
@@ -2531,6 +3007,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2531
3007
  "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"
2532
3008
  ])
2533
3009
  } else {
3010
+ guard self.persistDefaultChannelStateFromDefaults() else {
3011
+ self.rejectCall(call, message: "Channel changed but local persistence failed", code: "SETCHANNEL_PERSISTENCE_FAILED")
3012
+ return
3013
+ }
2534
3014
  if self._isAutoUpdateEnabled() && triggerAutoUpdate {
2535
3015
  self.logger.info("Calling autoupdater after channel change!")
2536
3016
  // Check if download is already in progress (with timeout protection)
@@ -2555,6 +3035,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2555
3035
  "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
2556
3036
  ])
2557
3037
  } else {
3038
+ guard self.persistDefaultChannelStateFromDefaults() else {
3039
+ self.rejectCall(call, message: "Channel synchronized but local persistence failed", code: "GETCHANNEL_PERSISTENCE_FAILED")
3040
+ return
3041
+ }
2558
3042
  self.resolveCall(call, data: res.toDict())
2559
3043
  }
2560
3044
  }