@capgo/capacitor-updater 7.50.2 → 7.51.15

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.
Files changed (26) hide show
  1. package/Package.swift +2 -1
  2. package/README.md +53 -48
  3. package/android/src/main/java/ee/forgr/capacitor_updater/AppLifecycleObserver.java +29 -2
  4. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +7 -3
  5. package/android/src/main/java/ee/forgr/capacitor_updater/BundleStatus.java +1 -0
  6. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +427 -116
  7. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1108 -180
  8. package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +102 -31
  9. package/android/src/main/java/ee/forgr/capacitor_updater/DataManager.java +23 -7
  10. package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +2 -2
  11. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +513 -201
  12. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +103 -3
  13. package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +1 -1
  14. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +115 -133
  15. package/dist/docs.json +32 -8
  16. package/dist/esm/definitions.d.ts +41 -17
  17. package/dist/esm/definitions.js.map +1 -1
  18. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +124 -0
  19. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +9 -1
  20. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +3 -0
  21. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +789 -92
  22. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1014 -268
  23. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +49 -31
  24. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +44 -20
  25. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +28 -0
  26. package/package.json +12 -7
@@ -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,12 @@ 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 = "7.50.1"
95
+
96
+ deinit {
97
+ implementation.shutdown()
98
+ }
99
+ private let pluginVersion: String = "7.51.15"
100
+ private let launchStartedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
89
101
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
90
102
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
91
103
  static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
@@ -123,6 +135,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
123
135
  private let previewSourceDefaultsKey = "CapacitorUpdater.previewSource"
124
136
  private let previewSessionsDefaultsKey = "CapacitorUpdater.previewSessions"
125
137
  private let previewSessionAlertPendingDefaultsKey = "CapacitorUpdater.previewSessionAlertPending"
138
+ private let defaultChannelInstallMarkerDefaultsKey = "CapacitorUpdater.defaultChannelInstallMarkerCreated"
139
+ private let defaultChannelInstallMarkerFilename = "CapacitorUpdater.defaultChannelInstallMarker"
140
+ private let defaultChannelStateFilename = "CapacitorUpdater.defaultChannelState"
141
+ private let defaultChannelPreviewSnapshotFilename = "CapacitorUpdater.defaultChannelPreviewSnapshot"
126
142
  private let previewDeepLinkScheme = "capgo"
127
143
  private let previewDeepLinkRootComponent = "preview"
128
144
  private let previewDeepLinkChannelComponent = "channel"
@@ -135,6 +151,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
135
151
  private var currentBuildVersion: String = "0"
136
152
  private var autoUpdate = false
137
153
  private var autoUpdateMode = CapacitorUpdaterPlugin.autoUpdateModeOff
154
+ private var launchStartReported = false
155
+ private var launchReadyReported = false
138
156
  private var appReadyTimeout = 10000
139
157
  private var appReadyCheck: DispatchWorkItem?
140
158
  private var resetWhenUpdate = true
@@ -160,6 +178,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
160
178
  private var autoDeleteFailed = false
161
179
  private var autoDeletePrevious = false
162
180
  var allowSetDefaultChannel = true
181
+ var persistDefaultChannelOnReinstall = true
163
182
  private var keepUrlPathAfterReload = false
164
183
  private var backgroundWork: DispatchWorkItem?
165
184
  private var taskRunning = false
@@ -172,8 +191,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
172
191
 
173
192
  // Lock to ensure cleanup completes before downloads start
174
193
  private let cleanupLock = NSLock()
194
+ // Short lock for cleanupComplete/cleanupTimedOut only. Never hold across cleanupGroup.wait.
195
+ private let cleanupStateLock = NSLock()
196
+ private let defaultChannelStateLock = NSLock()
197
+ private let cleanupGroup = DispatchGroup()
175
198
  private var cleanupComplete = false
199
+ private var cleanupTimedOut = false
176
200
  private var cleanupThread: Thread?
201
+ private var defaultChannelCleanupMustRetry = false
177
202
  private var persistCustomId = false
178
203
  private var persistModifyUrl = false
179
204
  private var allowManualBundleError = false
@@ -191,6 +216,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
191
216
  private var isLeavingPreviewForIncomingLink = false
192
217
  private var previewTransitionClearWorkItem: DispatchWorkItem?
193
218
  let semaphoreReady = DispatchSemaphore(value: 0)
219
+ // Best-effort flag: set before we expect notifyAppReady (load/reload).
220
+ // No lock — never held across waits; a rare race only mis-times one wait.
221
+ private var pendingNotifyAppReady = false
194
222
 
195
223
  private var delayUpdateUtils: DelayUpdateUtils!
196
224
 
@@ -216,6 +244,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
216
244
  self.implementation.deviceID = DeviceIdHelper.getOrCreateDeviceId()
217
245
  persistCustomId = getConfig().getBoolean("persistCustomId", false)
218
246
  allowSetDefaultChannel = getConfig().getBoolean("allowSetDefaultChannel", true)
247
+ persistDefaultChannelOnReinstall = getConfig().getBoolean("persistDefaultChannelOnReinstall", true)
219
248
  if persistCustomId {
220
249
  let storedCustomId = UserDefaults.standard.string(forKey: customIdDefaultsKey) ?? ""
221
250
  if !storedCustomId.isEmpty {
@@ -331,14 +360,61 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
331
360
  logger.info("Loaded persisted channelUrl")
332
361
  }
333
362
  }
363
+ implementation.restorePendingStats()
334
364
 
335
- // Load defaultChannel: first try from persistent storage (set via setChannel), then fall back to config
336
- if let storedDefaultChannel = UserDefaults.standard.object(forKey: defaultChannelDefaultsKey) as? String {
365
+ let nativeBuildVersionChanged = self.hasNativeBuildVersionChanged()
366
+ let defaultChannelPersistenceDisabled = !persistDefaultChannelOnReinstall
367
+ let restoredReinstall = defaultChannelPersistenceDisabled && self.isRestoredReinstall()
368
+ var installMarkerCanBePrepared = true
369
+ if shouldClearPersistedDefaultChannel(
370
+ nativeBuildVersionChanged: nativeBuildVersionChanged,
371
+ resetWhenUpdate: resetWhenUpdate,
372
+ restoredReinstall: restoredReinstall
373
+ ) {
374
+ installMarkerCanBePrepared = clearPersistedDefaultChannel()
375
+ if installMarkerCanBePrepared {
376
+ logger.info("Cleared persisted defaultChannel because reinstall persistence is disabled")
377
+ } else {
378
+ logger.warn("Cannot durably clear persisted defaultChannel")
379
+ self.defaultChannelCleanupMustRetry = true
380
+ self.invalidateDefaultChannelInstallMarker()
381
+ }
382
+ }
383
+ if defaultChannelPersistenceDisabled && installMarkerCanBePrepared {
384
+ self.prepareDefaultChannelInstallMarker()
385
+ }
386
+ if !previewSessionEnabled,
387
+ !defaultChannelCleanupMustRetry,
388
+ self.hasPendingDefaultChannelPreviewSnapshot(),
389
+ !self.restorePreviewPreviousDefaultChannel() {
390
+ logger.warn("Default channel preview restore remains pending")
391
+ }
392
+
393
+ let configDefaultChannel = getConfig().getString("defaultChannel", "")!
394
+ let storedDefaultChannel = UserDefaults.standard.string(forKey: defaultChannelDefaultsKey)
395
+ let stateFile = self.defaultChannelStateFile()
396
+ let state = stateFile.map { self.defaultChannelState(file: $0) }
397
+ if defaultChannelCleanupMustRetry {
398
+ implementation.defaultChannel = configDefaultChannel
399
+ logger.warn("Using configured defaultChannel until persisted cleanup can retry")
400
+ } else if let state = state, state.exists, state.isReadable {
401
+ self.reconcileDefaultChannelDefaults(with: state.channel)
402
+ implementation.defaultChannel = state.channel ?? configDefaultChannel
403
+ logger.info("Loaded persisted defaultChannel from local state")
404
+ } else if defaultChannelPersistenceDisabled, state?.exists == true {
405
+ implementation.defaultChannel = configDefaultChannel
406
+ logger.warn("Ignoring unreadable persisted defaultChannel while reinstall persistence is disabled")
407
+ } else if let storedDefaultChannel = storedDefaultChannel {
337
408
  implementation.defaultChannel = storedDefaultChannel
338
409
  logger.info("Loaded persisted defaultChannel from setChannel()")
339
410
  } else {
340
- implementation.defaultChannel = getConfig().getString("defaultChannel", "")!
411
+ implementation.defaultChannel = configDefaultChannel
341
412
  }
413
+ if !defaultChannelCleanupMustRetry,
414
+ state?.exists != true || (!defaultChannelPersistenceDisabled && state?.isReadable != true) {
415
+ _ = self.persistDefaultChannelStateFromDefaults()
416
+ }
417
+ self.reportAppLaunchStart()
342
418
  self.implementation.autoReset()
343
419
  let appHealthTracker = AppHealthTracker(implementation: self.implementation)
344
420
  self.appHealthTracker = appHealthTracker
@@ -347,16 +423,26 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
347
423
 
348
424
  // Check if app was recently installed/updated BEFORE cleanup updates the stored native build version.
349
425
  self.wasRecentlyInstalledOrUpdated = self.checkIfRecentlyInstalledOrUpdated()
350
- let nativeBuildVersionChanged = self.hasNativeBuildVersionChanged()
351
426
  if nativeBuildVersionChanged {
352
427
  self.clearPreviewSessionForNativeBuildChange()
353
428
  }
354
429
  self.leavePreviewSessionForLaunchURLIfNeeded()
355
430
 
356
- if resetWhenUpdate {
357
- let didResetCurrentBundle = self.resetCurrentBundleForNativeBuildChangeIfNeeded()
358
- self.cleanupObsoleteVersions(didResetCurrentBundle: didResetCurrentBundle)
431
+ // Downloads (including shake-menu / CapgoUpdater entry points) wait on this gate.
432
+ self.implementation.beforeDownload = { [weak self] in
433
+ try self?.waitForCleanupIfNeeded()
359
434
  }
435
+ // Always run async cleanup: delete obsolete bundles on native update (when enabled)
436
+ // and sweep orphan directories every launch. Must not block app startup.
437
+ if !resetWhenUpdate {
438
+ UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
439
+ UserDefaults.standard.synchronize()
440
+ }
441
+ let didResetCurrentBundle = resetWhenUpdate ? self.resetCurrentBundleForNativeBuildChangeIfNeeded() : false
442
+ self.cleanupObsoleteVersions(
443
+ resetWhenUpdate: resetWhenUpdate,
444
+ didResetCurrentBundle: didResetCurrentBundle
445
+ )
360
446
  self.reportNativeVersionStatsIfChanged()
361
447
 
362
448
  // Load the server
@@ -518,6 +604,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
518
604
  }
519
605
 
520
606
  private func semaphoreUp() {
607
+ pendingNotifyAppReady = true
521
608
  DispatchQueue.global().async {
522
609
  self.semaphoreWait(waitTime: 0)
523
610
  }
@@ -531,6 +618,327 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
531
618
  UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
532
619
  }
533
620
 
621
+ func shouldClearPersistedDefaultChannel(
622
+ nativeBuildVersionChanged: Bool,
623
+ resetWhenUpdate: Bool,
624
+ restoredReinstall: Bool
625
+ ) -> Bool {
626
+ !persistDefaultChannelOnReinstall && (restoredReinstall || (resetWhenUpdate && nativeBuildVersionChanged))
627
+ }
628
+
629
+ func clearPersistedDefaultChannel() -> Bool {
630
+ guard let stateFile = self.defaultChannelStateFile(),
631
+ let previewSnapshotFile = self.defaultChannelPreviewSnapshotFile() else {
632
+ return false
633
+ }
634
+ return self.clearPersistedDefaultChannel(
635
+ stateFile: stateFile,
636
+ previewSnapshotFile: previewSnapshotFile
637
+ )
638
+ }
639
+
640
+ func clearPersistedDefaultChannel(stateFile: URL, previewSnapshotFile: URL) -> Bool {
641
+ defaultChannelStateLock.lock()
642
+ defer { defaultChannelStateLock.unlock() }
643
+ do {
644
+ try self.persistDefaultChannelStateWithoutLock(channel: nil, file: stateFile)
645
+ try self.persistDefaultChannelPreviewSnapshotWithoutLock(
646
+ channel: nil,
647
+ isValid: false,
648
+ file: previewSnapshotFile
649
+ )
650
+ UserDefaults.standard.removeObject(forKey: defaultChannelDefaultsKey)
651
+ UserDefaults.standard.removeObject(forKey: previewPreviousDefaultChannelDefaultsKey)
652
+ UserDefaults.standard.removeObject(forKey: previewPreviousDefaultChannelWasSetDefaultsKey)
653
+ return true
654
+ } catch {
655
+ logger.warn("Cannot persist cleared default channel state: \(error.localizedDescription)")
656
+ return false
657
+ }
658
+ }
659
+
660
+ private func defaultChannelStateFile() -> URL? {
661
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
662
+ .appendingPathComponent(defaultChannelStateFilename)
663
+ }
664
+
665
+ private func defaultChannelPreviewSnapshotFile() -> URL? {
666
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
667
+ .appendingPathComponent(defaultChannelPreviewSnapshotFilename)
668
+ }
669
+
670
+ func defaultChannelState(file: URL) -> (exists: Bool, channel: String?, isReadable: Bool) {
671
+ defaultChannelStateLock.lock()
672
+ defer { defaultChannelStateLock.unlock() }
673
+ return self.defaultChannelStateWithoutLock(file: file)
674
+ }
675
+
676
+ private func defaultChannelStateWithoutLock(file: URL) -> (exists: Bool, channel: String?, isReadable: Bool) {
677
+ let fileManager = FileManager.default
678
+ guard fileManager.fileExists(atPath: file.path) else {
679
+ return (false, nil, true)
680
+ }
681
+ do {
682
+ let data = try Data(contentsOf: file)
683
+ guard !data.isEmpty else {
684
+ return (true, nil, true)
685
+ }
686
+ guard let channel = String(data: data, encoding: .utf8) else {
687
+ logger.warn("Cannot decode persisted default channel state")
688
+ return (true, nil, false)
689
+ }
690
+ return (true, channel, true)
691
+ } catch {
692
+ logger.warn("Cannot read persisted default channel state: \(error.localizedDescription)")
693
+ return (true, nil, false)
694
+ }
695
+ }
696
+
697
+ func persistDefaultChannelState(channel: String?, file: URL) throws {
698
+ defaultChannelStateLock.lock()
699
+ defer { defaultChannelStateLock.unlock() }
700
+ try self.persistDefaultChannelStateWithoutLock(channel: channel, file: file)
701
+ }
702
+
703
+ private func persistDefaultChannelStateWithoutLock(channel: String?, file: URL) throws {
704
+ let data = channel.map { Data($0.utf8) } ?? Data()
705
+ try self.persistBackupExcludedFile(file: file, data: data, overwrite: true)
706
+ }
707
+
708
+ func defaultChannelPreviewSnapshot(file: URL) -> DefaultChannelPreviewSnapshot {
709
+ defaultChannelStateLock.lock()
710
+ defer { defaultChannelStateLock.unlock() }
711
+ return self.defaultChannelPreviewSnapshotWithoutLock(file: file)
712
+ }
713
+
714
+ private func defaultChannelPreviewSnapshotWithoutLock(file: URL) -> DefaultChannelPreviewSnapshot {
715
+ guard FileManager.default.fileExists(atPath: file.path) else {
716
+ return .missing
717
+ }
718
+ do {
719
+ let data = try Data(contentsOf: file)
720
+ guard let kind = data.first else {
721
+ return .unreadable
722
+ }
723
+ switch kind {
724
+ case 0 where data.count == 1:
725
+ return .invalidated
726
+ case 1 where data.count == 1:
727
+ return .snapshot(nil)
728
+ case 2:
729
+ guard let channel = String(data: Data(data.dropFirst()), encoding: .utf8) else {
730
+ return .unreadable
731
+ }
732
+ return .snapshot(channel)
733
+ default:
734
+ return .unreadable
735
+ }
736
+ } catch {
737
+ logger.warn("Cannot read default channel preview snapshot: \(error.localizedDescription)")
738
+ return .unreadable
739
+ }
740
+ }
741
+
742
+ func persistDefaultChannelPreviewSnapshot(channel: String?, file: URL) throws {
743
+ defaultChannelStateLock.lock()
744
+ defer { defaultChannelStateLock.unlock() }
745
+ try self.persistDefaultChannelPreviewSnapshotWithoutLock(
746
+ channel: channel,
747
+ isValid: true,
748
+ file: file
749
+ )
750
+ }
751
+
752
+ func invalidateDefaultChannelPreviewSnapshot(file: URL) throws {
753
+ defaultChannelStateLock.lock()
754
+ defer { defaultChannelStateLock.unlock() }
755
+ try self.persistDefaultChannelPreviewSnapshotWithoutLock(
756
+ channel: nil,
757
+ isValid: false,
758
+ file: file
759
+ )
760
+ }
761
+
762
+ private func persistDefaultChannelPreviewSnapshotWithoutLock(
763
+ channel: String?,
764
+ isValid: Bool,
765
+ file: URL
766
+ ) throws {
767
+ var data = Data()
768
+ if !isValid {
769
+ data.append(0)
770
+ } else if let channel = channel {
771
+ data.append(2)
772
+ data.append(contentsOf: channel.utf8)
773
+ } else {
774
+ data.append(1)
775
+ }
776
+ try self.persistBackupExcludedFile(file: file, data: data, overwrite: true)
777
+ }
778
+
779
+ private func persistDefaultChannelPreviewSnapshot(channel: String?) -> Bool {
780
+ guard let file = self.defaultChannelPreviewSnapshotFile() else {
781
+ return false
782
+ }
783
+ do {
784
+ try self.persistDefaultChannelPreviewSnapshot(channel: channel, file: file)
785
+ return true
786
+ } catch {
787
+ logger.warn("Cannot persist default channel preview snapshot: \(error.localizedDescription)")
788
+ return false
789
+ }
790
+ }
791
+
792
+ private func hasPendingDefaultChannelPreviewSnapshot() -> Bool {
793
+ guard let file = self.defaultChannelPreviewSnapshotFile() else {
794
+ return false
795
+ }
796
+ switch self.defaultChannelPreviewSnapshot(file: file) {
797
+ case .snapshot, .unreadable:
798
+ return true
799
+ case .missing, .invalidated:
800
+ return false
801
+ }
802
+ }
803
+
804
+ @discardableResult
805
+ func persistDefaultChannelStateFromDefaults() -> Bool {
806
+ guard let file = self.defaultChannelStateFile() else {
807
+ logger.warn("Cannot locate default channel state file")
808
+ return false
809
+ }
810
+ return self.persistDefaultChannelStateFromDefaults(stateFile: file)
811
+ }
812
+
813
+ @discardableResult
814
+ func persistDefaultChannelStateFromDefaults(stateFile: URL) -> Bool {
815
+ defaultChannelStateLock.lock()
816
+ defer { defaultChannelStateLock.unlock() }
817
+ do {
818
+ try self.persistDefaultChannelStateWithoutLock(
819
+ channel: UserDefaults.standard.string(forKey: defaultChannelDefaultsKey),
820
+ file: stateFile
821
+ )
822
+ return true
823
+ } catch {
824
+ do {
825
+ if FileManager.default.fileExists(atPath: stateFile.path) {
826
+ try FileManager.default.removeItem(at: stateFile)
827
+ }
828
+ logger.warn("Cannot persist default channel state; falling back to UserDefaults: \(error.localizedDescription)")
829
+ return true
830
+ } catch let invalidationError {
831
+ logger.warn("Cannot persist or invalidate default channel state: \(invalidationError.localizedDescription)")
832
+ return false
833
+ }
834
+ }
835
+ }
836
+
837
+ private func persistedDefaultChannel() -> String? {
838
+ guard let file = self.defaultChannelStateFile() else {
839
+ return UserDefaults.standard.string(forKey: defaultChannelDefaultsKey)
840
+ }
841
+ return self.persistedDefaultChannel(stateFile: file)
842
+ }
843
+
844
+ func persistedDefaultChannel(stateFile: URL) -> String? {
845
+ let state = self.defaultChannelState(file: stateFile)
846
+ if state.exists {
847
+ if state.isReadable {
848
+ return state.channel
849
+ }
850
+ if !persistDefaultChannelOnReinstall {
851
+ return nil
852
+ }
853
+ }
854
+ return UserDefaults.standard.string(forKey: defaultChannelDefaultsKey)
855
+ }
856
+
857
+ private func reconcileDefaultChannelDefaults(with channel: String?) {
858
+ if let channel = channel {
859
+ UserDefaults.standard.set(channel, forKey: defaultChannelDefaultsKey)
860
+ } else {
861
+ UserDefaults.standard.removeObject(forKey: defaultChannelDefaultsKey)
862
+ }
863
+ }
864
+
865
+ private func defaultChannelInstallMarker() -> URL? {
866
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
867
+ .appendingPathComponent(defaultChannelInstallMarkerFilename)
868
+ }
869
+
870
+ private func invalidateDefaultChannelInstallMarker() {
871
+ guard let marker = self.defaultChannelInstallMarker() else {
872
+ return
873
+ }
874
+ do {
875
+ try self.invalidateDefaultChannelInstallMarker(marker: marker)
876
+ } catch {
877
+ logger.warn("Cannot invalidate default channel install marker for cleanup retry: \(error.localizedDescription)")
878
+ }
879
+ }
880
+
881
+ func invalidateDefaultChannelInstallMarker(marker: URL) throws {
882
+ let fileManager = FileManager.default
883
+ if fileManager.fileExists(atPath: marker.path) {
884
+ try fileManager.removeItem(at: marker)
885
+ }
886
+ }
887
+
888
+ func isRestoredReinstall() -> Bool {
889
+ guard let marker = self.defaultChannelInstallMarker() else {
890
+ return false
891
+ }
892
+ return self.isRestoredReinstall(
893
+ marker: marker,
894
+ markerWasCreated: UserDefaults.standard.bool(forKey: defaultChannelInstallMarkerDefaultsKey)
895
+ )
896
+ }
897
+
898
+ func isRestoredReinstall(marker: URL, markerWasCreated: Bool) -> Bool {
899
+ markerWasCreated && !FileManager.default.fileExists(atPath: marker.path)
900
+ }
901
+
902
+ private func prepareDefaultChannelInstallMarker() {
903
+ guard let marker = self.defaultChannelInstallMarker() else {
904
+ return
905
+ }
906
+ self.prepareDefaultChannelInstallMarker(
907
+ marker: marker,
908
+ markerWasCreated: UserDefaults.standard.bool(forKey: defaultChannelInstallMarkerDefaultsKey)
909
+ )
910
+ }
911
+
912
+ func prepareDefaultChannelInstallMarker(marker: URL, markerWasCreated: Bool) {
913
+ do {
914
+ try self.persistBackupExcludedFile(file: marker, data: Data())
915
+ if !markerWasCreated {
916
+ UserDefaults.standard.set(true, forKey: defaultChannelInstallMarkerDefaultsKey)
917
+ UserDefaults.standard.synchronize()
918
+ }
919
+ } catch {
920
+ try? FileManager.default.removeItem(at: marker)
921
+ UserDefaults.standard.set(false, forKey: defaultChannelInstallMarkerDefaultsKey)
922
+ UserDefaults.standard.synchronize()
923
+ logger.warn("Cannot prepare default channel install marker: \(error.localizedDescription)")
924
+ }
925
+ }
926
+
927
+ private func persistBackupExcludedFile(file: URL, data: Data, overwrite: Bool = false) throws {
928
+ let fileManager = FileManager.default
929
+ if overwrite || !fileManager.fileExists(atPath: file.path) {
930
+ try fileManager.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
931
+ try data.write(to: file, options: .atomic)
932
+ }
933
+ let currentResourceValues = try file.resourceValues(forKeys: [.isExcludedFromBackupKey])
934
+ if currentResourceValues.isExcludedFromBackup != true {
935
+ var resourceValues = URLResourceValues()
936
+ resourceValues.isExcludedFromBackup = true
937
+ var mutableFile = file
938
+ try mutableFile.setResourceValues(resourceValues)
939
+ }
940
+ }
941
+
534
942
  func hasNativeBuildVersionChanged() -> Bool {
535
943
  let previous = self.storedNativeBuildVersion()
536
944
  return previous != "0" && self.currentBuildVersion != previous
@@ -629,12 +1037,35 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
629
1037
  return true
630
1038
  }
631
1039
 
632
- private func cleanupObsoleteVersions(didResetCurrentBundle: Bool = false) {
1040
+ private func cleanupObsoleteVersions(resetWhenUpdate: Bool = true, didResetCurrentBundle: Bool = false) {
1041
+ // Enter before publishing incomplete state so waiters cannot hit an empty group.
1042
+ self.cleanupStateLock.lock()
1043
+ self.cleanupComplete = false
1044
+ self.cleanupTimedOut = false
1045
+ self.cleanupGroup.enter()
1046
+ self.cleanupStateLock.unlock()
633
1047
  cleanupThread = Thread {
1048
+ let bgTaskLock = NSLock()
1049
+ var cleanupBackgroundTask = UIBackgroundTaskIdentifier.invalid
1050
+ let endCleanupBackgroundTask = {
1051
+ bgTaskLock.lock()
1052
+ defer { bgTaskLock.unlock() }
1053
+ if cleanupBackgroundTask != .invalid {
1054
+ UIApplication.shared.endBackgroundTask(cleanupBackgroundTask)
1055
+ cleanupBackgroundTask = .invalid
1056
+ }
1057
+ }
1058
+ cleanupBackgroundTask = UIApplication.shared.beginBackgroundTask(withName: "CapgoBundleCleanup") {
1059
+ endCleanupBackgroundTask()
1060
+ }
634
1061
  self.cleanupLock.lock()
635
1062
  defer {
1063
+ self.cleanupStateLock.lock()
636
1064
  self.cleanupComplete = true
1065
+ self.cleanupStateLock.unlock()
637
1066
  self.cleanupLock.unlock()
1067
+ self.cleanupGroup.leave()
1068
+ endCleanupBackgroundTask()
638
1069
  self.logger.info("Cleanup complete")
639
1070
  }
640
1071
 
@@ -665,67 +1096,92 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
665
1096
  // 2. Compare both keys. If any is not equal to "currentBuildVersion", then revert to builtin version. This fixes the part 2 of this bug
666
1097
 
667
1098
  let previous = self.storedNativeBuildVersion()
668
- if previous != "0" && self.currentBuildVersion != previous {
1099
+ let nativeVersionChanged = previous != "0" && self.currentBuildVersion != previous
1100
+ if resetWhenUpdate && nativeVersionChanged {
669
1101
  if !didResetCurrentBundle {
670
1102
  self.logger.info("Native build version changed from \(previous) to \(self.currentBuildVersion). Resetting current bundle to builtin.")
671
1103
  self.implementation.reset(isInternal: true)
672
1104
  }
673
1105
  let res = self.implementation.list()
674
1106
  for version in res {
675
- // Check if thread was cancelled
676
1107
  if Thread.current.isCancelled {
677
- self.logger.warn("Cleanup was cancelled, stopping")
678
1108
  return
679
1109
  }
680
1110
  self.logger.info("Deleting obsolete bundle: \(version.getId())")
681
- let res = self.implementation.delete(id: version.getId())
682
- if !res {
1111
+ let deleted = self.implementation.delete(id: version.getId())
1112
+ if !deleted {
683
1113
  self.logger.error("Delete failed, id \(version.getId()) doesn't exist")
684
1114
  }
685
- }
686
-
687
- let storedBundles = self.implementation.list(raw: true)
688
- let allowedIds = Set(storedBundles.compactMap { info -> String? in
689
- let id = info.getId()
690
- return id.isEmpty ? nil : id
691
- })
692
- self.implementation.cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: Thread.current)
693
- self.implementation.cleanupOrphanedTempFolders(threadToCheck: Thread.current)
694
-
695
- // Check again before the expensive delta cache cleanup
696
- if Thread.current.isCancelled {
697
- self.logger.warn("Cleanup was cancelled before delta cache cleanup")
698
- return
1115
+ Thread.sleep(forTimeInterval: 0.075)
699
1116
  }
700
1117
  self.implementation.cleanupDeltaCache(threadToCheck: Thread.current)
701
1118
  }
702
- UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
703
- UserDefaults.standard.synchronize()
704
- }
705
- cleanupThread?.start()
706
1119
 
707
- // Start a timeout watchdog thread to cancel cleanup if it takes too long
708
- let timeout = Double(self.appReadyTimeout / 2) / 1000.0
709
- Thread.detachNewThread {
710
- Thread.sleep(forTimeInterval: timeout)
711
- if let thread = self.cleanupThread, !thread.isFinished && !self.cleanupComplete {
712
- self.logger.warn("Cleanup timeout exceeded (\(timeout)s), cancelling cleanup thread")
713
- thread.cancel()
1120
+ if Thread.current.isCancelled {
1121
+ return
1122
+ }
1123
+
1124
+ // Resume any DELETING leftovers from prior kills, one-by-one.
1125
+ self.implementation.drainPendingDeletes()
1126
+
1127
+ if Thread.current.isCancelled {
1128
+ return
1129
+ }
1130
+
1131
+ // Always sweep orphan directories so incomplete prior cleanups (or failed deletes)
1132
+ // cannot leave hundreds of MB behind across launches.
1133
+ let allowedIds = self.implementation.allowedBundleIdsForCleanup()
1134
+ self.implementation.cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: Thread.current)
1135
+ if Thread.current.isCancelled {
1136
+ return
1137
+ }
1138
+ self.implementation.cleanupOrphanedTempFolders(threadToCheck: Thread.current)
1139
+
1140
+ if self.defaultChannelCleanupMustRetry {
1141
+ self.logger.warn("Keeping the previous native build version so default channel cleanup retries")
1142
+ } else {
1143
+ UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
1144
+ UserDefaults.standard.synchronize()
714
1145
  }
715
1146
  }
1147
+ cleanupThread?.start()
716
1148
  }
717
1149
 
718
- private func waitForCleanupIfNeeded() {
719
- if cleanupComplete {
1150
+ private func cleanupTimeoutError() -> NSError {
1151
+ NSError(
1152
+ domain: "CapacitorUpdater",
1153
+ code: 1,
1154
+ userInfo: [NSLocalizedDescriptionKey: "Cleanup did not finish before download"]
1155
+ )
1156
+ }
1157
+
1158
+ private func waitForCleanupIfNeeded() throws {
1159
+ cleanupStateLock.lock()
1160
+ let alreadyDone = cleanupComplete
1161
+ let alreadyTimedOut = cleanupTimedOut
1162
+ cleanupStateLock.unlock()
1163
+ if alreadyDone {
720
1164
  return // Already done, no need to wait
721
1165
  }
1166
+ if alreadyTimedOut {
1167
+ throw cleanupTimeoutError()
1168
+ }
722
1169
 
723
1170
  logger.info("Waiting for cleanup to complete before starting download...")
724
-
725
- // Wait for cleanup to complete - blocks until lock is released
726
- cleanupLock.lock()
727
- cleanupLock.unlock()
728
-
1171
+ let result = cleanupGroup.wait(timeout: .now() + .seconds(60))
1172
+ if result == .timedOut {
1173
+ logger.warn("Cleanup wait timed out after 60s, cancelling leftover cleanup")
1174
+ cleanupThread?.cancel()
1175
+ let cancelled = cleanupGroup.wait(timeout: .now() + .seconds(60))
1176
+ if cancelled == .timedOut {
1177
+ cleanupStateLock.lock()
1178
+ cleanupTimedOut = true
1179
+ cleanupStateLock.unlock()
1180
+ logger.error("Cleanup did not finish after cancel, aborting download")
1181
+ throw cleanupTimeoutError()
1182
+ }
1183
+ return
1184
+ }
729
1185
  logger.info("Cleanup finished, proceeding with download")
730
1186
  }
731
1187
 
@@ -1142,6 +1598,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1142
1598
  }
1143
1599
 
1144
1600
  private func downloadBundle(urlString: String, version: String, sessionKey: String, checksum rawChecksum: String, manifestEntries: [ManifestEntry]?) throws -> BundleInfo {
1601
+ // Manual/preview downloads must wait too - launch orphan sweep can delete their temps.
1602
+ try self.waitForCleanupIfNeeded()
1145
1603
  guard let url = URL(string: urlString) else {
1146
1604
  throw makePreviewError("Invalid download URL")
1147
1605
  }
@@ -1293,6 +1751,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1293
1751
 
1294
1752
  let performReload: () -> Bool = {
1295
1753
  guard self.applyCurrentBundleToBridge(bridge) else {
1754
+ self.clearPendingNotifyAppReady()
1296
1755
  return false
1297
1756
  }
1298
1757
  self.checkAppReady()
@@ -1380,6 +1839,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1380
1839
  }
1381
1840
  }
1382
1841
 
1842
+ private func queueBundleForNextBackgroundInstall(_ next: BundleInfo) -> Bool {
1843
+ guard self.implementation.setNextBundle(next: next.getId()) else {
1844
+ self.logger.error("Failed to queue downloaded bundle as next: \(next.toString())")
1845
+ return false
1846
+ }
1847
+ return true
1848
+ }
1849
+
1383
1850
  private func applyDownloadedBundleForDirectUpdate(_ next: BundleInfo) -> Bool {
1384
1851
  let previousState = self.implementation.captureResetState()
1385
1852
  let previousBundleName = self.implementation.getCurrentBundle().getVersionName()
@@ -1497,8 +1964,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1497
1964
  UserDefaults.standard.removeObject(forKey: self.previewPreviousNextBundleDefaultsKey)
1498
1965
  }
1499
1966
 
1967
+ let previousDefaultChannel = self.persistedDefaultChannel()
1968
+ guard self.persistDefaultChannelPreviewSnapshot(channel: previousDefaultChannel) else {
1969
+ logger.error("Could not durably save the default channel preview snapshot")
1970
+ return false
1971
+ }
1500
1972
  UserDefaults.standard.set(self.implementation.appId, forKey: self.previewPreviousAppIdDefaultsKey)
1501
- if let previousDefaultChannel = UserDefaults.standard.object(forKey: self.defaultChannelDefaultsKey) as? String {
1973
+ if let previousDefaultChannel = previousDefaultChannel {
1502
1974
  UserDefaults.standard.set(previousDefaultChannel, forKey: self.previewPreviousDefaultChannelDefaultsKey)
1503
1975
  UserDefaults.standard.set(true, forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
1504
1976
  } else {
@@ -1946,7 +2418,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1946
2418
  ?? self.getBooleanConfig("allowShakeChannelSelector", defaultValue: false)
1947
2419
  self.restorePreviewPreviousNextBundle()
1948
2420
  self.restorePreviewPreviousAppId()
1949
- self.restorePreviewPreviousDefaultChannel()
2421
+ if !self.restorePreviewPreviousDefaultChannel() {
2422
+ logger.warn("Default channel preview restore will retry on next launch")
2423
+ }
1950
2424
 
1951
2425
  self.previewSessionEnabled = false
1952
2426
  self.previewSessionAlertPending = false
@@ -1973,7 +2447,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1973
2447
 
1974
2448
  self.restorePreviewPreviousNextBundle()
1975
2449
  self.restorePreviewPreviousAppId()
1976
- self.restorePreviewPreviousDefaultChannel()
2450
+ if !self.restorePreviewPreviousDefaultChannel() {
2451
+ logger.warn("Default channel preview restore will retry on next launch")
2452
+ }
1977
2453
  self.previewSessionEnabled = false
1978
2454
  self.previewSessionAlertPending = false
1979
2455
  self.isLeavingPreviewForIncomingLink = false
@@ -2001,14 +2477,17 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2001
2477
  }
2002
2478
 
2003
2479
  private func clearPreviewSessionPreferences() {
2480
+ let keepDefaultChannelSnapshotFallback = self.hasPendingDefaultChannelPreviewSnapshot()
2004
2481
  _ = self.implementation.setPreviewFallbackBundle(fallback: nil)
2005
2482
  UserDefaults.standard.removeObject(forKey: self.previewSessionDefaultsKey)
2006
2483
  UserDefaults.standard.removeObject(forKey: self.previewPreviousShakeMenuDefaultsKey)
2007
2484
  UserDefaults.standard.removeObject(forKey: self.previewPreviousShakeChannelSelectorDefaultsKey)
2008
2485
  UserDefaults.standard.removeObject(forKey: self.previewPreviousNextBundleDefaultsKey)
2009
2486
  UserDefaults.standard.removeObject(forKey: self.previewPreviousAppIdDefaultsKey)
2010
- UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelDefaultsKey)
2011
- UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
2487
+ if !keepDefaultChannelSnapshotFallback {
2488
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelDefaultsKey)
2489
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
2490
+ }
2012
2491
  UserDefaults.standard.removeObject(forKey: self.previewAppIdDefaultsKey)
2013
2492
  UserDefaults.standard.removeObject(forKey: self.previewPayloadUrlDefaultsKey)
2014
2493
  UserDefaults.standard.removeObject(forKey: self.previewNameDefaultsKey)
@@ -2026,21 +2505,95 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2026
2505
  logger.info("Restored appId after preview: \(previousAppId)")
2027
2506
  }
2028
2507
 
2029
- private func restorePreviewPreviousDefaultChannel() {
2030
- let configDefaultChannel = self.getStringConfig("defaultChannel", defaultValue: "")
2031
- let hadPreviousDefaultChannel = UserDefaults.standard.object(forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey) as? Bool ?? false
2508
+ private func restorePreviewPreviousDefaultChannel() -> Bool {
2509
+ guard !defaultChannelCleanupMustRetry else {
2510
+ logger.warn("Skipping default channel preview restore until cleanup retries")
2511
+ return false
2512
+ }
2513
+ guard let stateFile = self.defaultChannelStateFile(),
2514
+ let previewSnapshotFile = self.defaultChannelPreviewSnapshotFile() else {
2515
+ return false
2516
+ }
2032
2517
 
2033
- guard hadPreviousDefaultChannel,
2034
- let previousDefaultChannel = UserDefaults.standard.string(forKey: self.previewPreviousDefaultChannelDefaultsKey) else {
2518
+ let previousDefaultChannel: String?
2519
+ switch self.defaultChannelPreviewSnapshot(file: previewSnapshotFile) {
2520
+ case .invalidated:
2521
+ return true
2522
+ case let .snapshot(channel):
2523
+ previousDefaultChannel = channel
2524
+ case .missing:
2525
+ let hadPreviousDefaultChannel = UserDefaults.standard.object(
2526
+ forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey
2527
+ ) as? Bool ?? false
2528
+ previousDefaultChannel = hadPreviousDefaultChannel
2529
+ ? UserDefaults.standard.string(forKey: self.previewPreviousDefaultChannelDefaultsKey)
2530
+ : nil
2531
+ do {
2532
+ try self.persistDefaultChannelPreviewSnapshot(
2533
+ channel: previousDefaultChannel,
2534
+ file: previewSnapshotFile
2535
+ )
2536
+ } catch {
2537
+ logger.warn("Cannot migrate default channel preview snapshot: \(error.localizedDescription)")
2538
+ return false
2539
+ }
2540
+ case .unreadable:
2541
+ logger.warn("Cannot restore default channel from an unreadable preview snapshot")
2542
+ return false
2543
+ }
2544
+
2545
+ return self.persistRestoredDefaultChannelFromPreview(
2546
+ channel: previousDefaultChannel,
2547
+ stateFile: stateFile,
2548
+ previewSnapshotFile: previewSnapshotFile
2549
+ )
2550
+ }
2551
+
2552
+ private func applyRestoredDefaultChannelFromPreview(_ channel: String?) {
2553
+ let configDefaultChannel = self.getStringConfig("defaultChannel", defaultValue: "")
2554
+ if let channel = channel {
2555
+ UserDefaults.standard.set(channel, forKey: self.defaultChannelDefaultsKey)
2556
+ self.implementation.defaultChannel = channel
2557
+ logger.info("Restored defaultChannel after preview")
2558
+ } else {
2035
2559
  UserDefaults.standard.removeObject(forKey: self.defaultChannelDefaultsKey)
2036
2560
  self.implementation.defaultChannel = configDefaultChannel
2037
2561
  logger.info("Restored defaultChannel after preview to config value")
2038
- return
2039
2562
  }
2563
+ }
2040
2564
 
2041
- UserDefaults.standard.set(previousDefaultChannel, forKey: self.defaultChannelDefaultsKey)
2042
- self.implementation.defaultChannel = previousDefaultChannel
2043
- logger.info("Restored defaultChannel after preview")
2565
+ private func persistRestoredDefaultChannelFromPreview(
2566
+ channel: String?,
2567
+ stateFile: URL,
2568
+ previewSnapshotFile: URL
2569
+ ) -> Bool {
2570
+ defaultChannelStateLock.lock()
2571
+ defer { defaultChannelStateLock.unlock() }
2572
+ let didPersist: Bool
2573
+ do {
2574
+ try self.persistDefaultChannelStateWithoutLock(channel: channel, file: stateFile)
2575
+ try self.persistDefaultChannelPreviewSnapshotWithoutLock(
2576
+ channel: nil,
2577
+ isValid: false,
2578
+ file: previewSnapshotFile
2579
+ )
2580
+ didPersist = true
2581
+ } catch {
2582
+ let persistedState = self.defaultChannelStateWithoutLock(file: stateFile)
2583
+ let persistedSnapshot = self.defaultChannelPreviewSnapshotWithoutLock(file: previewSnapshotFile)
2584
+ if persistedState.exists,
2585
+ persistedState.isReadable,
2586
+ persistedState.channel == channel,
2587
+ persistedSnapshot == .invalidated {
2588
+ logger.warn("Default channel preview restore committed before metadata update failed")
2589
+ didPersist = true
2590
+ } else {
2591
+ logger.warn("Cannot durably restore default channel after preview: \(error.localizedDescription)")
2592
+ didPersist = false
2593
+ }
2594
+ }
2595
+ self.applyRestoredDefaultChannelFromPreview(channel)
2596
+ return didPersist
2044
2597
  }
2045
2598
 
2046
2599
  private func normalizedPreviewAppId(_ rawAppId: String?) -> String? {
@@ -2220,7 +2773,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2220
2773
  self.shakeMenuGesture = Self.normalizedShakeMenuGesture(self.getStringConfig("shakeMenuGesture", defaultValue: Self.shakeMenuGestureShake))
2221
2774
  self.syncShakeMenuGestureRecognizer()
2222
2775
  self.restorePreviewPreviousAppId()
2223
- self.restorePreviewPreviousDefaultChannel()
2776
+ if !self.restorePreviewPreviousDefaultChannel() {
2777
+ logger.warn("Default channel preview restore will retry on next launch")
2778
+ }
2224
2779
  _ = self.implementation.setPreviewFallbackBundle(fallback: nil)
2225
2780
  _ = self.implementation.setNextBundle(next: Optional<String>.none)
2226
2781
  self.clearPreviewSessionPreferences()
@@ -2252,7 +2807,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2252
2807
  guard self.previewSessionEnabled else {
2253
2808
  return
2254
2809
  }
2255
- if let topVC = UIApplication.topViewController(),
2810
+ if let topVC = UIApplication.topViewController(self.bridge?.viewController),
2256
2811
  topVC.isKind(of: UIAlertController.self) {
2257
2812
  self.previewSessionAlertPending = true
2258
2813
  UserDefaults.standard.set(true, forKey: self.previewSessionAlertPendingDefaultsKey)
@@ -2262,11 +2817,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2262
2817
 
2263
2818
  let alert = UIAlertController(
2264
2819
  title: "Preview started",
2265
- message: "Shake your device anytime to reload or leave the test app.",
2820
+ message: self.shakeMenuGesture == Self.shakeMenuGestureThreeFingerPinch ? "Three-finger pinch to open menu." : "Shake to open menu.",
2266
2821
  preferredStyle: .alert
2267
2822
  )
2268
2823
  alert.addAction(UIAlertAction(title: "Got it", style: .default))
2269
- if let topVC = UIApplication.topViewController() {
2824
+ if let topVC = UIApplication.topViewController(self.bridge?.viewController) {
2270
2825
  topVC.present(alert, animated: true)
2271
2826
  } else {
2272
2827
  self.previewSessionAlertPending = true
@@ -2465,6 +3020,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2465
3020
  "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
2466
3021
  ])
2467
3022
  } else {
3023
+ guard self.persistDefaultChannelStateFromDefaults() else {
3024
+ self.rejectCall(call, message: "Channel override removed but local persistence failed", code: "UNSETCHANNEL_PERSISTENCE_FAILED")
3025
+ return
3026
+ }
2468
3027
  if self._isAutoUpdateEnabled() && triggerAutoUpdate {
2469
3028
  self.logger.info("Calling autoupdater after channel change!")
2470
3029
  // Check if download is already in progress (with timeout protection)
@@ -2511,6 +3070,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2511
3070
  "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"
2512
3071
  ])
2513
3072
  } else {
3073
+ guard self.persistDefaultChannelStateFromDefaults() else {
3074
+ self.rejectCall(call, message: "Channel changed but local persistence failed", code: "SETCHANNEL_PERSISTENCE_FAILED")
3075
+ return
3076
+ }
2514
3077
  if self._isAutoUpdateEnabled() && triggerAutoUpdate {
2515
3078
  self.logger.info("Calling autoupdater after channel change!")
2516
3079
  // Check if download is already in progress (with timeout protection)
@@ -2535,6 +3098,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2535
3098
  "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
2536
3099
  ])
2537
3100
  } else {
3101
+ guard self.persistDefaultChannelStateFromDefaults() else {
3102
+ self.rejectCall(call, message: "Channel synchronized but local persistence failed", code: "GETCHANNEL_PERSISTENCE_FAILED")
3103
+ return
3104
+ }
2538
3105
  self.resolveCall(call, data: res.toDict())
2539
3106
  }
2540
3107
  }
@@ -2674,10 +3241,67 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2674
3241
  ])
2675
3242
  }
2676
3243
 
3244
+ private func reportAppLaunchStart() {
3245
+ guard !self.implementation.statsUrl.isEmpty, !launchStartReported else {
3246
+ return
3247
+ }
3248
+
3249
+ launchStartReported = true
3250
+ let current = self.implementation.getCurrentBundle()
3251
+ self.implementation.sendStats(
3252
+ action: "app_launch_start",
3253
+ versionName: current.getVersionName(),
3254
+ oldVersionName: "",
3255
+ metadata: [
3256
+ "launch_started_at": String(launchStartedAtMs),
3257
+ "source": "plugin_load"
3258
+ ]
3259
+ )
3260
+ }
3261
+
3262
+ private func reportAppLaunchReady(_ bundle: BundleInfo) {
3263
+ guard !self.implementation.statsUrl.isEmpty, !launchReadyReported else {
3264
+ return
3265
+ }
3266
+
3267
+ launchReadyReported = true
3268
+ let duration = max(0, Int64(Date().timeIntervalSince1970 * 1000) - launchStartedAtMs)
3269
+ self.implementation.sendStats(
3270
+ action: "app_launch_ready",
3271
+ versionName: bundle.getVersionName(),
3272
+ oldVersionName: "",
3273
+ metadata: [
3274
+ "duration_ms": String(duration),
3275
+ "launch_started_at": String(launchStartedAtMs),
3276
+ "source": "notify_app_ready"
3277
+ ]
3278
+ )
3279
+ }
3280
+
3281
+ private func reportAppLaunchTimeout(_ bundle: BundleInfo) {
3282
+ guard !self.implementation.statsUrl.isEmpty else {
3283
+ return
3284
+ }
3285
+
3286
+ let duration = max(0, Int64(Date().timeIntervalSince1970 * 1000) - launchStartedAtMs)
3287
+ self.implementation.sendStats(
3288
+ action: "app_launch_timeout",
3289
+ versionName: bundle.getVersionName(),
3290
+ oldVersionName: "",
3291
+ metadata: [
3292
+ "duration_ms": String(duration),
3293
+ "launch_started_at": String(launchStartedAtMs),
3294
+ "timeout_ms": String(appReadyTimeout),
3295
+ "source": "app_ready_timeout"
3296
+ ]
3297
+ )
3298
+ }
3299
+
2677
3300
  @objc func notifyAppReady(_ call: CAPPluginCall) {
2678
3301
  self.semaphoreDown()
2679
3302
  let bundle = self.implementation.getCurrentBundle()
2680
3303
  self.implementation.setSuccess(bundle: bundle, autoDeletePrevious: self.autoDeletePrevious)
3304
+ self.reportAppLaunchReady(bundle)
2681
3305
  logger.info("Current bundle loaded successfully. [notifyAppReady was called] \(bundle.toString())")
2682
3306
  self.clearIncomingPreviewTransition()
2683
3307
  self.hidePreviewTransitionLoader(reason: "notify-app-ready")
@@ -2785,16 +3409,26 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2785
3409
  "bundle": current.toJSON()
2786
3410
  ])
2787
3411
  self.persistLastFailedBundle(current)
3412
+ self.reportAppLaunchTimeout(current)
2788
3413
  self.implementation.sendStats(action: "update_fail", versionName: current.getVersionName())
2789
3414
  self.implementation.setError(bundle: current)
2790
3415
  _ = self.performReset(toLastSuccessful: true, usePendingBundle: false, isInternal: true)
2791
3416
  if self.autoDeleteFailed && !current.isBuiltin() {
3417
+ let failedId = current.getId()
2792
3418
  logger.info("Deleting failing bundle: \(current.toString())")
2793
- let res = self.implementation.delete(id: current.getId(), removeInfo: false)
2794
- if !res {
2795
- logger.info("Delete version deleted: \(current.toString())")
2796
- } else {
2797
- logger.error("Failed to delete failed bundle: \(current.toString())")
3419
+ // Mark before async work so kill/OOM still resumes via drainPendingDeletes.
3420
+ self.implementation.saveBundleInfo(
3421
+ id: failedId,
3422
+ bundle: current.setStatus(status: BundleStatus.DELETING.storedValue)
3423
+ )
3424
+ UserDefaults.standard.synchronize()
3425
+ DispatchQueue.global(qos: .utility).async {
3426
+ let res = self.implementation.delete(id: failedId, removeInfo: false)
3427
+ if res {
3428
+ self.logger.info("Failed bundle deleted: \(failedId)")
3429
+ } else {
3430
+ self.logger.error("Failed to delete failed bundle: \(failedId)")
3431
+ }
2798
3432
  }
2799
3433
  }
2800
3434
  } else {
@@ -2819,7 +3453,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2819
3453
  func sendReadyToJs(current: BundleInfo, msg: String) {
2820
3454
  logger.info("sendReadyToJs")
2821
3455
  DispatchQueue.global().async {
2822
- self.semaphoreWait(waitTime: self.appReadyTimeout)
3456
+ // Only wait after load()/reload() armed the semaphore. Foreground resumes
3457
+ // with autoUpdate disabled call sendReadyToJs again without a fresh
3458
+ // notifyAppReady(), so waiting there always timed out after appReadyTimeout.
3459
+ if self.consumePendingNotifyAppReady() {
3460
+ self.semaphoreWait(waitTime: self.appReadyTimeout)
3461
+ }
2823
3462
  self.notifyListeners("appReady", data: ["bundle": current.toJSON(), "status": msg], retainUntilConsumed: true)
2824
3463
 
2825
3464
  // Auto hide splashscreen if enabled
@@ -2831,6 +3470,16 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2831
3470
  }
2832
3471
  }
2833
3472
 
3473
+ private func consumePendingNotifyAppReady() -> Bool {
3474
+ let shouldWait = pendingNotifyAppReady
3475
+ pendingNotifyAppReady = false
3476
+ return shouldWait
3477
+ }
3478
+
3479
+ private func clearPendingNotifyAppReady() {
3480
+ pendingNotifyAppReady = false
3481
+ }
3482
+
2834
3483
  private func hideSplashscreen() {
2835
3484
  if Thread.isMainThread {
2836
3485
  self.performHideSplashscreen()
@@ -3382,6 +4031,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3382
4031
  isAutoUpdateModeEnabled(autoUpdateMode) && autoUpdateMode != autoUpdateModeOnlyDownload
3383
4032
  }
3384
4033
 
4034
+ /// Incomplete leftovers must be downloaded again. Android already skips
4035
+ /// `DOWNLOADING` here; iOS used to treat them as ready, then `setNextBundle`
4036
+ /// failed quietly and the update never applied.
4037
+ static func shouldRetryDownloadForExistingBundle(_ bundle: BundleInfo) -> Bool {
4038
+ bundle.isDeleted() || bundle.isDeleting() || bundle.isDownloading()
4039
+ }
4040
+
3385
4041
  static func isDirectUpdateMode(_ directUpdateMode: String) -> Bool {
3386
4042
  directUpdateMode == autoUpdateModeInstall || directUpdateMode == autoUpdateModeLaunch || directUpdateMode == autoUpdateModeAlways
3387
4043
  }
@@ -3444,6 +4100,22 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3444
4100
  self.currentBuildVersion = currentBuildVersion
3445
4101
  }
3446
4102
 
4103
+ func setAppReadyTimeoutForTesting(_ timeout: Int) {
4104
+ self.appReadyTimeout = timeout
4105
+ }
4106
+
4107
+ func armPendingNotifyAppReadyForTesting() {
4108
+ pendingNotifyAppReady = true
4109
+ }
4110
+
4111
+ func clearPendingNotifyAppReadyForTesting() {
4112
+ clearPendingNotifyAppReady()
4113
+ }
4114
+
4115
+ var isPendingNotifyAppReadyForTesting: Bool {
4116
+ pendingNotifyAppReady
4117
+ }
4118
+
3447
4119
  func shouldUseDirectUpdateForTesting() -> Bool {
3448
4120
  self.shouldUseDirectUpdate()
3449
4121
  }
@@ -3593,7 +4265,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3593
4265
  func runBackgroundDownloadWork(_ work: @escaping () -> Void) {
3594
4266
  // Live update checks/downloads are user-visible work. Using `.background`
3595
4267
  // lets the scheduler starve them for minutes while the app is active.
3596
- DispatchQueue.global(qos: .utility).async(execute: work)
4268
+ DispatchQueue.global(qos: .userInitiated).async(execute: work)
3597
4269
  }
3598
4270
 
3599
4271
  private func beginDownloadBackgroundTask() {
@@ -3644,8 +4316,6 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3644
4316
  }
3645
4317
 
3646
4318
  self.runBackgroundDownloadWork {
3647
- // Wait for cleanup to complete before starting download
3648
- self.waitForCleanupIfNeeded()
3649
4319
  if self.shouldBlockAutoUpdateForPreviewSession() {
3650
4320
  self.clearDownloadInProgressState()
3651
4321
  return
@@ -3653,7 +4323,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3653
4323
  self.beginDownloadBackgroundTask()
3654
4324
  self.logger.info("Check for update via \(self.updateUrl)")
3655
4325
  let res = self.implementation.getLatest(url: url, channel: nil)
3656
- let current = self.implementation.getCurrentBundle()
4326
+ var current = self.implementation.getCurrentBundle()
3657
4327
  if self.shouldBlockAutoUpdateForPreviewSession() {
3658
4328
  self.clearDownloadInProgressState()
3659
4329
  self.endBackGroundTask()
@@ -3672,6 +4342,16 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3672
4342
  )
3673
4343
  return
3674
4344
  }
4345
+ // File mutations wait here. getLatest already ran in parallel with cleanup.
4346
+ do {
4347
+ try self.waitForCleanupIfNeeded()
4348
+ } catch {
4349
+ self.logger.error("Cleanup still running, skipping download")
4350
+ self.clearDownloadInProgressState()
4351
+ self.endBackGroundTask()
4352
+ return
4353
+ }
4354
+ current = self.implementation.getCurrentBundle()
3675
4355
  if res.version == "builtin" {
3676
4356
  self.logger.info("Latest version is builtin")
3677
4357
  let directUpdateAllowed = plannedDirectUpdate && !self.autoSplashscreenTimedOut
@@ -3733,14 +4413,16 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3733
4413
  do {
3734
4414
  self.logger.info("New bundle: \(latestVersionName) found. Current is: \(current.getVersionName()). \(messageUpdate)")
3735
4415
  var nextImpl = self.implementation.getBundleInfoByVersionName(version: latestVersionName)
3736
- if nextImpl == nil || nextImpl?.isDeleted() == true {
3737
- if nextImpl?.isDeleted() == true {
3738
- self.logger.info("Latest bundle already exists and will be deleted, download will overwrite it.")
3739
- let res = self.implementation.delete(id: nextImpl!.getId(), removeInfo: true)
3740
- if res {
3741
- self.logger.info("Failed bundle deleted: \(nextImpl!.toString())")
4416
+ let needsDownload = nextImpl.map(Self.shouldRetryDownloadForExistingBundle) ?? true
4417
+ if needsDownload {
4418
+ if let existing = nextImpl {
4419
+ self.logger.info("Latest bundle already exists in incomplete state (\(existing.getStatus())) and will be deleted, download will overwrite it.")
4420
+ _ = self.implementation.setNextBundle(next: Optional<String>.none)
4421
+ let deleted = self.implementation.delete(id: existing.getId(), removeInfo: true)
4422
+ if deleted {
4423
+ self.logger.info("Incomplete bundle deleted: \(existing.toString())")
3742
4424
  } else {
3743
- self.logger.error("Failed to delete failed bundle: \(nextImpl!.toString())")
4425
+ self.logger.error("Failed to delete incomplete bundle: \(existing.toString())")
3744
4426
  }
3745
4427
  }
3746
4428
  self.consumeOnLaunchDirectUpdateAttempt(plannedDirectUpdate: plannedDirectUpdate)
@@ -3827,8 +4509,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3827
4509
  error: false,
3828
4510
  plannedDirectUpdate: plannedDirectUpdate
3829
4511
  )
3830
- } else {
3831
- _ = self.implementation.setNextBundle(next: next.getId())
4512
+ } else if self.queueBundleForNextBackgroundInstall(next) {
3832
4513
  self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
3833
4514
  self.endBackGroundTaskWithNotif(
3834
4515
  msg: "Direct update reload failed, update will install next background",
@@ -3837,20 +4518,35 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3837
4518
  error: false,
3838
4519
  plannedDirectUpdate: plannedDirectUpdate
3839
4520
  )
4521
+ } else {
4522
+ self.endBackGroundTaskWithNotif(
4523
+ msg: "Direct update reload failed, and next bundle could not be queued",
4524
+ latestVersionName: latestVersionName,
4525
+ current: current,
4526
+ plannedDirectUpdate: plannedDirectUpdate
4527
+ )
3840
4528
  }
3841
4529
  } else if self.shouldAutoSetNextBundle() {
3842
4530
  if plannedDirectUpdate && !directUpdateAllowed {
3843
4531
  self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will install on next app background.")
3844
4532
  }
3845
- self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
3846
- _ = self.implementation.setNextBundle(next: next.getId())
3847
- self.endBackGroundTaskWithNotif(
3848
- msg: "update downloaded, will install next background",
3849
- latestVersionName: latestVersionName,
3850
- current: current,
3851
- error: false,
3852
- plannedDirectUpdate: plannedDirectUpdate
3853
- )
4533
+ if self.queueBundleForNextBackgroundInstall(next) {
4534
+ self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
4535
+ self.endBackGroundTaskWithNotif(
4536
+ msg: "update downloaded, will install next background",
4537
+ latestVersionName: latestVersionName,
4538
+ current: current,
4539
+ error: false,
4540
+ plannedDirectUpdate: plannedDirectUpdate
4541
+ )
4542
+ } else {
4543
+ self.endBackGroundTaskWithNotif(
4544
+ msg: "Update downloaded, but next bundle could not be queued",
4545
+ latestVersionName: latestVersionName,
4546
+ current: current,
4547
+ plannedDirectUpdate: plannedDirectUpdate
4548
+ )
4549
+ }
3854
4550
  } else {
3855
4551
  self.logger.info("autoUpdate is set to onlyDownload, downloaded update will not be set as next bundle")
3856
4552
  self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()], retainUntilConsumed: true)
@@ -4014,6 +4710,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4014
4710
 
4015
4711
  let current: BundleInfo = self.implementation.getCurrentBundle()
4016
4712
  self.implementation.sendStats(action: "app_moved_to_background", versionName: current.getVersionName())
4713
+ self.implementation.persistPendingStats()
4017
4714
  logger.info("Check for pending update")
4018
4715
 
4019
4716
  // Show splashscreen only if autoSplashscreen is enabled AND autoUpdate is enabled AND directUpdate would be used