@capgo/capacitor-updater 8.51.0 → 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.0"
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,8 +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()
191
+ private let cleanupGroup = DispatchGroup()
178
192
  private var cleanupComplete = false
179
193
  private var cleanupThread: Thread?
194
+ private var defaultChannelCleanupMustRetry = false
180
195
  private var persistCustomId = false
181
196
  private var persistModifyUrl = false
182
197
  private var allowManualBundleError = false
@@ -194,6 +209,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
194
209
  private var isLeavingPreviewForIncomingLink = false
195
210
  private var previewTransitionClearWorkItem: DispatchWorkItem?
196
211
  let semaphoreReady = DispatchSemaphore(value: 0)
212
+ // Best-effort flag: set before we expect notifyAppReady (load/reload).
213
+ // No lock — never held across waits; a rare race only mis-times one wait.
214
+ private var pendingNotifyAppReady = false
197
215
 
198
216
  private var delayUpdateUtils: DelayUpdateUtils!
199
217
 
@@ -219,6 +237,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
219
237
  self.implementation.deviceID = DeviceIdHelper.getOrCreateDeviceId()
220
238
  persistCustomId = getConfig().getBoolean("persistCustomId", false)
221
239
  allowSetDefaultChannel = getConfig().getBoolean("allowSetDefaultChannel", true)
240
+ persistDefaultChannelOnReinstall = getConfig().getBoolean("persistDefaultChannelOnReinstall", true)
222
241
  if persistCustomId {
223
242
  let storedCustomId = UserDefaults.standard.string(forKey: customIdDefaultsKey) ?? ""
224
243
  if !storedCustomId.isEmpty {
@@ -335,12 +354,57 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
335
354
  }
336
355
  }
337
356
 
338
- // Load defaultChannel: first try from persistent storage (set via setChannel), then fall back to config
339
- 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 {
340
400
  implementation.defaultChannel = storedDefaultChannel
341
401
  logger.info("Loaded persisted defaultChannel from setChannel()")
342
402
  } else {
343
- implementation.defaultChannel = getConfig().getString("defaultChannel", "")!
403
+ implementation.defaultChannel = configDefaultChannel
404
+ }
405
+ if !defaultChannelCleanupMustRetry,
406
+ state?.exists != true || (!defaultChannelPersistenceDisabled && state?.isReadable != true) {
407
+ _ = self.persistDefaultChannelStateFromDefaults()
344
408
  }
345
409
  self.reportAppLaunchStart()
346
410
  self.implementation.autoReset()
@@ -351,16 +415,26 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
351
415
 
352
416
  // Check if app was recently installed/updated BEFORE cleanup updates the stored native build version.
353
417
  self.wasRecentlyInstalledOrUpdated = self.checkIfRecentlyInstalledOrUpdated()
354
- let nativeBuildVersionChanged = self.hasNativeBuildVersionChanged()
355
418
  if nativeBuildVersionChanged {
356
419
  self.clearPreviewSessionForNativeBuildChange()
357
420
  }
358
421
  self.leavePreviewSessionForLaunchURLIfNeeded()
359
422
 
360
- if resetWhenUpdate {
361
- let didResetCurrentBundle = self.resetCurrentBundleForNativeBuildChangeIfNeeded()
362
- self.cleanupObsoleteVersions(didResetCurrentBundle: didResetCurrentBundle)
423
+ // Downloads (including shake-menu / CapgoUpdater entry points) wait on this gate.
424
+ self.implementation.beforeDownload = { [weak self] in
425
+ self?.waitForCleanupIfNeeded()
363
426
  }
427
+ // Always run async cleanup: delete obsolete bundles on native update (when enabled)
428
+ // and sweep orphan directories every launch. Must not block app startup.
429
+ if !resetWhenUpdate {
430
+ UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
431
+ UserDefaults.standard.synchronize()
432
+ }
433
+ let didResetCurrentBundle = resetWhenUpdate ? self.resetCurrentBundleForNativeBuildChangeIfNeeded() : false
434
+ self.cleanupObsoleteVersions(
435
+ resetWhenUpdate: resetWhenUpdate,
436
+ didResetCurrentBundle: didResetCurrentBundle
437
+ )
364
438
  self.reportNativeVersionStatsIfChanged()
365
439
 
366
440
  // Load the server
@@ -522,6 +596,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
522
596
  }
523
597
 
524
598
  private func semaphoreUp() {
599
+ pendingNotifyAppReady = true
525
600
  DispatchQueue.global().async {
526
601
  self.semaphoreWait(waitTime: 0)
527
602
  }
@@ -535,6 +610,327 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
535
610
  UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
536
611
  }
537
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
+
538
934
  func hasNativeBuildVersionChanged() -> Bool {
539
935
  let previous = self.storedNativeBuildVersion()
540
936
  return previous != "0" && self.currentBuildVersion != previous
@@ -633,12 +1029,30 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
633
1029
  return true
634
1030
  }
635
1031
 
636
- private func cleanupObsoleteVersions(didResetCurrentBundle: Bool = false) {
1032
+ private func cleanupObsoleteVersions(resetWhenUpdate: Bool = true, didResetCurrentBundle: Bool = false) {
1033
+ // Enter before start so waiters never race past an unstarted cleanup thread.
1034
+ self.cleanupComplete = false
1035
+ self.cleanupGroup.enter()
637
1036
  cleanupThread = Thread {
1037
+ let bgTaskLock = NSLock()
1038
+ var cleanupBackgroundTask = UIBackgroundTaskIdentifier.invalid
1039
+ let endCleanupBackgroundTask = {
1040
+ bgTaskLock.lock()
1041
+ defer { bgTaskLock.unlock() }
1042
+ if cleanupBackgroundTask != .invalid {
1043
+ UIApplication.shared.endBackgroundTask(cleanupBackgroundTask)
1044
+ cleanupBackgroundTask = .invalid
1045
+ }
1046
+ }
1047
+ cleanupBackgroundTask = UIApplication.shared.beginBackgroundTask(withName: "CapgoBundleCleanup") {
1048
+ endCleanupBackgroundTask()
1049
+ }
638
1050
  self.cleanupLock.lock()
639
1051
  defer {
640
1052
  self.cleanupComplete = true
641
1053
  self.cleanupLock.unlock()
1054
+ self.cleanupGroup.leave()
1055
+ endCleanupBackgroundTask()
642
1056
  self.logger.info("Cleanup complete")
643
1057
  }
644
1058
 
@@ -669,54 +1083,41 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
669
1083
  // 2. Compare both keys. If any is not equal to "currentBuildVersion", then revert to builtin version. This fixes the part 2 of this bug
670
1084
 
671
1085
  let previous = self.storedNativeBuildVersion()
672
- if previous != "0" && self.currentBuildVersion != previous {
1086
+ let nativeVersionChanged = previous != "0" && self.currentBuildVersion != previous
1087
+ if resetWhenUpdate && nativeVersionChanged {
673
1088
  if !didResetCurrentBundle {
674
1089
  self.logger.info("Native build version changed from \(previous) to \(self.currentBuildVersion). Resetting current bundle to builtin.")
675
1090
  self.implementation.reset(isInternal: true)
676
1091
  }
677
1092
  let res = self.implementation.list()
678
1093
  for version in res {
679
- // Check if thread was cancelled
680
- if Thread.current.isCancelled {
681
- self.logger.warn("Cleanup was cancelled, stopping")
682
- return
683
- }
684
1094
  self.logger.info("Deleting obsolete bundle: \(version.getId())")
685
- let res = self.implementation.delete(id: version.getId())
686
- if !res {
1095
+ let deleted = self.implementation.delete(id: version.getId())
1096
+ if !deleted {
687
1097
  self.logger.error("Delete failed, id \(version.getId()) doesn't exist")
688
1098
  }
1099
+ Thread.sleep(forTimeInterval: 0.075)
689
1100
  }
690
-
691
- let storedBundles = self.implementation.list(raw: true)
692
- let allowedIds = Set(storedBundles.compactMap { info -> String? in
693
- let id = info.getId()
694
- return id.isEmpty ? nil : id
695
- })
696
- self.implementation.cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: Thread.current)
697
- self.implementation.cleanupOrphanedTempFolders(threadToCheck: Thread.current)
698
-
699
- // Check again before the expensive delta cache cleanup
700
- if Thread.current.isCancelled {
701
- self.logger.warn("Cleanup was cancelled before delta cache cleanup")
702
- return
703
- }
704
- self.implementation.cleanupDeltaCache(threadToCheck: Thread.current)
1101
+ self.implementation.cleanupDeltaCache()
705
1102
  }
706
- UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
707
- UserDefaults.standard.synchronize()
708
- }
709
- cleanupThread?.start()
710
1103
 
711
- // Start a timeout watchdog thread to cancel cleanup if it takes too long
712
- let timeout = Double(self.appReadyTimeout / 2) / 1000.0
713
- Thread.detachNewThread {
714
- Thread.sleep(forTimeInterval: timeout)
715
- if let thread = self.cleanupThread, !thread.isFinished && !self.cleanupComplete {
716
- self.logger.warn("Cleanup timeout exceeded (\(timeout)s), cancelling cleanup thread")
717
- thread.cancel()
1104
+ // Resume any DELETING leftovers from prior kills, one-by-one.
1105
+ self.implementation.drainPendingDeletes()
1106
+
1107
+ // Always sweep orphan directories so incomplete prior cleanups (or failed deletes)
1108
+ // cannot leave hundreds of MB behind across launches.
1109
+ let allowedIds = self.implementation.allowedBundleIdsForCleanup()
1110
+ self.implementation.cleanupDownloadDirectories(allowedIds: allowedIds)
1111
+ self.implementation.cleanupOrphanedTempFolders(threadToCheck: nil)
1112
+
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()
718
1118
  }
719
1119
  }
1120
+ cleanupThread?.start()
720
1121
  }
721
1122
 
722
1123
  private func waitForCleanupIfNeeded() {
@@ -725,11 +1126,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
725
1126
  }
726
1127
 
727
1128
  logger.info("Waiting for cleanup to complete before starting download...")
728
-
729
- // Wait for cleanup to complete - blocks until lock is released
730
- cleanupLock.lock()
731
- cleanupLock.unlock()
732
-
1129
+ cleanupGroup.wait()
733
1130
  logger.info("Cleanup finished, proceeding with download")
734
1131
  }
735
1132
 
@@ -1146,6 +1543,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1146
1543
  }
1147
1544
 
1148
1545
  private func downloadBundle(urlString: String, version: String, sessionKey: String, checksum rawChecksum: String, manifestEntries: [ManifestEntry]?) throws -> BundleInfo {
1546
+ // Manual/preview downloads must wait too - launch orphan sweep can delete their temps.
1547
+ self.waitForCleanupIfNeeded()
1149
1548
  guard let url = URL(string: urlString) else {
1150
1549
  throw makePreviewError("Invalid download URL")
1151
1550
  }
@@ -1297,6 +1696,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1297
1696
 
1298
1697
  let performReload: () -> Bool = {
1299
1698
  guard self.applyCurrentBundleToBridge(bridge) else {
1699
+ self.clearPendingNotifyAppReady()
1300
1700
  return false
1301
1701
  }
1302
1702
  self.checkAppReady()
@@ -1501,8 +1901,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1501
1901
  UserDefaults.standard.removeObject(forKey: self.previewPreviousNextBundleDefaultsKey)
1502
1902
  }
1503
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
+ }
1504
1909
  UserDefaults.standard.set(self.implementation.appId, forKey: self.previewPreviousAppIdDefaultsKey)
1505
- if let previousDefaultChannel = UserDefaults.standard.object(forKey: self.defaultChannelDefaultsKey) as? String {
1910
+ if let previousDefaultChannel = previousDefaultChannel {
1506
1911
  UserDefaults.standard.set(previousDefaultChannel, forKey: self.previewPreviousDefaultChannelDefaultsKey)
1507
1912
  UserDefaults.standard.set(true, forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
1508
1913
  } else {
@@ -1950,7 +2355,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1950
2355
  ?? self.getBooleanConfig("allowShakeChannelSelector", defaultValue: false)
1951
2356
  self.restorePreviewPreviousNextBundle()
1952
2357
  self.restorePreviewPreviousAppId()
1953
- self.restorePreviewPreviousDefaultChannel()
2358
+ if !self.restorePreviewPreviousDefaultChannel() {
2359
+ logger.warn("Default channel preview restore will retry on next launch")
2360
+ }
1954
2361
 
1955
2362
  self.previewSessionEnabled = false
1956
2363
  self.previewSessionAlertPending = false
@@ -1977,7 +2384,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1977
2384
 
1978
2385
  self.restorePreviewPreviousNextBundle()
1979
2386
  self.restorePreviewPreviousAppId()
1980
- self.restorePreviewPreviousDefaultChannel()
2387
+ if !self.restorePreviewPreviousDefaultChannel() {
2388
+ logger.warn("Default channel preview restore will retry on next launch")
2389
+ }
1981
2390
  self.previewSessionEnabled = false
1982
2391
  self.previewSessionAlertPending = false
1983
2392
  self.isLeavingPreviewForIncomingLink = false
@@ -2005,14 +2414,17 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2005
2414
  }
2006
2415
 
2007
2416
  private func clearPreviewSessionPreferences() {
2417
+ let keepDefaultChannelSnapshotFallback = self.hasPendingDefaultChannelPreviewSnapshot()
2008
2418
  _ = self.implementation.setPreviewFallbackBundle(fallback: nil)
2009
2419
  UserDefaults.standard.removeObject(forKey: self.previewSessionDefaultsKey)
2010
2420
  UserDefaults.standard.removeObject(forKey: self.previewPreviousShakeMenuDefaultsKey)
2011
2421
  UserDefaults.standard.removeObject(forKey: self.previewPreviousShakeChannelSelectorDefaultsKey)
2012
2422
  UserDefaults.standard.removeObject(forKey: self.previewPreviousNextBundleDefaultsKey)
2013
2423
  UserDefaults.standard.removeObject(forKey: self.previewPreviousAppIdDefaultsKey)
2014
- UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelDefaultsKey)
2015
- UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
2424
+ if !keepDefaultChannelSnapshotFallback {
2425
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelDefaultsKey)
2426
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
2427
+ }
2016
2428
  UserDefaults.standard.removeObject(forKey: self.previewAppIdDefaultsKey)
2017
2429
  UserDefaults.standard.removeObject(forKey: self.previewPayloadUrlDefaultsKey)
2018
2430
  UserDefaults.standard.removeObject(forKey: self.previewNameDefaultsKey)
@@ -2030,21 +2442,95 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2030
2442
  logger.info("Restored appId after preview: \(previousAppId)")
2031
2443
  }
2032
2444
 
2033
- private func restorePreviewPreviousDefaultChannel() {
2034
- let configDefaultChannel = self.getStringConfig("defaultChannel", defaultValue: "")
2035
- 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
+ }
2454
+
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
+ }
2036
2488
 
2037
- guard hadPreviousDefaultChannel,
2038
- let previousDefaultChannel = UserDefaults.standard.string(forKey: self.previewPreviousDefaultChannelDefaultsKey) else {
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 {
2039
2496
  UserDefaults.standard.removeObject(forKey: self.defaultChannelDefaultsKey)
2040
2497
  self.implementation.defaultChannel = configDefaultChannel
2041
2498
  logger.info("Restored defaultChannel after preview to config value")
2042
- return
2043
2499
  }
2500
+ }
2044
2501
 
2045
- UserDefaults.standard.set(previousDefaultChannel, forKey: self.defaultChannelDefaultsKey)
2046
- self.implementation.defaultChannel = previousDefaultChannel
2047
- 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
2048
2534
  }
2049
2535
 
2050
2536
  private func normalizedPreviewAppId(_ rawAppId: String?) -> String? {
@@ -2224,7 +2710,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2224
2710
  self.shakeMenuGesture = Self.normalizedShakeMenuGesture(self.getStringConfig("shakeMenuGesture", defaultValue: Self.shakeMenuGestureShake))
2225
2711
  self.syncShakeMenuGestureRecognizer()
2226
2712
  self.restorePreviewPreviousAppId()
2227
- self.restorePreviewPreviousDefaultChannel()
2713
+ if !self.restorePreviewPreviousDefaultChannel() {
2714
+ logger.warn("Default channel preview restore will retry on next launch")
2715
+ }
2228
2716
  _ = self.implementation.setPreviewFallbackBundle(fallback: nil)
2229
2717
  _ = self.implementation.setNextBundle(next: Optional<String>.none)
2230
2718
  self.clearPreviewSessionPreferences()
@@ -2469,6 +2957,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2469
2957
  "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
2470
2958
  ])
2471
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
+ }
2472
2964
  if self._isAutoUpdateEnabled() && triggerAutoUpdate {
2473
2965
  self.logger.info("Calling autoupdater after channel change!")
2474
2966
  // Check if download is already in progress (with timeout protection)
@@ -2515,6 +3007,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2515
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"
2516
3008
  ])
2517
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
+ }
2518
3014
  if self._isAutoUpdateEnabled() && triggerAutoUpdate {
2519
3015
  self.logger.info("Calling autoupdater after channel change!")
2520
3016
  // Check if download is already in progress (with timeout protection)
@@ -2539,6 +3035,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2539
3035
  "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
2540
3036
  ])
2541
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
+ }
2542
3042
  self.resolveCall(call, data: res.toDict())
2543
3043
  }
2544
3044
  }
@@ -2851,12 +3351,21 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2851
3351
  self.implementation.setError(bundle: current)
2852
3352
  _ = self.performReset(toLastSuccessful: true, usePendingBundle: false, isInternal: true)
2853
3353
  if self.autoDeleteFailed && !current.isBuiltin() {
3354
+ let failedId = current.getId()
2854
3355
  logger.info("Deleting failing bundle: \(current.toString())")
2855
- let res = self.implementation.delete(id: current.getId(), removeInfo: false)
2856
- if !res {
2857
- logger.info("Delete version deleted: \(current.toString())")
2858
- } else {
2859
- logger.error("Failed to delete failed bundle: \(current.toString())")
3356
+ // Mark before async work so kill/OOM still resumes via drainPendingDeletes.
3357
+ self.implementation.saveBundleInfo(
3358
+ id: failedId,
3359
+ bundle: current.setStatus(status: BundleStatus.DELETING.storedValue)
3360
+ )
3361
+ UserDefaults.standard.synchronize()
3362
+ DispatchQueue.global(qos: .utility).async {
3363
+ let res = self.implementation.delete(id: failedId, removeInfo: false)
3364
+ if res {
3365
+ self.logger.info("Failed bundle deleted: \(failedId)")
3366
+ } else {
3367
+ self.logger.error("Failed to delete failed bundle: \(failedId)")
3368
+ }
2860
3369
  }
2861
3370
  }
2862
3371
  } else {
@@ -2881,7 +3390,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2881
3390
  func sendReadyToJs(current: BundleInfo, msg: String) {
2882
3391
  logger.info("sendReadyToJs")
2883
3392
  DispatchQueue.global().async {
2884
- self.semaphoreWait(waitTime: self.appReadyTimeout)
3393
+ // Only wait after load()/reload() armed the semaphore. Foreground resumes
3394
+ // with autoUpdate disabled call sendReadyToJs again without a fresh
3395
+ // notifyAppReady(), so waiting there always timed out after appReadyTimeout.
3396
+ if self.consumePendingNotifyAppReady() {
3397
+ self.semaphoreWait(waitTime: self.appReadyTimeout)
3398
+ }
2885
3399
  self.notifyListeners("appReady", data: ["bundle": current.toJSON(), "status": msg], retainUntilConsumed: true)
2886
3400
 
2887
3401
  // Auto hide splashscreen if enabled
@@ -2893,6 +3407,16 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2893
3407
  }
2894
3408
  }
2895
3409
 
3410
+ private func consumePendingNotifyAppReady() -> Bool {
3411
+ let shouldWait = pendingNotifyAppReady
3412
+ pendingNotifyAppReady = false
3413
+ return shouldWait
3414
+ }
3415
+
3416
+ private func clearPendingNotifyAppReady() {
3417
+ pendingNotifyAppReady = false
3418
+ }
3419
+
2896
3420
  private func hideSplashscreen() {
2897
3421
  if Thread.isMainThread {
2898
3422
  self.performHideSplashscreen()
@@ -3506,6 +4030,22 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3506
4030
  self.currentBuildVersion = currentBuildVersion
3507
4031
  }
3508
4032
 
4033
+ func setAppReadyTimeoutForTesting(_ timeout: Int) {
4034
+ self.appReadyTimeout = timeout
4035
+ }
4036
+
4037
+ func armPendingNotifyAppReadyForTesting() {
4038
+ pendingNotifyAppReady = true
4039
+ }
4040
+
4041
+ func clearPendingNotifyAppReadyForTesting() {
4042
+ clearPendingNotifyAppReady()
4043
+ }
4044
+
4045
+ var isPendingNotifyAppReadyForTesting: Bool {
4046
+ pendingNotifyAppReady
4047
+ }
4048
+
3509
4049
  func shouldUseDirectUpdateForTesting() -> Bool {
3510
4050
  self.shouldUseDirectUpdate()
3511
4051
  }