@capgo/capacitor-updater 7.50.2 → 7.51.16

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 (32) hide show
  1. package/CapgoCapacitorUpdater.podspec +0 -2
  2. package/Package.swift +4 -5
  3. package/README.md +53 -48
  4. package/android/build.gradle +6 -6
  5. package/android/src/main/java/ee/forgr/capacitor_updater/AppLifecycleObserver.java +29 -2
  6. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +7 -3
  7. package/android/src/main/java/ee/forgr/capacitor_updater/BundleStatus.java +1 -0
  8. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +427 -116
  9. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1108 -182
  10. package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +102 -31
  11. package/android/src/main/java/ee/forgr/capacitor_updater/DataManager.java +23 -7
  12. package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +2 -2
  13. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +11 -0
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +513 -201
  15. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +103 -3
  16. package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +1 -1
  17. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +115 -133
  18. package/dist/docs.json +32 -8
  19. package/dist/esm/definitions.d.ts +41 -17
  20. package/dist/esm/definitions.js.map +1 -1
  21. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +124 -0
  22. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +9 -1
  23. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +3 -0
  24. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +806 -92
  25. package/ios/Sources/CapacitorUpdaterPlugin/CapgoRawRsa.swift +36 -0
  26. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1020 -270
  27. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +49 -31
  28. package/ios/Sources/CapacitorUpdaterPlugin/RSA.swift +73 -251
  29. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +44 -20
  30. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +28 -0
  31. package/package.json +13 -8
  32. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +0 -39
@@ -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.16"
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,11 @@ 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
222
+ private let semaphoreWaitTestingLock = NSLock()
223
+ private var didEnterSemaphoreWaitForTesting = false
194
224
 
195
225
  private var delayUpdateUtils: DelayUpdateUtils!
196
226
 
@@ -216,6 +246,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
216
246
  self.implementation.deviceID = DeviceIdHelper.getOrCreateDeviceId()
217
247
  persistCustomId = getConfig().getBoolean("persistCustomId", false)
218
248
  allowSetDefaultChannel = getConfig().getBoolean("allowSetDefaultChannel", true)
249
+ persistDefaultChannelOnReinstall = getConfig().getBoolean("persistDefaultChannelOnReinstall", true)
219
250
  if persistCustomId {
220
251
  let storedCustomId = UserDefaults.standard.string(forKey: customIdDefaultsKey) ?? ""
221
252
  if !storedCustomId.isEmpty {
@@ -331,14 +362,61 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
331
362
  logger.info("Loaded persisted channelUrl")
332
363
  }
333
364
  }
365
+ implementation.restorePendingStats()
334
366
 
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 {
367
+ let nativeBuildVersionChanged = self.hasNativeBuildVersionChanged()
368
+ let defaultChannelPersistenceDisabled = !persistDefaultChannelOnReinstall
369
+ let restoredReinstall = defaultChannelPersistenceDisabled && self.isRestoredReinstall()
370
+ var installMarkerCanBePrepared = true
371
+ if shouldClearPersistedDefaultChannel(
372
+ nativeBuildVersionChanged: nativeBuildVersionChanged,
373
+ resetWhenUpdate: resetWhenUpdate,
374
+ restoredReinstall: restoredReinstall
375
+ ) {
376
+ installMarkerCanBePrepared = clearPersistedDefaultChannel()
377
+ if installMarkerCanBePrepared {
378
+ logger.info("Cleared persisted defaultChannel because reinstall persistence is disabled")
379
+ } else {
380
+ logger.warn("Cannot durably clear persisted defaultChannel")
381
+ self.defaultChannelCleanupMustRetry = true
382
+ self.invalidateDefaultChannelInstallMarker()
383
+ }
384
+ }
385
+ if defaultChannelPersistenceDisabled && installMarkerCanBePrepared {
386
+ self.prepareDefaultChannelInstallMarker()
387
+ }
388
+ if !previewSessionEnabled,
389
+ !defaultChannelCleanupMustRetry,
390
+ self.hasPendingDefaultChannelPreviewSnapshot(),
391
+ !self.restorePreviewPreviousDefaultChannel() {
392
+ logger.warn("Default channel preview restore remains pending")
393
+ }
394
+
395
+ let configDefaultChannel = getConfig().getString("defaultChannel", "")!
396
+ let storedDefaultChannel = UserDefaults.standard.string(forKey: defaultChannelDefaultsKey)
397
+ let stateFile = self.defaultChannelStateFile()
398
+ let state = stateFile.map { self.defaultChannelState(file: $0) }
399
+ if defaultChannelCleanupMustRetry {
400
+ implementation.defaultChannel = configDefaultChannel
401
+ logger.warn("Using configured defaultChannel until persisted cleanup can retry")
402
+ } else if let state = state, state.exists, state.isReadable {
403
+ self.reconcileDefaultChannelDefaults(with: state.channel)
404
+ implementation.defaultChannel = state.channel ?? configDefaultChannel
405
+ logger.info("Loaded persisted defaultChannel from local state")
406
+ } else if defaultChannelPersistenceDisabled, state?.exists == true {
407
+ implementation.defaultChannel = configDefaultChannel
408
+ logger.warn("Ignoring unreadable persisted defaultChannel while reinstall persistence is disabled")
409
+ } else if let storedDefaultChannel = storedDefaultChannel {
337
410
  implementation.defaultChannel = storedDefaultChannel
338
411
  logger.info("Loaded persisted defaultChannel from setChannel()")
339
412
  } else {
340
- implementation.defaultChannel = getConfig().getString("defaultChannel", "")!
413
+ implementation.defaultChannel = configDefaultChannel
341
414
  }
415
+ if !defaultChannelCleanupMustRetry,
416
+ state?.exists != true || (!defaultChannelPersistenceDisabled && state?.isReadable != true) {
417
+ _ = self.persistDefaultChannelStateFromDefaults()
418
+ }
419
+ self.reportAppLaunchStart()
342
420
  self.implementation.autoReset()
343
421
  let appHealthTracker = AppHealthTracker(implementation: self.implementation)
344
422
  self.appHealthTracker = appHealthTracker
@@ -347,16 +425,26 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
347
425
 
348
426
  // Check if app was recently installed/updated BEFORE cleanup updates the stored native build version.
349
427
  self.wasRecentlyInstalledOrUpdated = self.checkIfRecentlyInstalledOrUpdated()
350
- let nativeBuildVersionChanged = self.hasNativeBuildVersionChanged()
351
428
  if nativeBuildVersionChanged {
352
429
  self.clearPreviewSessionForNativeBuildChange()
353
430
  }
354
431
  self.leavePreviewSessionForLaunchURLIfNeeded()
355
432
 
356
- if resetWhenUpdate {
357
- let didResetCurrentBundle = self.resetCurrentBundleForNativeBuildChangeIfNeeded()
358
- self.cleanupObsoleteVersions(didResetCurrentBundle: didResetCurrentBundle)
433
+ // Downloads (including shake-menu / CapgoUpdater entry points) wait on this gate.
434
+ self.implementation.beforeDownload = { [weak self] in
435
+ try self?.waitForCleanupIfNeeded()
359
436
  }
437
+ // Always run async cleanup: delete obsolete bundles on native update (when enabled)
438
+ // and sweep orphan directories every launch. Must not block app startup.
439
+ if !resetWhenUpdate {
440
+ UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
441
+ UserDefaults.standard.synchronize()
442
+ }
443
+ let didResetCurrentBundle = resetWhenUpdate ? self.resetCurrentBundleForNativeBuildChangeIfNeeded() : false
444
+ self.cleanupObsoleteVersions(
445
+ resetWhenUpdate: resetWhenUpdate,
446
+ didResetCurrentBundle: didResetCurrentBundle
447
+ )
360
448
  self.reportNativeVersionStatsIfChanged()
361
449
 
362
450
  // Load the server
@@ -511,6 +599,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
511
599
 
512
600
  private func semaphoreWait(waitTime: Int) {
513
601
  // print("\\(CapgoUpdater.TAG) semaphoreWait \\(waitTime)")
602
+ semaphoreWaitTestingLock.lock()
603
+ didEnterSemaphoreWaitForTesting = true
604
+ semaphoreWaitTestingLock.unlock()
514
605
  let result = semaphoreReady.wait(timeout: .now() + .milliseconds(waitTime))
515
606
  if result == .timedOut {
516
607
  logger.error("Semaphore wait timed out after \(waitTime)ms")
@@ -518,6 +609,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
518
609
  }
519
610
 
520
611
  private func semaphoreUp() {
612
+ pendingNotifyAppReady = true
521
613
  DispatchQueue.global().async {
522
614
  self.semaphoreWait(waitTime: 0)
523
615
  }
@@ -531,6 +623,327 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
531
623
  UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
532
624
  }
533
625
 
626
+ func shouldClearPersistedDefaultChannel(
627
+ nativeBuildVersionChanged: Bool,
628
+ resetWhenUpdate: Bool,
629
+ restoredReinstall: Bool
630
+ ) -> Bool {
631
+ !persistDefaultChannelOnReinstall && (restoredReinstall || (resetWhenUpdate && nativeBuildVersionChanged))
632
+ }
633
+
634
+ func clearPersistedDefaultChannel() -> Bool {
635
+ guard let stateFile = self.defaultChannelStateFile(),
636
+ let previewSnapshotFile = self.defaultChannelPreviewSnapshotFile() else {
637
+ return false
638
+ }
639
+ return self.clearPersistedDefaultChannel(
640
+ stateFile: stateFile,
641
+ previewSnapshotFile: previewSnapshotFile
642
+ )
643
+ }
644
+
645
+ func clearPersistedDefaultChannel(stateFile: URL, previewSnapshotFile: URL) -> Bool {
646
+ defaultChannelStateLock.lock()
647
+ defer { defaultChannelStateLock.unlock() }
648
+ do {
649
+ try self.persistDefaultChannelStateWithoutLock(channel: nil, file: stateFile)
650
+ try self.persistDefaultChannelPreviewSnapshotWithoutLock(
651
+ channel: nil,
652
+ isValid: false,
653
+ file: previewSnapshotFile
654
+ )
655
+ UserDefaults.standard.removeObject(forKey: defaultChannelDefaultsKey)
656
+ UserDefaults.standard.removeObject(forKey: previewPreviousDefaultChannelDefaultsKey)
657
+ UserDefaults.standard.removeObject(forKey: previewPreviousDefaultChannelWasSetDefaultsKey)
658
+ return true
659
+ } catch {
660
+ logger.warn("Cannot persist cleared default channel state: \(error.localizedDescription)")
661
+ return false
662
+ }
663
+ }
664
+
665
+ private func defaultChannelStateFile() -> URL? {
666
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
667
+ .appendingPathComponent(defaultChannelStateFilename)
668
+ }
669
+
670
+ private func defaultChannelPreviewSnapshotFile() -> URL? {
671
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
672
+ .appendingPathComponent(defaultChannelPreviewSnapshotFilename)
673
+ }
674
+
675
+ func defaultChannelState(file: URL) -> (exists: Bool, channel: String?, isReadable: Bool) {
676
+ defaultChannelStateLock.lock()
677
+ defer { defaultChannelStateLock.unlock() }
678
+ return self.defaultChannelStateWithoutLock(file: file)
679
+ }
680
+
681
+ private func defaultChannelStateWithoutLock(file: URL) -> (exists: Bool, channel: String?, isReadable: Bool) {
682
+ let fileManager = FileManager.default
683
+ guard fileManager.fileExists(atPath: file.path) else {
684
+ return (false, nil, true)
685
+ }
686
+ do {
687
+ let data = try Data(contentsOf: file)
688
+ guard !data.isEmpty else {
689
+ return (true, nil, true)
690
+ }
691
+ guard let channel = String(data: data, encoding: .utf8) else {
692
+ logger.warn("Cannot decode persisted default channel state")
693
+ return (true, nil, false)
694
+ }
695
+ return (true, channel, true)
696
+ } catch {
697
+ logger.warn("Cannot read persisted default channel state: \(error.localizedDescription)")
698
+ return (true, nil, false)
699
+ }
700
+ }
701
+
702
+ func persistDefaultChannelState(channel: String?, file: URL) throws {
703
+ defaultChannelStateLock.lock()
704
+ defer { defaultChannelStateLock.unlock() }
705
+ try self.persistDefaultChannelStateWithoutLock(channel: channel, file: file)
706
+ }
707
+
708
+ private func persistDefaultChannelStateWithoutLock(channel: String?, file: URL) throws {
709
+ let data = channel.map { Data($0.utf8) } ?? Data()
710
+ try self.persistBackupExcludedFile(file: file, data: data, overwrite: true)
711
+ }
712
+
713
+ func defaultChannelPreviewSnapshot(file: URL) -> DefaultChannelPreviewSnapshot {
714
+ defaultChannelStateLock.lock()
715
+ defer { defaultChannelStateLock.unlock() }
716
+ return self.defaultChannelPreviewSnapshotWithoutLock(file: file)
717
+ }
718
+
719
+ private func defaultChannelPreviewSnapshotWithoutLock(file: URL) -> DefaultChannelPreviewSnapshot {
720
+ guard FileManager.default.fileExists(atPath: file.path) else {
721
+ return .missing
722
+ }
723
+ do {
724
+ let data = try Data(contentsOf: file)
725
+ guard let kind = data.first else {
726
+ return .unreadable
727
+ }
728
+ switch kind {
729
+ case 0 where data.count == 1:
730
+ return .invalidated
731
+ case 1 where data.count == 1:
732
+ return .snapshot(nil)
733
+ case 2:
734
+ guard let channel = String(data: Data(data.dropFirst()), encoding: .utf8) else {
735
+ return .unreadable
736
+ }
737
+ return .snapshot(channel)
738
+ default:
739
+ return .unreadable
740
+ }
741
+ } catch {
742
+ logger.warn("Cannot read default channel preview snapshot: \(error.localizedDescription)")
743
+ return .unreadable
744
+ }
745
+ }
746
+
747
+ func persistDefaultChannelPreviewSnapshot(channel: String?, file: URL) throws {
748
+ defaultChannelStateLock.lock()
749
+ defer { defaultChannelStateLock.unlock() }
750
+ try self.persistDefaultChannelPreviewSnapshotWithoutLock(
751
+ channel: channel,
752
+ isValid: true,
753
+ file: file
754
+ )
755
+ }
756
+
757
+ func invalidateDefaultChannelPreviewSnapshot(file: URL) throws {
758
+ defaultChannelStateLock.lock()
759
+ defer { defaultChannelStateLock.unlock() }
760
+ try self.persistDefaultChannelPreviewSnapshotWithoutLock(
761
+ channel: nil,
762
+ isValid: false,
763
+ file: file
764
+ )
765
+ }
766
+
767
+ private func persistDefaultChannelPreviewSnapshotWithoutLock(
768
+ channel: String?,
769
+ isValid: Bool,
770
+ file: URL
771
+ ) throws {
772
+ var data = Data()
773
+ if !isValid {
774
+ data.append(0)
775
+ } else if let channel = channel {
776
+ data.append(2)
777
+ data.append(contentsOf: channel.utf8)
778
+ } else {
779
+ data.append(1)
780
+ }
781
+ try self.persistBackupExcludedFile(file: file, data: data, overwrite: true)
782
+ }
783
+
784
+ private func persistDefaultChannelPreviewSnapshot(channel: String?) -> Bool {
785
+ guard let file = self.defaultChannelPreviewSnapshotFile() else {
786
+ return false
787
+ }
788
+ do {
789
+ try self.persistDefaultChannelPreviewSnapshot(channel: channel, file: file)
790
+ return true
791
+ } catch {
792
+ logger.warn("Cannot persist default channel preview snapshot: \(error.localizedDescription)")
793
+ return false
794
+ }
795
+ }
796
+
797
+ private func hasPendingDefaultChannelPreviewSnapshot() -> Bool {
798
+ guard let file = self.defaultChannelPreviewSnapshotFile() else {
799
+ return false
800
+ }
801
+ switch self.defaultChannelPreviewSnapshot(file: file) {
802
+ case .snapshot, .unreadable:
803
+ return true
804
+ case .missing, .invalidated:
805
+ return false
806
+ }
807
+ }
808
+
809
+ @discardableResult
810
+ func persistDefaultChannelStateFromDefaults() -> Bool {
811
+ guard let file = self.defaultChannelStateFile() else {
812
+ logger.warn("Cannot locate default channel state file")
813
+ return false
814
+ }
815
+ return self.persistDefaultChannelStateFromDefaults(stateFile: file)
816
+ }
817
+
818
+ @discardableResult
819
+ func persistDefaultChannelStateFromDefaults(stateFile: URL) -> Bool {
820
+ defaultChannelStateLock.lock()
821
+ defer { defaultChannelStateLock.unlock() }
822
+ do {
823
+ try self.persistDefaultChannelStateWithoutLock(
824
+ channel: UserDefaults.standard.string(forKey: defaultChannelDefaultsKey),
825
+ file: stateFile
826
+ )
827
+ return true
828
+ } catch {
829
+ do {
830
+ if FileManager.default.fileExists(atPath: stateFile.path) {
831
+ try FileManager.default.removeItem(at: stateFile)
832
+ }
833
+ logger.warn("Cannot persist default channel state; falling back to UserDefaults: \(error.localizedDescription)")
834
+ return true
835
+ } catch let invalidationError {
836
+ logger.warn("Cannot persist or invalidate default channel state: \(invalidationError.localizedDescription)")
837
+ return false
838
+ }
839
+ }
840
+ }
841
+
842
+ private func persistedDefaultChannel() -> String? {
843
+ guard let file = self.defaultChannelStateFile() else {
844
+ return UserDefaults.standard.string(forKey: defaultChannelDefaultsKey)
845
+ }
846
+ return self.persistedDefaultChannel(stateFile: file)
847
+ }
848
+
849
+ func persistedDefaultChannel(stateFile: URL) -> String? {
850
+ let state = self.defaultChannelState(file: stateFile)
851
+ if state.exists {
852
+ if state.isReadable {
853
+ return state.channel
854
+ }
855
+ if !persistDefaultChannelOnReinstall {
856
+ return nil
857
+ }
858
+ }
859
+ return UserDefaults.standard.string(forKey: defaultChannelDefaultsKey)
860
+ }
861
+
862
+ private func reconcileDefaultChannelDefaults(with channel: String?) {
863
+ if let channel = channel {
864
+ UserDefaults.standard.set(channel, forKey: defaultChannelDefaultsKey)
865
+ } else {
866
+ UserDefaults.standard.removeObject(forKey: defaultChannelDefaultsKey)
867
+ }
868
+ }
869
+
870
+ private func defaultChannelInstallMarker() -> URL? {
871
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
872
+ .appendingPathComponent(defaultChannelInstallMarkerFilename)
873
+ }
874
+
875
+ private func invalidateDefaultChannelInstallMarker() {
876
+ guard let marker = self.defaultChannelInstallMarker() else {
877
+ return
878
+ }
879
+ do {
880
+ try self.invalidateDefaultChannelInstallMarker(marker: marker)
881
+ } catch {
882
+ logger.warn("Cannot invalidate default channel install marker for cleanup retry: \(error.localizedDescription)")
883
+ }
884
+ }
885
+
886
+ func invalidateDefaultChannelInstallMarker(marker: URL) throws {
887
+ let fileManager = FileManager.default
888
+ if fileManager.fileExists(atPath: marker.path) {
889
+ try fileManager.removeItem(at: marker)
890
+ }
891
+ }
892
+
893
+ func isRestoredReinstall() -> Bool {
894
+ guard let marker = self.defaultChannelInstallMarker() else {
895
+ return false
896
+ }
897
+ return self.isRestoredReinstall(
898
+ marker: marker,
899
+ markerWasCreated: UserDefaults.standard.bool(forKey: defaultChannelInstallMarkerDefaultsKey)
900
+ )
901
+ }
902
+
903
+ func isRestoredReinstall(marker: URL, markerWasCreated: Bool) -> Bool {
904
+ markerWasCreated && !FileManager.default.fileExists(atPath: marker.path)
905
+ }
906
+
907
+ private func prepareDefaultChannelInstallMarker() {
908
+ guard let marker = self.defaultChannelInstallMarker() else {
909
+ return
910
+ }
911
+ self.prepareDefaultChannelInstallMarker(
912
+ marker: marker,
913
+ markerWasCreated: UserDefaults.standard.bool(forKey: defaultChannelInstallMarkerDefaultsKey)
914
+ )
915
+ }
916
+
917
+ func prepareDefaultChannelInstallMarker(marker: URL, markerWasCreated: Bool) {
918
+ do {
919
+ try self.persistBackupExcludedFile(file: marker, data: Data())
920
+ if !markerWasCreated {
921
+ UserDefaults.standard.set(true, forKey: defaultChannelInstallMarkerDefaultsKey)
922
+ UserDefaults.standard.synchronize()
923
+ }
924
+ } catch {
925
+ try? FileManager.default.removeItem(at: marker)
926
+ UserDefaults.standard.set(false, forKey: defaultChannelInstallMarkerDefaultsKey)
927
+ UserDefaults.standard.synchronize()
928
+ logger.warn("Cannot prepare default channel install marker: \(error.localizedDescription)")
929
+ }
930
+ }
931
+
932
+ private func persistBackupExcludedFile(file: URL, data: Data, overwrite: Bool = false) throws {
933
+ let fileManager = FileManager.default
934
+ if overwrite || !fileManager.fileExists(atPath: file.path) {
935
+ try fileManager.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
936
+ try data.write(to: file, options: .atomic)
937
+ }
938
+ let currentResourceValues = try file.resourceValues(forKeys: [.isExcludedFromBackupKey])
939
+ if currentResourceValues.isExcludedFromBackup != true {
940
+ var resourceValues = URLResourceValues()
941
+ resourceValues.isExcludedFromBackup = true
942
+ var mutableFile = file
943
+ try mutableFile.setResourceValues(resourceValues)
944
+ }
945
+ }
946
+
534
947
  func hasNativeBuildVersionChanged() -> Bool {
535
948
  let previous = self.storedNativeBuildVersion()
536
949
  return previous != "0" && self.currentBuildVersion != previous
@@ -629,12 +1042,35 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
629
1042
  return true
630
1043
  }
631
1044
 
632
- private func cleanupObsoleteVersions(didResetCurrentBundle: Bool = false) {
1045
+ private func cleanupObsoleteVersions(resetWhenUpdate: Bool = true, didResetCurrentBundle: Bool = false) {
1046
+ // Enter before publishing incomplete state so waiters cannot hit an empty group.
1047
+ self.cleanupStateLock.lock()
1048
+ self.cleanupComplete = false
1049
+ self.cleanupTimedOut = false
1050
+ self.cleanupGroup.enter()
1051
+ self.cleanupStateLock.unlock()
633
1052
  cleanupThread = Thread {
1053
+ let bgTaskLock = NSLock()
1054
+ var cleanupBackgroundTask = UIBackgroundTaskIdentifier.invalid
1055
+ let endCleanupBackgroundTask = {
1056
+ bgTaskLock.lock()
1057
+ defer { bgTaskLock.unlock() }
1058
+ if cleanupBackgroundTask != .invalid {
1059
+ UIApplication.shared.endBackgroundTask(cleanupBackgroundTask)
1060
+ cleanupBackgroundTask = .invalid
1061
+ }
1062
+ }
1063
+ cleanupBackgroundTask = UIApplication.shared.beginBackgroundTask(withName: "CapgoBundleCleanup") {
1064
+ endCleanupBackgroundTask()
1065
+ }
634
1066
  self.cleanupLock.lock()
635
1067
  defer {
1068
+ self.cleanupStateLock.lock()
636
1069
  self.cleanupComplete = true
1070
+ self.cleanupStateLock.unlock()
637
1071
  self.cleanupLock.unlock()
1072
+ self.cleanupGroup.leave()
1073
+ endCleanupBackgroundTask()
638
1074
  self.logger.info("Cleanup complete")
639
1075
  }
640
1076
 
@@ -665,67 +1101,92 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
665
1101
  // 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
1102
 
667
1103
  let previous = self.storedNativeBuildVersion()
668
- if previous != "0" && self.currentBuildVersion != previous {
1104
+ let nativeVersionChanged = previous != "0" && self.currentBuildVersion != previous
1105
+ if resetWhenUpdate && nativeVersionChanged {
669
1106
  if !didResetCurrentBundle {
670
1107
  self.logger.info("Native build version changed from \(previous) to \(self.currentBuildVersion). Resetting current bundle to builtin.")
671
1108
  self.implementation.reset(isInternal: true)
672
1109
  }
673
1110
  let res = self.implementation.list()
674
1111
  for version in res {
675
- // Check if thread was cancelled
676
1112
  if Thread.current.isCancelled {
677
- self.logger.warn("Cleanup was cancelled, stopping")
678
1113
  return
679
1114
  }
680
1115
  self.logger.info("Deleting obsolete bundle: \(version.getId())")
681
- let res = self.implementation.delete(id: version.getId())
682
- if !res {
1116
+ let deleted = self.implementation.delete(id: version.getId())
1117
+ if !deleted {
683
1118
  self.logger.error("Delete failed, id \(version.getId()) doesn't exist")
684
1119
  }
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
1120
+ Thread.sleep(forTimeInterval: 0.075)
699
1121
  }
700
1122
  self.implementation.cleanupDeltaCache(threadToCheck: Thread.current)
701
1123
  }
702
- UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
703
- UserDefaults.standard.synchronize()
704
- }
705
- cleanupThread?.start()
706
1124
 
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()
1125
+ if Thread.current.isCancelled {
1126
+ return
1127
+ }
1128
+
1129
+ // Resume any DELETING leftovers from prior kills, one-by-one.
1130
+ self.implementation.drainPendingDeletes()
1131
+
1132
+ if Thread.current.isCancelled {
1133
+ return
1134
+ }
1135
+
1136
+ // Always sweep orphan directories so incomplete prior cleanups (or failed deletes)
1137
+ // cannot leave hundreds of MB behind across launches.
1138
+ let allowedIds = self.implementation.allowedBundleIdsForCleanup()
1139
+ self.implementation.cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: Thread.current)
1140
+ if Thread.current.isCancelled {
1141
+ return
1142
+ }
1143
+ self.implementation.cleanupOrphanedTempFolders(threadToCheck: Thread.current)
1144
+
1145
+ if self.defaultChannelCleanupMustRetry {
1146
+ self.logger.warn("Keeping the previous native build version so default channel cleanup retries")
1147
+ } else {
1148
+ UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
1149
+ UserDefaults.standard.synchronize()
714
1150
  }
715
1151
  }
1152
+ cleanupThread?.start()
1153
+ }
1154
+
1155
+ private func cleanupTimeoutError() -> NSError {
1156
+ NSError(
1157
+ domain: "CapacitorUpdater",
1158
+ code: 1,
1159
+ userInfo: [NSLocalizedDescriptionKey: "Cleanup did not finish before download"]
1160
+ )
716
1161
  }
717
1162
 
718
- private func waitForCleanupIfNeeded() {
719
- if cleanupComplete {
1163
+ private func waitForCleanupIfNeeded() throws {
1164
+ cleanupStateLock.lock()
1165
+ let alreadyDone = cleanupComplete
1166
+ let alreadyTimedOut = cleanupTimedOut
1167
+ cleanupStateLock.unlock()
1168
+ if alreadyDone {
720
1169
  return // Already done, no need to wait
721
1170
  }
1171
+ if alreadyTimedOut {
1172
+ throw cleanupTimeoutError()
1173
+ }
722
1174
 
723
1175
  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
-
1176
+ let result = cleanupGroup.wait(timeout: .now() + .seconds(60))
1177
+ if result == .timedOut {
1178
+ logger.warn("Cleanup wait timed out after 60s, cancelling leftover cleanup")
1179
+ cleanupThread?.cancel()
1180
+ let cancelled = cleanupGroup.wait(timeout: .now() + .seconds(60))
1181
+ if cancelled == .timedOut {
1182
+ cleanupStateLock.lock()
1183
+ cleanupTimedOut = true
1184
+ cleanupStateLock.unlock()
1185
+ logger.error("Cleanup did not finish after cancel, aborting download")
1186
+ throw cleanupTimeoutError()
1187
+ }
1188
+ return
1189
+ }
729
1190
  logger.info("Cleanup finished, proceeding with download")
730
1191
  }
731
1192
 
@@ -1142,6 +1603,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1142
1603
  }
1143
1604
 
1144
1605
  private func downloadBundle(urlString: String, version: String, sessionKey: String, checksum rawChecksum: String, manifestEntries: [ManifestEntry]?) throws -> BundleInfo {
1606
+ // Manual/preview downloads must wait too - launch orphan sweep can delete their temps.
1607
+ try self.waitForCleanupIfNeeded()
1145
1608
  guard let url = URL(string: urlString) else {
1146
1609
  throw makePreviewError("Invalid download URL")
1147
1610
  }
@@ -1293,6 +1756,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1293
1756
 
1294
1757
  let performReload: () -> Bool = {
1295
1758
  guard self.applyCurrentBundleToBridge(bridge) else {
1759
+ self.clearPendingNotifyAppReady()
1296
1760
  return false
1297
1761
  }
1298
1762
  self.checkAppReady()
@@ -1380,6 +1844,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1380
1844
  }
1381
1845
  }
1382
1846
 
1847
+ private func queueBundleForNextBackgroundInstall(_ next: BundleInfo) -> Bool {
1848
+ guard self.implementation.setNextBundle(next: next.getId()) else {
1849
+ self.logger.error("Failed to queue downloaded bundle as next: \(next.toString())")
1850
+ return false
1851
+ }
1852
+ return true
1853
+ }
1854
+
1383
1855
  private func applyDownloadedBundleForDirectUpdate(_ next: BundleInfo) -> Bool {
1384
1856
  let previousState = self.implementation.captureResetState()
1385
1857
  let previousBundleName = self.implementation.getCurrentBundle().getVersionName()
@@ -1497,8 +1969,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1497
1969
  UserDefaults.standard.removeObject(forKey: self.previewPreviousNextBundleDefaultsKey)
1498
1970
  }
1499
1971
 
1972
+ let previousDefaultChannel = self.persistedDefaultChannel()
1973
+ guard self.persistDefaultChannelPreviewSnapshot(channel: previousDefaultChannel) else {
1974
+ logger.error("Could not durably save the default channel preview snapshot")
1975
+ return false
1976
+ }
1500
1977
  UserDefaults.standard.set(self.implementation.appId, forKey: self.previewPreviousAppIdDefaultsKey)
1501
- if let previousDefaultChannel = UserDefaults.standard.object(forKey: self.defaultChannelDefaultsKey) as? String {
1978
+ if let previousDefaultChannel = previousDefaultChannel {
1502
1979
  UserDefaults.standard.set(previousDefaultChannel, forKey: self.previewPreviousDefaultChannelDefaultsKey)
1503
1980
  UserDefaults.standard.set(true, forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
1504
1981
  } else {
@@ -1946,7 +2423,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1946
2423
  ?? self.getBooleanConfig("allowShakeChannelSelector", defaultValue: false)
1947
2424
  self.restorePreviewPreviousNextBundle()
1948
2425
  self.restorePreviewPreviousAppId()
1949
- self.restorePreviewPreviousDefaultChannel()
2426
+ if !self.restorePreviewPreviousDefaultChannel() {
2427
+ logger.warn("Default channel preview restore will retry on next launch")
2428
+ }
1950
2429
 
1951
2430
  self.previewSessionEnabled = false
1952
2431
  self.previewSessionAlertPending = false
@@ -1973,7 +2452,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1973
2452
 
1974
2453
  self.restorePreviewPreviousNextBundle()
1975
2454
  self.restorePreviewPreviousAppId()
1976
- self.restorePreviewPreviousDefaultChannel()
2455
+ if !self.restorePreviewPreviousDefaultChannel() {
2456
+ logger.warn("Default channel preview restore will retry on next launch")
2457
+ }
1977
2458
  self.previewSessionEnabled = false
1978
2459
  self.previewSessionAlertPending = false
1979
2460
  self.isLeavingPreviewForIncomingLink = false
@@ -2001,14 +2482,17 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2001
2482
  }
2002
2483
 
2003
2484
  private func clearPreviewSessionPreferences() {
2485
+ let keepDefaultChannelSnapshotFallback = self.hasPendingDefaultChannelPreviewSnapshot()
2004
2486
  _ = self.implementation.setPreviewFallbackBundle(fallback: nil)
2005
2487
  UserDefaults.standard.removeObject(forKey: self.previewSessionDefaultsKey)
2006
2488
  UserDefaults.standard.removeObject(forKey: self.previewPreviousShakeMenuDefaultsKey)
2007
2489
  UserDefaults.standard.removeObject(forKey: self.previewPreviousShakeChannelSelectorDefaultsKey)
2008
2490
  UserDefaults.standard.removeObject(forKey: self.previewPreviousNextBundleDefaultsKey)
2009
2491
  UserDefaults.standard.removeObject(forKey: self.previewPreviousAppIdDefaultsKey)
2010
- UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelDefaultsKey)
2011
- UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
2492
+ if !keepDefaultChannelSnapshotFallback {
2493
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelDefaultsKey)
2494
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
2495
+ }
2012
2496
  UserDefaults.standard.removeObject(forKey: self.previewAppIdDefaultsKey)
2013
2497
  UserDefaults.standard.removeObject(forKey: self.previewPayloadUrlDefaultsKey)
2014
2498
  UserDefaults.standard.removeObject(forKey: self.previewNameDefaultsKey)
@@ -2026,21 +2510,95 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2026
2510
  logger.info("Restored appId after preview: \(previousAppId)")
2027
2511
  }
2028
2512
 
2029
- private func restorePreviewPreviousDefaultChannel() {
2030
- let configDefaultChannel = self.getStringConfig("defaultChannel", defaultValue: "")
2031
- let hadPreviousDefaultChannel = UserDefaults.standard.object(forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey) as? Bool ?? false
2513
+ private func restorePreviewPreviousDefaultChannel() -> Bool {
2514
+ guard !defaultChannelCleanupMustRetry else {
2515
+ logger.warn("Skipping default channel preview restore until cleanup retries")
2516
+ return false
2517
+ }
2518
+ guard let stateFile = self.defaultChannelStateFile(),
2519
+ let previewSnapshotFile = self.defaultChannelPreviewSnapshotFile() else {
2520
+ return false
2521
+ }
2522
+
2523
+ let previousDefaultChannel: String?
2524
+ switch self.defaultChannelPreviewSnapshot(file: previewSnapshotFile) {
2525
+ case .invalidated:
2526
+ return true
2527
+ case let .snapshot(channel):
2528
+ previousDefaultChannel = channel
2529
+ case .missing:
2530
+ let hadPreviousDefaultChannel = UserDefaults.standard.object(
2531
+ forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey
2532
+ ) as? Bool ?? false
2533
+ previousDefaultChannel = hadPreviousDefaultChannel
2534
+ ? UserDefaults.standard.string(forKey: self.previewPreviousDefaultChannelDefaultsKey)
2535
+ : nil
2536
+ do {
2537
+ try self.persistDefaultChannelPreviewSnapshot(
2538
+ channel: previousDefaultChannel,
2539
+ file: previewSnapshotFile
2540
+ )
2541
+ } catch {
2542
+ logger.warn("Cannot migrate default channel preview snapshot: \(error.localizedDescription)")
2543
+ return false
2544
+ }
2545
+ case .unreadable:
2546
+ logger.warn("Cannot restore default channel from an unreadable preview snapshot")
2547
+ return false
2548
+ }
2032
2549
 
2033
- guard hadPreviousDefaultChannel,
2034
- let previousDefaultChannel = UserDefaults.standard.string(forKey: self.previewPreviousDefaultChannelDefaultsKey) else {
2550
+ return self.persistRestoredDefaultChannelFromPreview(
2551
+ channel: previousDefaultChannel,
2552
+ stateFile: stateFile,
2553
+ previewSnapshotFile: previewSnapshotFile
2554
+ )
2555
+ }
2556
+
2557
+ private func applyRestoredDefaultChannelFromPreview(_ channel: String?) {
2558
+ let configDefaultChannel = self.getStringConfig("defaultChannel", defaultValue: "")
2559
+ if let channel = channel {
2560
+ UserDefaults.standard.set(channel, forKey: self.defaultChannelDefaultsKey)
2561
+ self.implementation.defaultChannel = channel
2562
+ logger.info("Restored defaultChannel after preview")
2563
+ } else {
2035
2564
  UserDefaults.standard.removeObject(forKey: self.defaultChannelDefaultsKey)
2036
2565
  self.implementation.defaultChannel = configDefaultChannel
2037
2566
  logger.info("Restored defaultChannel after preview to config value")
2038
- return
2039
2567
  }
2568
+ }
2040
2569
 
2041
- UserDefaults.standard.set(previousDefaultChannel, forKey: self.defaultChannelDefaultsKey)
2042
- self.implementation.defaultChannel = previousDefaultChannel
2043
- logger.info("Restored defaultChannel after preview")
2570
+ private func persistRestoredDefaultChannelFromPreview(
2571
+ channel: String?,
2572
+ stateFile: URL,
2573
+ previewSnapshotFile: URL
2574
+ ) -> Bool {
2575
+ defaultChannelStateLock.lock()
2576
+ defer { defaultChannelStateLock.unlock() }
2577
+ let didPersist: Bool
2578
+ do {
2579
+ try self.persistDefaultChannelStateWithoutLock(channel: channel, file: stateFile)
2580
+ try self.persistDefaultChannelPreviewSnapshotWithoutLock(
2581
+ channel: nil,
2582
+ isValid: false,
2583
+ file: previewSnapshotFile
2584
+ )
2585
+ didPersist = true
2586
+ } catch {
2587
+ let persistedState = self.defaultChannelStateWithoutLock(file: stateFile)
2588
+ let persistedSnapshot = self.defaultChannelPreviewSnapshotWithoutLock(file: previewSnapshotFile)
2589
+ if persistedState.exists,
2590
+ persistedState.isReadable,
2591
+ persistedState.channel == channel,
2592
+ persistedSnapshot == .invalidated {
2593
+ logger.warn("Default channel preview restore committed before metadata update failed")
2594
+ didPersist = true
2595
+ } else {
2596
+ logger.warn("Cannot durably restore default channel after preview: \(error.localizedDescription)")
2597
+ didPersist = false
2598
+ }
2599
+ }
2600
+ self.applyRestoredDefaultChannelFromPreview(channel)
2601
+ return didPersist
2044
2602
  }
2045
2603
 
2046
2604
  private func normalizedPreviewAppId(_ rawAppId: String?) -> String? {
@@ -2220,7 +2778,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2220
2778
  self.shakeMenuGesture = Self.normalizedShakeMenuGesture(self.getStringConfig("shakeMenuGesture", defaultValue: Self.shakeMenuGestureShake))
2221
2779
  self.syncShakeMenuGestureRecognizer()
2222
2780
  self.restorePreviewPreviousAppId()
2223
- self.restorePreviewPreviousDefaultChannel()
2781
+ if !self.restorePreviewPreviousDefaultChannel() {
2782
+ logger.warn("Default channel preview restore will retry on next launch")
2783
+ }
2224
2784
  _ = self.implementation.setPreviewFallbackBundle(fallback: nil)
2225
2785
  _ = self.implementation.setNextBundle(next: Optional<String>.none)
2226
2786
  self.clearPreviewSessionPreferences()
@@ -2252,7 +2812,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2252
2812
  guard self.previewSessionEnabled else {
2253
2813
  return
2254
2814
  }
2255
- if let topVC = UIApplication.topViewController(),
2815
+ if let topVC = UIApplication.topViewController(self.bridge?.viewController),
2256
2816
  topVC.isKind(of: UIAlertController.self) {
2257
2817
  self.previewSessionAlertPending = true
2258
2818
  UserDefaults.standard.set(true, forKey: self.previewSessionAlertPendingDefaultsKey)
@@ -2262,11 +2822,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2262
2822
 
2263
2823
  let alert = UIAlertController(
2264
2824
  title: "Preview started",
2265
- message: "Shake your device anytime to reload or leave the test app.",
2825
+ message: self.shakeMenuGesture == Self.shakeMenuGestureThreeFingerPinch ? "Three-finger pinch to open menu." : "Shake to open menu.",
2266
2826
  preferredStyle: .alert
2267
2827
  )
2268
2828
  alert.addAction(UIAlertAction(title: "Got it", style: .default))
2269
- if let topVC = UIApplication.topViewController() {
2829
+ if let topVC = UIApplication.topViewController(self.bridge?.viewController) {
2270
2830
  topVC.present(alert, animated: true)
2271
2831
  } else {
2272
2832
  self.previewSessionAlertPending = true
@@ -2465,6 +3025,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2465
3025
  "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
2466
3026
  ])
2467
3027
  } else {
3028
+ guard self.persistDefaultChannelStateFromDefaults() else {
3029
+ self.rejectCall(call, message: "Channel override removed but local persistence failed", code: "UNSETCHANNEL_PERSISTENCE_FAILED")
3030
+ return
3031
+ }
2468
3032
  if self._isAutoUpdateEnabled() && triggerAutoUpdate {
2469
3033
  self.logger.info("Calling autoupdater after channel change!")
2470
3034
  // Check if download is already in progress (with timeout protection)
@@ -2511,6 +3075,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2511
3075
  "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
3076
  ])
2513
3077
  } else {
3078
+ guard self.persistDefaultChannelStateFromDefaults() else {
3079
+ self.rejectCall(call, message: "Channel changed but local persistence failed", code: "SETCHANNEL_PERSISTENCE_FAILED")
3080
+ return
3081
+ }
2514
3082
  if self._isAutoUpdateEnabled() && triggerAutoUpdate {
2515
3083
  self.logger.info("Calling autoupdater after channel change!")
2516
3084
  // Check if download is already in progress (with timeout protection)
@@ -2535,6 +3103,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2535
3103
  "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
2536
3104
  ])
2537
3105
  } else {
3106
+ guard self.persistDefaultChannelStateFromDefaults() else {
3107
+ self.rejectCall(call, message: "Channel synchronized but local persistence failed", code: "GETCHANNEL_PERSISTENCE_FAILED")
3108
+ return
3109
+ }
2538
3110
  self.resolveCall(call, data: res.toDict())
2539
3111
  }
2540
3112
  }
@@ -2674,10 +3246,67 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2674
3246
  ])
2675
3247
  }
2676
3248
 
3249
+ private func reportAppLaunchStart() {
3250
+ guard !self.implementation.statsUrl.isEmpty, !launchStartReported else {
3251
+ return
3252
+ }
3253
+
3254
+ launchStartReported = true
3255
+ let current = self.implementation.getCurrentBundle()
3256
+ self.implementation.sendStats(
3257
+ action: "app_launch_start",
3258
+ versionName: current.getVersionName(),
3259
+ oldVersionName: "",
3260
+ metadata: [
3261
+ "launch_started_at": String(launchStartedAtMs),
3262
+ "source": "plugin_load"
3263
+ ]
3264
+ )
3265
+ }
3266
+
3267
+ private func reportAppLaunchReady(_ bundle: BundleInfo) {
3268
+ guard !self.implementation.statsUrl.isEmpty, !launchReadyReported else {
3269
+ return
3270
+ }
3271
+
3272
+ launchReadyReported = true
3273
+ let duration = max(0, Int64(Date().timeIntervalSince1970 * 1000) - launchStartedAtMs)
3274
+ self.implementation.sendStats(
3275
+ action: "app_launch_ready",
3276
+ versionName: bundle.getVersionName(),
3277
+ oldVersionName: "",
3278
+ metadata: [
3279
+ "duration_ms": String(duration),
3280
+ "launch_started_at": String(launchStartedAtMs),
3281
+ "source": "notify_app_ready"
3282
+ ]
3283
+ )
3284
+ }
3285
+
3286
+ private func reportAppLaunchTimeout(_ bundle: BundleInfo) {
3287
+ guard !self.implementation.statsUrl.isEmpty else {
3288
+ return
3289
+ }
3290
+
3291
+ let duration = max(0, Int64(Date().timeIntervalSince1970 * 1000) - launchStartedAtMs)
3292
+ self.implementation.sendStats(
3293
+ action: "app_launch_timeout",
3294
+ versionName: bundle.getVersionName(),
3295
+ oldVersionName: "",
3296
+ metadata: [
3297
+ "duration_ms": String(duration),
3298
+ "launch_started_at": String(launchStartedAtMs),
3299
+ "timeout_ms": String(appReadyTimeout),
3300
+ "source": "app_ready_timeout"
3301
+ ]
3302
+ )
3303
+ }
3304
+
2677
3305
  @objc func notifyAppReady(_ call: CAPPluginCall) {
2678
3306
  self.semaphoreDown()
2679
3307
  let bundle = self.implementation.getCurrentBundle()
2680
3308
  self.implementation.setSuccess(bundle: bundle, autoDeletePrevious: self.autoDeletePrevious)
3309
+ self.reportAppLaunchReady(bundle)
2681
3310
  logger.info("Current bundle loaded successfully. [notifyAppReady was called] \(bundle.toString())")
2682
3311
  self.clearIncomingPreviewTransition()
2683
3312
  self.hidePreviewTransitionLoader(reason: "notify-app-ready")
@@ -2785,16 +3414,26 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2785
3414
  "bundle": current.toJSON()
2786
3415
  ])
2787
3416
  self.persistLastFailedBundle(current)
3417
+ self.reportAppLaunchTimeout(current)
2788
3418
  self.implementation.sendStats(action: "update_fail", versionName: current.getVersionName())
2789
3419
  self.implementation.setError(bundle: current)
2790
3420
  _ = self.performReset(toLastSuccessful: true, usePendingBundle: false, isInternal: true)
2791
3421
  if self.autoDeleteFailed && !current.isBuiltin() {
3422
+ let failedId = current.getId()
2792
3423
  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())")
3424
+ // Mark before async work so kill/OOM still resumes via drainPendingDeletes.
3425
+ self.implementation.saveBundleInfo(
3426
+ id: failedId,
3427
+ bundle: current.setStatus(status: BundleStatus.DELETING.storedValue)
3428
+ )
3429
+ UserDefaults.standard.synchronize()
3430
+ DispatchQueue.global(qos: .utility).async {
3431
+ let res = self.implementation.delete(id: failedId, removeInfo: false)
3432
+ if res {
3433
+ self.logger.info("Failed bundle deleted: \(failedId)")
3434
+ } else {
3435
+ self.logger.error("Failed to delete failed bundle: \(failedId)")
3436
+ }
2798
3437
  }
2799
3438
  }
2800
3439
  } else {
@@ -2819,7 +3458,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2819
3458
  func sendReadyToJs(current: BundleInfo, msg: String) {
2820
3459
  logger.info("sendReadyToJs")
2821
3460
  DispatchQueue.global().async {
2822
- self.semaphoreWait(waitTime: self.appReadyTimeout)
3461
+ // Only wait after load()/reload() armed the semaphore. Foreground resumes
3462
+ // with autoUpdate disabled call sendReadyToJs again without a fresh
3463
+ // notifyAppReady(), so waiting there always timed out after appReadyTimeout.
3464
+ if self.consumePendingNotifyAppReady() {
3465
+ self.semaphoreWait(waitTime: self.appReadyTimeout)
3466
+ }
2823
3467
  self.notifyListeners("appReady", data: ["bundle": current.toJSON(), "status": msg], retainUntilConsumed: true)
2824
3468
 
2825
3469
  // Auto hide splashscreen if enabled
@@ -2831,6 +3475,16 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2831
3475
  }
2832
3476
  }
2833
3477
 
3478
+ private func consumePendingNotifyAppReady() -> Bool {
3479
+ let shouldWait = pendingNotifyAppReady
3480
+ pendingNotifyAppReady = false
3481
+ return shouldWait
3482
+ }
3483
+
3484
+ private func clearPendingNotifyAppReady() {
3485
+ pendingNotifyAppReady = false
3486
+ }
3487
+
2834
3488
  private func hideSplashscreen() {
2835
3489
  if Thread.isMainThread {
2836
3490
  self.performHideSplashscreen()
@@ -3382,6 +4036,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3382
4036
  isAutoUpdateModeEnabled(autoUpdateMode) && autoUpdateMode != autoUpdateModeOnlyDownload
3383
4037
  }
3384
4038
 
4039
+ /// Incomplete leftovers must be downloaded again. Android already skips
4040
+ /// `DOWNLOADING` here; iOS used to treat them as ready, then `setNextBundle`
4041
+ /// failed quietly and the update never applied.
4042
+ static func shouldRetryDownloadForExistingBundle(_ bundle: BundleInfo) -> Bool {
4043
+ bundle.isDeleted() || bundle.isDeleting() || bundle.isDownloading()
4044
+ }
4045
+
3385
4046
  static func isDirectUpdateMode(_ directUpdateMode: String) -> Bool {
3386
4047
  directUpdateMode == autoUpdateModeInstall || directUpdateMode == autoUpdateModeLaunch || directUpdateMode == autoUpdateModeAlways
3387
4048
  }
@@ -3444,6 +4105,34 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3444
4105
  self.currentBuildVersion = currentBuildVersion
3445
4106
  }
3446
4107
 
4108
+ func setAppReadyTimeoutForTesting(_ timeout: Int) {
4109
+ self.appReadyTimeout = timeout
4110
+ }
4111
+
4112
+ func armPendingNotifyAppReadyForTesting() {
4113
+ pendingNotifyAppReady = true
4114
+ }
4115
+
4116
+ func resetSemaphoreWaitTestingStateForTesting() {
4117
+ semaphoreWaitTestingLock.lock()
4118
+ didEnterSemaphoreWaitForTesting = false
4119
+ semaphoreWaitTestingLock.unlock()
4120
+ }
4121
+
4122
+ var didEnterSemaphoreWaitForTestingState: Bool {
4123
+ semaphoreWaitTestingLock.lock()
4124
+ defer { semaphoreWaitTestingLock.unlock() }
4125
+ return didEnterSemaphoreWaitForTesting
4126
+ }
4127
+
4128
+ func clearPendingNotifyAppReadyForTesting() {
4129
+ clearPendingNotifyAppReady()
4130
+ }
4131
+
4132
+ var isPendingNotifyAppReadyForTesting: Bool {
4133
+ pendingNotifyAppReady
4134
+ }
4135
+
3447
4136
  func shouldUseDirectUpdateForTesting() -> Bool {
3448
4137
  self.shouldUseDirectUpdate()
3449
4138
  }
@@ -3593,7 +4282,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3593
4282
  func runBackgroundDownloadWork(_ work: @escaping () -> Void) {
3594
4283
  // Live update checks/downloads are user-visible work. Using `.background`
3595
4284
  // lets the scheduler starve them for minutes while the app is active.
3596
- DispatchQueue.global(qos: .utility).async(execute: work)
4285
+ DispatchQueue.global(qos: .userInitiated).async(execute: work)
3597
4286
  }
3598
4287
 
3599
4288
  private func beginDownloadBackgroundTask() {
@@ -3644,8 +4333,6 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3644
4333
  }
3645
4334
 
3646
4335
  self.runBackgroundDownloadWork {
3647
- // Wait for cleanup to complete before starting download
3648
- self.waitForCleanupIfNeeded()
3649
4336
  if self.shouldBlockAutoUpdateForPreviewSession() {
3650
4337
  self.clearDownloadInProgressState()
3651
4338
  return
@@ -3653,7 +4340,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3653
4340
  self.beginDownloadBackgroundTask()
3654
4341
  self.logger.info("Check for update via \(self.updateUrl)")
3655
4342
  let res = self.implementation.getLatest(url: url, channel: nil)
3656
- let current = self.implementation.getCurrentBundle()
4343
+ var current = self.implementation.getCurrentBundle()
3657
4344
  if self.shouldBlockAutoUpdateForPreviewSession() {
3658
4345
  self.clearDownloadInProgressState()
3659
4346
  self.endBackGroundTask()
@@ -3672,6 +4359,16 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3672
4359
  )
3673
4360
  return
3674
4361
  }
4362
+ // File mutations wait here. getLatest already ran in parallel with cleanup.
4363
+ do {
4364
+ try self.waitForCleanupIfNeeded()
4365
+ } catch {
4366
+ self.logger.error("Cleanup still running, skipping download")
4367
+ self.clearDownloadInProgressState()
4368
+ self.endBackGroundTask()
4369
+ return
4370
+ }
4371
+ current = self.implementation.getCurrentBundle()
3675
4372
  if res.version == "builtin" {
3676
4373
  self.logger.info("Latest version is builtin")
3677
4374
  let directUpdateAllowed = plannedDirectUpdate && !self.autoSplashscreenTimedOut
@@ -3733,14 +4430,16 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3733
4430
  do {
3734
4431
  self.logger.info("New bundle: \(latestVersionName) found. Current is: \(current.getVersionName()). \(messageUpdate)")
3735
4432
  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())")
4433
+ let needsDownload = nextImpl.map(Self.shouldRetryDownloadForExistingBundle) ?? true
4434
+ if needsDownload {
4435
+ if let existing = nextImpl {
4436
+ self.logger.info("Latest bundle already exists in incomplete state (\(existing.getStatus())) and will be deleted, download will overwrite it.")
4437
+ _ = self.implementation.setNextBundle(next: Optional<String>.none)
4438
+ let deleted = self.implementation.delete(id: existing.getId(), removeInfo: true)
4439
+ if deleted {
4440
+ self.logger.info("Incomplete bundle deleted: \(existing.toString())")
3742
4441
  } else {
3743
- self.logger.error("Failed to delete failed bundle: \(nextImpl!.toString())")
4442
+ self.logger.error("Failed to delete incomplete bundle: \(existing.toString())")
3744
4443
  }
3745
4444
  }
3746
4445
  self.consumeOnLaunchDirectUpdateAttempt(plannedDirectUpdate: plannedDirectUpdate)
@@ -3827,8 +4526,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3827
4526
  error: false,
3828
4527
  plannedDirectUpdate: plannedDirectUpdate
3829
4528
  )
3830
- } else {
3831
- _ = self.implementation.setNextBundle(next: next.getId())
4529
+ } else if self.queueBundleForNextBackgroundInstall(next) {
3832
4530
  self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
3833
4531
  self.endBackGroundTaskWithNotif(
3834
4532
  msg: "Direct update reload failed, update will install next background",
@@ -3837,20 +4535,35 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3837
4535
  error: false,
3838
4536
  plannedDirectUpdate: plannedDirectUpdate
3839
4537
  )
4538
+ } else {
4539
+ self.endBackGroundTaskWithNotif(
4540
+ msg: "Direct update reload failed, and next bundle could not be queued",
4541
+ latestVersionName: latestVersionName,
4542
+ current: current,
4543
+ plannedDirectUpdate: plannedDirectUpdate
4544
+ )
3840
4545
  }
3841
4546
  } else if self.shouldAutoSetNextBundle() {
3842
4547
  if plannedDirectUpdate && !directUpdateAllowed {
3843
4548
  self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will install on next app background.")
3844
4549
  }
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
- )
4550
+ if self.queueBundleForNextBackgroundInstall(next) {
4551
+ self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
4552
+ self.endBackGroundTaskWithNotif(
4553
+ msg: "update downloaded, will install next background",
4554
+ latestVersionName: latestVersionName,
4555
+ current: current,
4556
+ error: false,
4557
+ plannedDirectUpdate: plannedDirectUpdate
4558
+ )
4559
+ } else {
4560
+ self.endBackGroundTaskWithNotif(
4561
+ msg: "Update downloaded, but next bundle could not be queued",
4562
+ latestVersionName: latestVersionName,
4563
+ current: current,
4564
+ plannedDirectUpdate: plannedDirectUpdate
4565
+ )
4566
+ }
3854
4567
  } else {
3855
4568
  self.logger.info("autoUpdate is set to onlyDownload, downloaded update will not be set as next bundle")
3856
4569
  self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()], retainUntilConsumed: true)
@@ -4014,6 +4727,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4014
4727
 
4015
4728
  let current: BundleInfo = self.implementation.getCurrentBundle()
4016
4729
  self.implementation.sendStats(action: "app_moved_to_background", versionName: current.getVersionName())
4730
+ self.implementation.persistPendingStats()
4017
4731
  logger.info("Check for pending update")
4018
4732
 
4019
4733
  // Show splashscreen only if autoSplashscreen is enabled AND autoUpdate is enabled AND directUpdate would be used