@capgo/capacitor-updater 6.43.5 → 6.50.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/Package.swift +1 -1
  2. package/README.md +643 -115
  3. package/android/build.gradle +3 -3
  4. package/android/src/main/java/ee/forgr/capacitor_updater/AndroidAppExitReporter.java +92 -0
  5. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +3121 -354
  6. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +905 -283
  7. package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +45 -30
  8. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +75 -32
  9. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +2 -0
  10. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +3 -3
  11. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +401 -27
  12. package/android/src/main/java/ee/forgr/capacitor_updater/ThreeFingerPinchDetector.java +323 -0
  13. package/dist/docs.json +1590 -196
  14. package/dist/esm/definitions.d.ts +755 -66
  15. package/dist/esm/definitions.js.map +1 -1
  16. package/dist/esm/web.d.ts +11 -1
  17. package/dist/esm/web.js +59 -1
  18. package/dist/esm/web.js.map +1 -1
  19. package/dist/plugin.cjs.js +59 -1
  20. package/dist/plugin.cjs.js.map +1 -1
  21. package/dist/plugin.js +59 -1
  22. package/dist/plugin.js.map +1 -1
  23. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +0 -1
  24. package/ios/Sources/CapacitorUpdaterPlugin/AppHealthTracker.swift +82 -0
  25. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +0 -16
  26. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +2 -2
  27. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +78 -2
  28. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +2732 -416
  29. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1191 -363
  30. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +0 -1
  31. package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +80 -1
  32. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +438 -39
  33. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +276 -0
  34. package/package.json +18 -3
@@ -37,6 +37,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
37
37
  CAPPluginMethod(name: "setStatsUrl", returnType: CAPPluginReturnPromise),
38
38
  CAPPluginMethod(name: "setChannelUrl", returnType: CAPPluginReturnPromise),
39
39
  CAPPluginMethod(name: "set", returnType: CAPPluginReturnPromise),
40
+ CAPPluginMethod(name: "startPreviewSession", returnType: CAPPluginReturnPromise),
41
+ CAPPluginMethod(name: "listPreviews", returnType: CAPPluginReturnPromise),
42
+ CAPPluginMethod(name: "setPreview", returnType: CAPPluginReturnPromise),
43
+ CAPPluginMethod(name: "resetPreview", returnType: CAPPluginReturnPromise),
44
+ CAPPluginMethod(name: "deletePreview", returnType: CAPPluginReturnPromise),
45
+ CAPPluginMethod(name: "checkPreviewUpdate", returnType: CAPPluginReturnPromise),
46
+ CAPPluginMethod(name: "updatePreview", returnType: CAPPluginReturnPromise),
40
47
  CAPPluginMethod(name: "list", returnType: CAPPluginReturnPromise),
41
48
  CAPPluginMethod(name: "delete", returnType: CAPPluginReturnPromise),
42
49
  CAPPluginMethod(name: "setBundleError", returnType: CAPPluginReturnPromise),
@@ -47,8 +54,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
47
54
  CAPPluginMethod(name: "setMultiDelay", returnType: CAPPluginReturnPromise),
48
55
  CAPPluginMethod(name: "cancelDelay", returnType: CAPPluginReturnPromise),
49
56
  CAPPluginMethod(name: "getLatest", returnType: CAPPluginReturnPromise),
57
+ CAPPluginMethod(name: "getMissingBundleFiles", returnType: CAPPluginReturnPromise),
58
+ CAPPluginMethod(name: "getBundleDownloadSize", returnType: CAPPluginReturnPromise),
59
+ CAPPluginMethod(name: "triggerUpdateCheck", returnType: CAPPluginReturnPromise),
50
60
  CAPPluginMethod(name: "setChannel", returnType: CAPPluginReturnPromise),
51
61
  CAPPluginMethod(name: "unsetChannel", returnType: CAPPluginReturnPromise),
62
+ CAPPluginMethod(name: "reportWebViewError", returnType: CAPPluginReturnPromise),
52
63
  CAPPluginMethod(name: "getChannel", returnType: CAPPluginReturnPromise),
53
64
  CAPPluginMethod(name: "listChannels", returnType: CAPPluginReturnPromise),
54
65
  CAPPluginMethod(name: "setCustomId", returnType: CAPPluginReturnPromise),
@@ -64,6 +75,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
64
75
  CAPPluginMethod(name: "isShakeMenuEnabled", returnType: CAPPluginReturnPromise),
65
76
  CAPPluginMethod(name: "setShakeChannelSelector", returnType: CAPPluginReturnPromise),
66
77
  CAPPluginMethod(name: "isShakeChannelSelectorEnabled", returnType: CAPPluginReturnPromise),
78
+ CAPPluginMethod(name: "getAppId", returnType: CAPPluginReturnPromise),
79
+ CAPPluginMethod(name: "setAppId", returnType: CAPPluginReturnPromise),
67
80
  // App Store update methods
68
81
  CAPPluginMethod(name: "getAppUpdateInfo", returnType: CAPPluginReturnPromise),
69
82
  CAPPluginMethod(name: "openAppStore", returnType: CAPPluginReturnPromise),
@@ -72,10 +85,19 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
72
85
  CAPPluginMethod(name: "completeFlexibleUpdate", returnType: CAPPluginReturnPromise)
73
86
  ]
74
87
  public var implementation = CapgoUpdater()
75
- private let pluginVersion: String = "8.43.5"
88
+ private let pluginVersion: String = "6.50.1"
76
89
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
77
90
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
78
91
  static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
92
+ static let autoUpdateModeOff = "off"
93
+ static let autoUpdateModeBackground = "atBackground"
94
+ static let autoUpdateModeInstall = "atInstall"
95
+ static let autoUpdateModeLaunch = "onLaunch"
96
+ static let autoUpdateModeAlways = "always"
97
+ static let autoUpdateModeOnlyDownload = "onlyDownload"
98
+ static let shakeMenuGestureShake = "shake"
99
+ static let shakeMenuGestureThreeFingerPinch = "threeFingerPinch"
100
+ private static let previewLoaderTimeoutMs = 60000
79
101
  private let keepUrlPathFlagKey = "__capgo_keep_url_path_after_reload"
80
102
  private let customIdDefaultsKey = "CapacitorUpdater.customId"
81
103
  private let updateUrlDefaultsKey = "CapacitorUpdater.updateUrl"
@@ -83,12 +105,36 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
83
105
  private let channelUrlDefaultsKey = "CapacitorUpdater.channelUrl"
84
106
  private let defaultChannelDefaultsKey = "CapacitorUpdater.defaultChannel"
85
107
  private let lastFailedBundleDefaultsKey = "CapacitorUpdater.lastFailedBundle"
108
+ private let lastVersionOsDefaultsKey = "CapacitorUpdater.lastVersionOs"
109
+ private let lastVersionBuildDefaultsKey = "CapacitorUpdater.lastVersionBuild"
110
+ private let lastVersionCodeDefaultsKey = "CapacitorUpdater.lastVersionCode"
111
+ private let osVersionChangedAction = "os_version_changed"
112
+ private let nativeAppVersionChangedAction = "native_app_version_changed"
113
+ private let previewSessionDefaultsKey = "CapacitorUpdater.previewSession"
114
+ private let previewPreviousShakeMenuDefaultsKey = "CapacitorUpdater.previewPreviousShakeMenu"
115
+ private let previewPreviousShakeChannelSelectorDefaultsKey = "CapacitorUpdater.previewPreviousShakeChannelSelector"
116
+ private let previewPreviousNextBundleDefaultsKey = "CapacitorUpdater.previewPreviousNextBundle"
117
+ private let previewPreviousAppIdDefaultsKey = "CapacitorUpdater.previewPreviousAppId"
118
+ private let previewPreviousDefaultChannelDefaultsKey = "CapacitorUpdater.previewPreviousDefaultChannel"
119
+ private let previewPreviousDefaultChannelWasSetDefaultsKey = "CapacitorUpdater.previewPreviousDefaultChannelWasSet"
120
+ private let previewAppIdDefaultsKey = "CapacitorUpdater.previewAppId"
121
+ private let previewPayloadUrlDefaultsKey = "CapacitorUpdater.previewPayloadUrl"
122
+ private let previewNameDefaultsKey = "CapacitorUpdater.previewName"
123
+ private let previewSourceDefaultsKey = "CapacitorUpdater.previewSource"
124
+ private let previewSessionsDefaultsKey = "CapacitorUpdater.previewSessions"
125
+ private let previewSessionAlertPendingDefaultsKey = "CapacitorUpdater.previewSessionAlertPending"
126
+ private let previewDeepLinkScheme = "capgo"
127
+ private let previewDeepLinkRootComponent = "preview"
128
+ private let previewDeepLinkChannelComponent = "channel"
129
+ private let previewDeepLinkBundleComponent = "bundle"
130
+ private let previewPathSeparator = Character(UnicodeScalar(UInt8(47)))
86
131
  // Note: DELAY_CONDITION_PREFERENCES is now defined in DelayUpdateUtils.DELAY_CONDITION_PREFERENCES
87
132
  private var updateUrl = ""
88
133
  private var backgroundTaskID: UIBackgroundTaskIdentifier = UIBackgroundTaskIdentifier.invalid
89
134
  private var currentVersionNative: Version = "0.0.0"
90
135
  private var currentBuildVersion: String = "0"
91
136
  private var autoUpdate = false
137
+ private var autoUpdateMode = CapacitorUpdaterPlugin.autoUpdateModeOff
92
138
  private var appReadyTimeout = 10000
93
139
  private var appReadyCheck: DispatchWorkItem?
94
140
  private var resetWhenUpdate = true
@@ -102,7 +148,15 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
102
148
  private var autoSplashscreenTimeoutWorkItem: DispatchWorkItem?
103
149
  private var splashscreenLoaderView: UIActivityIndicatorView?
104
150
  private var splashscreenLoaderContainer: UIView?
151
+ private var previewTransitionLoaderView: UIActivityIndicatorView?
152
+ private var previewTransitionLoaderContainer: UIView?
153
+ private var previewTransitionLoaderTimeoutWorkItem: DispatchWorkItem?
154
+ private var previewTransitionLoaderRequested = false
155
+ private let splashscreenPluginName = "SplashScreen"
156
+ private let splashscreenRetryDelayMilliseconds = 100
157
+ private let splashscreenMaxRetries = 20
105
158
  private var autoSplashscreenTimedOut = false
159
+ private var splashscreenInvocationToken = 0
106
160
  private var autoDeleteFailed = false
107
161
  private var autoDeletePrevious = false
108
162
  var allowSetDefaultChannel = true
@@ -111,9 +165,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
111
165
  private var taskRunning = false
112
166
  private var periodCheckDelay = 0
113
167
  private let downloadLock = NSLock()
168
+ private let onLaunchDirectUpdateStateLock = NSLock()
114
169
  private var downloadInProgress = false
115
170
  private var downloadStartTime: Date?
116
- private let downloadTimeout: TimeInterval = 3600 // 1 hour timeout
171
+ private let downloadTimeout: TimeInterval = 600 // 10 minute timeout
117
172
 
118
173
  // Lock to ensure cleanup completes before downloads start
119
174
  private let cleanupLock = NSLock()
@@ -122,9 +177,19 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
122
177
  private var persistCustomId = false
123
178
  private var persistModifyUrl = false
124
179
  private var allowManualBundleError = false
180
+ private var allowPreview = false
125
181
  private var keepUrlPathFlagLastValue: Bool?
182
+ private var appHealthTracker: AppHealthTracker?
183
+ private var webViewStatsReporter: WebViewStatsReporter?
126
184
  public var shakeMenuEnabled = false
127
185
  public var shakeChannelSelectorEnabled = false
186
+ public var shakeMenuGesture = CapacitorUpdaterPlugin.shakeMenuGestureShake
187
+ var shakeMenuPinchGestureRecognizer: ThreeFingerPinchGestureRecognizer?
188
+ var shakeMenuPinchGestureTriggered = false
189
+ public var previewSessionEnabled = false
190
+ private var previewSessionAlertPending = false
191
+ private var isLeavingPreviewForIncomingLink = false
192
+ private var previewTransitionClearWorkItem: DispatchWorkItem?
128
193
  let semaphoreReady = DispatchSemaphore(value: 0)
129
194
 
130
195
  private var delayUpdateUtils: DelayUpdateUtils!
@@ -138,6 +203,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
138
203
  } else {
139
204
  logger.error("Failed to get webView for logging")
140
205
  }
206
+ let webViewStatsReporter = WebViewStatsReporter(implementation: implementation)
207
+ self.webViewStatsReporter = webViewStatsReporter
208
+ webViewStatsReporter.install(on: self.bridge?.webView)
141
209
  #if targetEnvironment(simulator)
142
210
  logger.info("::::: SIMULATOR :::::")
143
211
  logger.info("Application directory: \(NSHomeDirectory())")
@@ -157,6 +225,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
157
225
  }
158
226
  persistModifyUrl = getConfig().getBoolean("persistModifyUrl", false)
159
227
  allowManualBundleError = getConfig().getBoolean("allowManualBundleError", false)
228
+ allowPreview = getConfig().getBoolean("allowPreview", false)
160
229
  logger.info("init for device \(self.implementation.deviceID)")
161
230
  guard let versionName = getConfig().getString("version", Bundle.main.versionName) else {
162
231
  logger.error("Cannot get version name")
@@ -176,33 +245,6 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
176
245
  keepUrlPathAfterReload = getConfig().getBoolean("keepUrlPathAfterReload", false)
177
246
  syncKeepUrlPathFlag(enabled: keepUrlPathAfterReload)
178
247
 
179
- // Handle directUpdate configuration - support string values and backward compatibility
180
- if let directUpdateString = getConfig().getString("directUpdate") {
181
- // Handle backward compatibility for boolean true
182
- if directUpdateString == "true" {
183
- directUpdateMode = "always"
184
- directUpdate = true
185
- } else {
186
- directUpdateMode = directUpdateString
187
- directUpdate = directUpdateString == "always" || directUpdateString == "atInstall" || directUpdateString == "onLaunch"
188
- // Validate directUpdate value
189
- if directUpdateString != "false" && directUpdateString != "always" && directUpdateString != "atInstall" && directUpdateString != "onLaunch" {
190
- logger.error("Invalid directUpdate value: \"\(directUpdateString)\". Supported values are: \"false\", \"true\", \"always\", \"atInstall\", \"onLaunch\". Defaulting to \"false\".")
191
- directUpdateMode = "false"
192
- directUpdate = false
193
- }
194
- }
195
- } else {
196
- let directUpdateBool = getConfig().getBoolean("directUpdate", false)
197
- if directUpdateBool {
198
- directUpdateMode = "always" // backward compatibility: true = always
199
- directUpdate = true
200
- } else {
201
- directUpdateMode = "false"
202
- directUpdate = false
203
- }
204
- }
205
-
206
248
  autoSplashscreen = getConfig().getBoolean("autoSplashscreen", false)
207
249
  autoSplashscreenLoader = getConfig().getBoolean("autoSplashscreenLoader", false)
208
250
  let splashscreenTimeoutValue = getConfig().getInt("autoSplashscreenTimeout", 10000)
@@ -212,21 +254,40 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
212
254
  updateUrl = storedUpdateUrl
213
255
  logger.info("Loaded persisted updateUrl")
214
256
  }
215
- autoUpdate = getConfig().getBoolean("autoUpdate", true)
257
+ configureAutoUpdateModeFromConfig()
216
258
  appReadyTimeout = max(1000, getConfig().getInt("appReadyTimeout", 10000)) // Minimum 1 second
217
259
  implementation.timeout = Double(getConfig().getInt("responseTimeout", 20))
218
260
  resetWhenUpdate = getConfig().getBoolean("resetWhenUpdate", true)
219
261
  shakeMenuEnabled = getConfig().getBoolean("shakeMenu", false)
220
262
  shakeChannelSelectorEnabled = getConfig().getBoolean("allowShakeChannelSelector", false)
221
- let periodCheckDelayValue = getConfig().getInt("periodCheckDelay", 0)
222
- if periodCheckDelayValue >= 0 && periodCheckDelayValue > 600 {
223
- periodCheckDelay = 600
224
- } else {
225
- periodCheckDelay = periodCheckDelayValue
263
+ shakeMenuGesture = Self.normalizedShakeMenuGesture(getConfig().getString("shakeMenuGesture", Self.shakeMenuGestureShake))
264
+ let storedPreviewSessionEnabled = UserDefaults.standard.bool(forKey: previewSessionDefaultsKey)
265
+ let shouldClearPreviewSessionBecauseDisabled = !allowPreview && storedPreviewSessionEnabled
266
+ previewSessionEnabled = allowPreview && storedPreviewSessionEnabled
267
+ implementation.previewSession = previewSessionEnabled
268
+ if previewSessionEnabled {
269
+ previewSessionAlertPending = UserDefaults.standard.object(forKey: previewSessionAlertPendingDefaultsKey) as? Bool ?? true
270
+ shakeMenuEnabled = true
271
+ shakeChannelSelectorEnabled = UserDefaults.standard.object(forKey: previewPreviousShakeChannelSelectorDefaultsKey) as? Bool
272
+ ?? shakeChannelSelectorEnabled
226
273
  }
274
+ syncShakeMenuGestureRecognizer()
275
+ periodCheckDelay = Self.normalizedPeriodCheckDelaySeconds(getConfig().getInt("periodCheckDelay", 0))
227
276
 
228
277
  implementation.setPublicKey(getConfig().getString("publicKey") ?? "")
229
278
  implementation.notifyDownloadRaw = notifyDownload
279
+ implementation.notifyListeners = { [weak self] eventName, data in
280
+ let emit = {
281
+ self?.notifyListeners(eventName, data: data)
282
+ }
283
+ if Thread.isMainThread {
284
+ emit()
285
+ } else {
286
+ DispatchQueue.main.async {
287
+ emit()
288
+ }
289
+ }
290
+ }
230
291
  implementation.pluginVersion = self.pluginVersion
231
292
 
232
293
  // Set logger for shared classes
@@ -248,6 +309,15 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
248
309
  // crash the app on purpose it should not happen
249
310
  fatalError("appId is missing in capacitor.config.json or plugin config, and cannot be retrieved from the native app, please add it globally or in the plugin config")
250
311
  }
312
+ if shouldClearPreviewSessionBecauseDisabled {
313
+ clearPreviewSessionBecauseDisabled()
314
+ }
315
+ if previewSessionEnabled,
316
+ let previewAppId = UserDefaults.standard.string(forKey: previewAppIdDefaultsKey),
317
+ !previewAppId.isEmpty {
318
+ implementation.appId = previewAppId
319
+ logger.info("Using preview appId \(previewAppId)")
320
+ }
251
321
  logger.info("appId \(implementation.appId)")
252
322
  implementation.statsUrl = getConfig().getString("statsUrl", CapacitorUpdaterPlugin.statsUrlDefault)!
253
323
  implementation.channelUrl = getConfig().getString("channelUrl", CapacitorUpdaterPlugin.channelUrlDefault)!
@@ -270,13 +340,24 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
270
340
  implementation.defaultChannel = getConfig().getString("defaultChannel", "")!
271
341
  }
272
342
  self.implementation.autoReset()
343
+ let appHealthTracker = AppHealthTracker(implementation: self.implementation)
344
+ self.appHealthTracker = appHealthTracker
345
+ appHealthTracker.reportPreviousUncleanForegroundExit()
346
+ appHealthTracker.startSession()
273
347
 
274
- // Check if app was recently installed/updated BEFORE cleanupObsoleteVersions updates LatestVersionNative
348
+ // Check if app was recently installed/updated BEFORE cleanup updates the stored native build version.
275
349
  self.wasRecentlyInstalledOrUpdated = self.checkIfRecentlyInstalledOrUpdated()
350
+ let nativeBuildVersionChanged = self.hasNativeBuildVersionChanged()
351
+ if nativeBuildVersionChanged {
352
+ self.clearPreviewSessionForNativeBuildChange()
353
+ }
354
+ self.leavePreviewSessionForLaunchURLIfNeeded()
276
355
 
277
356
  if resetWhenUpdate {
278
- self.cleanupObsoleteVersions()
357
+ let didResetCurrentBundle = self.resetCurrentBundleForNativeBuildChangeIfNeeded()
358
+ self.cleanupObsoleteVersions(didResetCurrentBundle: didResetCurrentBundle)
279
359
  }
360
+ self.reportNativeVersionStatsIfChanged()
280
361
 
281
362
  // Load the server
282
363
  // This is very much swift specific, android does not do that
@@ -289,9 +370,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
289
370
  logger.error("unable to force reload, the plugin might fallback to the builtin version")
290
371
  }
291
372
 
292
- let nc = NotificationCenter.default
293
- nc.addObserver(self, selector: #selector(appMovedToBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
294
- nc.addObserver(self, selector: #selector(appMovedToForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
373
+ self.registerNotificationObservers()
295
374
 
296
375
  // Check for 'kill' delay condition on app launch
297
376
  // This handles cases where the app was killed (willTerminateNotification is not reliable for system kills)
@@ -299,6 +378,47 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
299
378
 
300
379
  self.appMovedToForeground()
301
380
  self.checkForUpdateAfterDelay()
381
+ self.showPreviewSessionNoticeIfNeeded()
382
+ }
383
+
384
+ private func registerNotificationObservers() {
385
+ let notificationCenter = NotificationCenter.default
386
+ notificationCenter.addObserver(
387
+ self,
388
+ selector: #selector(appMovedToBackground),
389
+ name: UIApplication.didEnterBackgroundNotification,
390
+ object: nil
391
+ )
392
+ notificationCenter.addObserver(
393
+ self,
394
+ selector: #selector(appMovedToForeground),
395
+ name: UIApplication.willEnterForegroundNotification,
396
+ object: nil
397
+ )
398
+ notificationCenter.addObserver(
399
+ self,
400
+ selector: #selector(appWillTerminate),
401
+ name: UIApplication.willTerminateNotification,
402
+ object: nil
403
+ )
404
+ notificationCenter.addObserver(
405
+ self,
406
+ selector: #selector(appDidReceiveMemoryWarning),
407
+ name: UIApplication.didReceiveMemoryWarningNotification,
408
+ object: nil
409
+ )
410
+ notificationCenter.addObserver(
411
+ self,
412
+ selector: #selector(handleOpenURLForPreviewSession(notification:)),
413
+ name: Notification.Name.capacitorOpenURL,
414
+ object: nil
415
+ )
416
+ notificationCenter.addObserver(
417
+ self,
418
+ selector: #selector(handleOpenURLForPreviewSession(notification:)),
419
+ name: Notification.Name.capacitorOpenUniversalLink,
420
+ object: nil
421
+ )
302
422
  }
303
423
 
304
424
  private func syncKeepUrlPathFlag(enabled: Bool) {
@@ -348,6 +468,22 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
348
468
  }
349
469
  }
350
470
 
471
+ @objc private func appWillTerminate() {
472
+ appHealthTracker?.markForeground(false)
473
+ }
474
+
475
+ @objc private func appDidReceiveMemoryWarning() {
476
+ appHealthTracker?.reportMemoryWarning()
477
+ }
478
+
479
+ @objc func reportWebViewError(_ call: CAPPluginCall) {
480
+ guard let webViewStatsReporter = webViewStatsReporter else {
481
+ call.resolve()
482
+ return
483
+ }
484
+ webViewStatsReporter.reportError(call)
485
+ }
486
+
351
487
  private func initialLoad() -> Bool {
352
488
  guard let bridge = self.bridge else { return false }
353
489
  if keepUrlPathAfterReload {
@@ -391,7 +527,109 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
391
527
  semaphoreReady.signal()
392
528
  }
393
529
 
394
- private func cleanupObsoleteVersions() {
530
+ func storedNativeBuildVersion() -> String {
531
+ UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
532
+ }
533
+
534
+ func hasNativeBuildVersionChanged() -> Bool {
535
+ let previous = self.storedNativeBuildVersion()
536
+ return previous != "0" && self.currentBuildVersion != previous
537
+ }
538
+
539
+ func reportNativeVersionStatsIfChanged() {
540
+ self.reportNativeVersionStatsIfChanged(
541
+ currentVersionBuild: self.implementation.versionBuild,
542
+ currentVersionCode: self.currentBuildVersion,
543
+ currentVersionOs: UIDevice.current.systemVersion
544
+ )
545
+ }
546
+
547
+ func reportNativeVersionStatsIfChanged(currentVersionBuild: String?, currentVersionCode: String?, currentVersionOs: String?) {
548
+ let normalizedVersionBuild = self.normalizedStatsValue(currentVersionBuild)
549
+ let normalizedVersionCode = self.normalizedStatsValue(currentVersionCode)
550
+ let normalizedVersionOs = self.normalizedStatsValue(currentVersionOs)
551
+ let userDefaults = UserDefaults.standard
552
+ let previousVersionOs = userDefaults.string(forKey: self.lastVersionOsDefaultsKey) ?? ""
553
+ let previousVersionBuild = userDefaults.string(forKey: self.lastVersionBuildDefaultsKey) ?? ""
554
+ let previousVersionCode = userDefaults.string(forKey: self.lastVersionCodeDefaultsKey) ?? ""
555
+ let osVersionChanged = !normalizedVersionOs.isEmpty && !previousVersionOs.isEmpty && previousVersionOs != normalizedVersionOs
556
+
557
+ if osVersionChanged {
558
+ self.implementation.sendStats(
559
+ action: self.osVersionChangedAction,
560
+ versionName: self.implementation.getCurrentBundle().getVersionName(),
561
+ oldVersionName: "",
562
+ metadata: [
563
+ "previous_version_os": previousVersionOs,
564
+ "current_version_os": normalizedVersionOs
565
+ ],
566
+ onSent: { [weak self] in
567
+ self?.persistLastVersionOs(normalizedVersionOs)
568
+ }
569
+ )
570
+ }
571
+
572
+ let hasPreviousNativeVersion = !previousVersionBuild.isEmpty || !previousVersionCode.isEmpty
573
+ let nativeVersionChanged = hasPreviousNativeVersion &&
574
+ (previousVersionBuild != normalizedVersionBuild || previousVersionCode != normalizedVersionCode)
575
+ if nativeVersionChanged {
576
+ self.implementation.sendStats(
577
+ action: self.nativeAppVersionChangedAction,
578
+ versionName: self.implementation.getCurrentBundle().getVersionName(),
579
+ oldVersionName: "",
580
+ metadata: [
581
+ "previous_version_build": previousVersionBuild,
582
+ "current_version_build": normalizedVersionBuild,
583
+ "previous_version_code": previousVersionCode,
584
+ "current_version_code": normalizedVersionCode
585
+ ],
586
+ onSent: { [weak self] in
587
+ self?.persistLastNativeAppVersion(build: normalizedVersionBuild, code: normalizedVersionCode)
588
+ }
589
+ )
590
+ }
591
+
592
+ if !osVersionChanged || !nativeVersionChanged {
593
+ if !osVersionChanged {
594
+ userDefaults.set(normalizedVersionOs, forKey: self.lastVersionOsDefaultsKey)
595
+ }
596
+ if !nativeVersionChanged {
597
+ userDefaults.set(normalizedVersionBuild, forKey: self.lastVersionBuildDefaultsKey)
598
+ userDefaults.set(normalizedVersionCode, forKey: self.lastVersionCodeDefaultsKey)
599
+ }
600
+ userDefaults.synchronize()
601
+ }
602
+ }
603
+
604
+ private func persistLastVersionOs(_ versionOs: String) {
605
+ UserDefaults.standard.set(versionOs, forKey: self.lastVersionOsDefaultsKey)
606
+ UserDefaults.standard.synchronize()
607
+ }
608
+
609
+ private func persistLastNativeAppVersion(build: String, code: String) {
610
+ UserDefaults.standard.set(build, forKey: self.lastVersionBuildDefaultsKey)
611
+ UserDefaults.standard.set(code, forKey: self.lastVersionCodeDefaultsKey)
612
+ UserDefaults.standard.synchronize()
613
+ }
614
+
615
+ private func normalizedStatsValue(_ value: String?) -> String {
616
+ value ?? ""
617
+ }
618
+
619
+ @discardableResult
620
+ func resetCurrentBundleForNativeBuildChangeIfNeeded() -> Bool {
621
+ let previous = self.storedNativeBuildVersion()
622
+ guard previous != "0" && self.currentBuildVersion != previous else {
623
+ return false
624
+ }
625
+
626
+ // Reset startup state synchronously so initialLoad() boots from the builtin bundle.
627
+ self.logger.info("Native build version changed from \(previous) to \(self.currentBuildVersion). Resetting startup bundle to builtin.")
628
+ self.implementation.reset(isInternal: true)
629
+ return true
630
+ }
631
+
632
+ private func cleanupObsoleteVersions(didResetCurrentBundle: Bool = false) {
395
633
  cleanupThread = Thread {
396
634
  self.cleanupLock.lock()
397
635
  defer {
@@ -426,9 +664,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
426
664
  // 1. Write "LatestVersionNative" - this fixes the part 1 of this bug
427
665
  // 2. Compare both keys. If any is not equal to "currentBuildVersion", then revert to builtin version. This fixes the part 2 of this bug
428
666
 
429
- let previous = UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
667
+ let previous = self.storedNativeBuildVersion()
430
668
  if previous != "0" && self.currentBuildVersion != previous {
431
- _ = self._reset(toLastSuccessful: false)
669
+ if !didResetCurrentBundle {
670
+ self.logger.info("Native build version changed from \(previous) to \(self.currentBuildVersion). Resetting current bundle to builtin.")
671
+ self.implementation.reset(isInternal: true)
672
+ }
432
673
  let res = self.implementation.list()
433
674
  for version in res {
434
675
  // Check if thread was cancelled
@@ -488,11 +729,88 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
488
729
  logger.info("Cleanup finished, proceeding with download")
489
730
  }
490
731
 
732
+ private func resolveCall(_ call: CAPPluginCall, data: PluginCallResultData? = nil) {
733
+ let resolve = {
734
+ let savedCall = self.bridge?.savedCall(withID: call.callbackId)
735
+ let targetCall = savedCall ?? call
736
+
737
+ if let data {
738
+ targetCall.resolve(data)
739
+ } else {
740
+ targetCall.resolve()
741
+ }
742
+
743
+ if savedCall != nil {
744
+ self.bridge?.releaseCall(withID: call.callbackId)
745
+ }
746
+ }
747
+
748
+ if Thread.isMainThread {
749
+ resolve()
750
+ } else {
751
+ DispatchQueue.main.async {
752
+ resolve()
753
+ }
754
+ }
755
+ }
756
+
757
+ private func rejectCall(_ call: CAPPluginCall, message: String, code: String? = nil, error: Error? = nil, data: PluginCallResultData? = nil) {
758
+ let reject = {
759
+ let savedCall = self.bridge?.savedCall(withID: call.callbackId)
760
+ let targetCall = savedCall ?? call
761
+
762
+ targetCall.reject(message, code, error, data)
763
+
764
+ if savedCall != nil {
765
+ self.bridge?.releaseCall(withID: call.callbackId)
766
+ }
767
+ }
768
+
769
+ if Thread.isMainThread {
770
+ reject()
771
+ } else {
772
+ DispatchQueue.main.async {
773
+ reject()
774
+ }
775
+ }
776
+ }
777
+
778
+ private func saveCallForAsyncHandling(_ call: CAPPluginCall) {
779
+ bridge?.saveCall(call)
780
+ }
781
+
782
+ private func notifyListenersOnMain(_ eventName: String, data: JSObject) {
783
+ let notify = {
784
+ self.notifyListeners(eventName, data: data)
785
+ }
786
+
787
+ if Thread.isMainThread {
788
+ notify()
789
+ } else {
790
+ DispatchQueue.main.async {
791
+ notify()
792
+ }
793
+ }
794
+ }
795
+
796
+ private func bundlePayload(_ bundleInfo: BundleInfo) -> JSObject {
797
+ var payload: JSObject = [:]
798
+ for (key, value) in bundleInfo.toJSON() {
799
+ payload[key] = value
800
+ }
801
+ return payload
802
+ }
803
+
491
804
  @objc func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
492
805
  let bundleInfo = bundle ?? self.implementation.getBundleInfo(id: id)
493
- self.notifyListeners("download", data: ["percent": percent, "bundle": bundleInfo.toJSON()])
806
+ var downloadPayload: JSObject = [:]
807
+ downloadPayload["percent"] = percent
808
+ downloadPayload["bundle"] = bundlePayload(bundleInfo)
809
+ self.notifyListenersOnMain("download", data: downloadPayload)
494
810
  if percent == 100 {
495
- self.notifyListeners("downloadComplete", data: ["bundle": bundleInfo.toJSON()])
811
+ var downloadCompletePayload: JSObject = [:]
812
+ downloadCompletePayload["bundle"] = bundlePayload(bundleInfo)
813
+ self.notifyListenersOnMain("downloadComplete", data: downloadCompletePayload)
496
814
  self.implementation.sendStats(action: "download_complete", versionName: bundleInfo.getVersionName())
497
815
  } else if percent.isMultiple(of: 10) || ignoreMultipleOfTen {
498
816
  self.implementation.sendStats(action: "download_\(percent)", versionName: bundleInfo.getVersionName())
@@ -541,206 +859,1420 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
541
859
  call.resolve()
542
860
  }
543
861
 
544
- @objc func setChannelUrl(_ call: CAPPluginCall) {
545
- if !getConfig().getBoolean("allowModifyUrl", false) {
546
- logger.error("setChannelUrl called without allowModifyUrl")
547
- call.reject("setChannelUrl called without allowModifyUrl set allowModifyUrl in your config to true to allow it")
548
- return
862
+ @objc func setChannelUrl(_ call: CAPPluginCall) {
863
+ if !getConfig().getBoolean("allowModifyUrl", false) {
864
+ logger.error("setChannelUrl called without allowModifyUrl")
865
+ call.reject("setChannelUrl called without allowModifyUrl set allowModifyUrl in your config to true to allow it")
866
+ return
867
+ }
868
+ guard let url = call.getString("url") else {
869
+ logger.error("setChannelUrl called without url")
870
+ call.reject("setChannelUrl called without url")
871
+ return
872
+ }
873
+ self.implementation.channelUrl = url
874
+ if persistModifyUrl {
875
+ UserDefaults.standard.set(url, forKey: channelUrlDefaultsKey)
876
+ UserDefaults.standard.synchronize()
877
+ }
878
+ call.resolve()
879
+ }
880
+
881
+ @objc func getBuiltinVersion(_ call: CAPPluginCall) {
882
+ call.resolve(["version": implementation.versionBuild])
883
+ }
884
+
885
+ @objc func getDeviceId(_ call: CAPPluginCall) {
886
+ call.resolve(["deviceId": implementation.deviceID])
887
+ }
888
+
889
+ @objc func getPluginVersion(_ call: CAPPluginCall) {
890
+ call.resolve(["version": self.pluginVersion])
891
+ }
892
+
893
+ private func manifestEntries(from manifestArray: [Any]?) -> [ManifestEntry]? {
894
+ guard let manifestArray = manifestArray else {
895
+ return nil
896
+ }
897
+ var manifestEntries: [ManifestEntry] = []
898
+ for item in manifestArray {
899
+ if let manifestDict = item as? [String: Any] {
900
+ manifestEntries.append(ManifestEntry(
901
+ file_name: manifestDict["file_name"] as? String,
902
+ file_hash: manifestDict["file_hash"] as? String,
903
+ download_url: manifestDict["download_url"] as? String
904
+ ))
905
+ }
906
+ }
907
+ return manifestEntries
908
+ }
909
+
910
+ private struct PreviewPayload: Decodable {
911
+ let version: String?
912
+ let url: String?
913
+ let checksum: String?
914
+ let sessionKey: String?
915
+ let manifest: [ManifestEntry]?
916
+ let message: String?
917
+ let error: String?
918
+ }
919
+
920
+ private func normalizedPreviewMetadataValue(_ rawValue: String?) -> String? {
921
+ guard let rawValue else {
922
+ return nil
923
+ }
924
+
925
+ let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
926
+ guard !value.isEmpty else {
927
+ return nil
928
+ }
929
+
930
+ let lowercased = value.lowercased()
931
+ guard lowercased != "undefined", lowercased != "null" else {
932
+ return nil
933
+ }
934
+
935
+ return value
936
+ }
937
+
938
+ private func previewSessions() -> [String: [String: Any]] {
939
+ guard let rawSessions = UserDefaults.standard.dictionary(forKey: self.previewSessionsDefaultsKey) else {
940
+ return [:]
941
+ }
942
+
943
+ var sessions: [String: [String: Any]] = [:]
944
+ for (id, rawValue) in rawSessions {
945
+ if let session = rawValue as? [String: Any] {
946
+ sessions[id] = session
947
+ }
948
+ }
949
+ return sessions
950
+ }
951
+
952
+ private func savePreviewSessions(_ sessions: [String: [String: Any]]) {
953
+ UserDefaults.standard.set(sessions, forKey: self.previewSessionsDefaultsKey)
954
+ UserDefaults.standard.synchronize()
955
+ }
956
+
957
+ private func metadataString(_ metadata: [String: Any], _ key: String) -> String? {
958
+ self.normalizedPreviewMetadataValue(metadata[key] as? String)
959
+ }
960
+
961
+ private func currentPreviewMetadataValue(forKey key: String) -> String? {
962
+ self.normalizedPreviewMetadataValue(UserDefaults.standard.string(forKey: key))
963
+ }
964
+
965
+ private func previewInfo(
966
+ id: String,
967
+ metadata: [String: Any],
968
+ availableBundleIds: Set<String>,
969
+ currentBundleId: String
970
+ ) -> [String: Any]? {
971
+ let bundle = self.implementation.getBundleInfo(id: id)
972
+ if !bundle.isBuiltin() && !availableBundleIds.contains(id) {
973
+ return nil
974
+ }
975
+ if bundle.isDeleted() || bundle.isErrorStatus() {
976
+ return nil
977
+ }
978
+
979
+ var info: [String: Any] = [
980
+ "id": id,
981
+ "bundle": bundle.toJSON(),
982
+ "createdAt": self.metadataString(metadata, "createdAt") ?? Date().iso8601withFractionalSeconds,
983
+ "updatedAt": self.metadataString(metadata, "updatedAt") ?? Date().iso8601withFractionalSeconds,
984
+ "lastUsedAt": self.metadataString(metadata, "lastUsedAt") ?? Date().iso8601withFractionalSeconds,
985
+ "isActive": self.previewSessionEnabled && currentBundleId == id
986
+ ]
987
+
988
+ for key in ["name", "source", "appId", "payloadUrl"] {
989
+ if let value = self.metadataString(metadata, key) {
990
+ info[key] = value
991
+ }
992
+ }
993
+
994
+ return info
995
+ }
996
+
997
+ private func listPreviewInfos(cleanup: Bool = true) -> [[String: Any]] {
998
+ let availableBundleIds = Set(self.implementation.list().map { $0.getId() })
999
+ let currentBundleId = self.implementation.getCurrentBundleId()
1000
+ var sessions = self.previewSessions()
1001
+ var previews: [[String: Any]] = []
1002
+ var staleIds: [String] = []
1003
+
1004
+ for (id, metadata) in sessions {
1005
+ if let info = self.previewInfo(
1006
+ id: id,
1007
+ metadata: metadata,
1008
+ availableBundleIds: availableBundleIds,
1009
+ currentBundleId: currentBundleId
1010
+ ) {
1011
+ previews.append(info)
1012
+ } else {
1013
+ staleIds.append(id)
1014
+ }
1015
+ }
1016
+
1017
+ if cleanup && !staleIds.isEmpty {
1018
+ for id in staleIds {
1019
+ sessions.removeValue(forKey: id)
1020
+ }
1021
+ self.savePreviewSessions(sessions)
1022
+ }
1023
+
1024
+ return previews.sorted { first, second in
1025
+ let firstUsed = first["lastUsedAt"] as? String ?? ""
1026
+ let secondUsed = second["lastUsedAt"] as? String ?? ""
1027
+ return firstUsed > secondUsed
1028
+ }
1029
+ }
1030
+
1031
+ private func storedPreviewInfo(id: String) -> [String: Any]? {
1032
+ let sessions = self.previewSessions()
1033
+ guard let metadata = sessions[id] else {
1034
+ return nil
1035
+ }
1036
+ let availableBundleIds = Set(self.implementation.list().map { $0.getId() })
1037
+ return self.previewInfo(
1038
+ id: id,
1039
+ metadata: metadata,
1040
+ availableBundleIds: availableBundleIds,
1041
+ currentBundleId: self.implementation.getCurrentBundleId()
1042
+ )
1043
+ }
1044
+
1045
+ @discardableResult
1046
+ private func recordPreviewBundle(_ bundle: BundleInfo, replacing oldId: String? = nil) -> [String: Any] {
1047
+ let now = Date().iso8601withFractionalSeconds
1048
+ var sessions = self.previewSessions()
1049
+ let id = bundle.getId()
1050
+ let replacingPreview = oldId.map { $0 != id } ?? false
1051
+ var metadata = sessions[id] ?? (replacingPreview ? sessions[oldId ?? ""] ?? [:] : [:])
1052
+
1053
+ if metadata["createdAt"] == nil {
1054
+ metadata["createdAt"] = now
1055
+ }
1056
+ metadata["updatedAt"] = now
1057
+ if metadata["lastUsedAt"] == nil || self.implementation.getCurrentBundleId() == id {
1058
+ metadata["lastUsedAt"] = now
1059
+ }
1060
+ metadata["version"] = bundle.getVersionName()
1061
+
1062
+ if !replacingPreview {
1063
+ if let appId = self.currentPreviewMetadataValue(forKey: self.previewAppIdDefaultsKey) {
1064
+ metadata["appId"] = appId
1065
+ } else {
1066
+ metadata.removeValue(forKey: "appId")
1067
+ }
1068
+
1069
+ if let payloadUrl = self.currentPreviewMetadataValue(forKey: self.previewPayloadUrlDefaultsKey) {
1070
+ metadata["payloadUrl"] = payloadUrl
1071
+ } else {
1072
+ metadata.removeValue(forKey: "payloadUrl")
1073
+ }
1074
+ }
1075
+
1076
+ if !replacingPreview {
1077
+ if let name = self.currentPreviewMetadataValue(forKey: self.previewNameDefaultsKey) {
1078
+ metadata["name"] = name
1079
+ } else {
1080
+ metadata.removeValue(forKey: "name")
1081
+ }
1082
+ }
1083
+ if self.metadataString(metadata, "name") == nil {
1084
+ metadata["name"] = bundle.getVersionName()
1085
+ }
1086
+
1087
+ if !replacingPreview {
1088
+ if let source = self.currentPreviewMetadataValue(forKey: self.previewSourceDefaultsKey) {
1089
+ metadata["source"] = source
1090
+ } else {
1091
+ metadata.removeValue(forKey: "source")
1092
+ }
1093
+ }
1094
+
1095
+ if let oldId, oldId != id {
1096
+ sessions.removeValue(forKey: oldId)
1097
+ }
1098
+ sessions[id] = metadata
1099
+ self.savePreviewSessions(sessions)
1100
+
1101
+ return self.storedPreviewInfo(id: id) ?? [
1102
+ "id": id,
1103
+ "bundle": bundle.toJSON(),
1104
+ "createdAt": now,
1105
+ "updatedAt": now,
1106
+ "lastUsedAt": now,
1107
+ "isActive": self.previewSessionEnabled && self.implementation.getCurrentBundleId() == id
1108
+ ]
1109
+ }
1110
+
1111
+ private func updateCurrentPreviewSessionMetadata(from preview: [String: Any]) {
1112
+ if let appId = self.metadataString(preview, "appId") {
1113
+ self.implementation.appId = appId
1114
+ UserDefaults.standard.set(appId, forKey: self.previewAppIdDefaultsKey)
1115
+ } else {
1116
+ self.restorePreviewPreviousAppId()
1117
+ UserDefaults.standard.removeObject(forKey: self.previewAppIdDefaultsKey)
1118
+ }
1119
+
1120
+ if let payloadUrl = self.metadataString(preview, "payloadUrl") {
1121
+ UserDefaults.standard.set(payloadUrl, forKey: self.previewPayloadUrlDefaultsKey)
1122
+ } else {
1123
+ UserDefaults.standard.removeObject(forKey: self.previewPayloadUrlDefaultsKey)
1124
+ }
1125
+
1126
+ if let name = self.metadataString(preview, "name") {
1127
+ UserDefaults.standard.set(name, forKey: self.previewNameDefaultsKey)
1128
+ } else {
1129
+ UserDefaults.standard.removeObject(forKey: self.previewNameDefaultsKey)
1130
+ }
1131
+
1132
+ if let source = self.metadataString(preview, "source") {
1133
+ UserDefaults.standard.set(source, forKey: self.previewSourceDefaultsKey)
1134
+ } else {
1135
+ UserDefaults.standard.removeObject(forKey: self.previewSourceDefaultsKey)
1136
+ }
1137
+ UserDefaults.standard.synchronize()
1138
+ }
1139
+
1140
+ private func makePreviewError(_ message: String) -> NSError {
1141
+ NSError(domain: "CapacitorUpdaterPreview", code: 0, userInfo: [NSLocalizedDescriptionKey: message])
1142
+ }
1143
+
1144
+ private func downloadBundle(urlString: String, version: String, sessionKey: String, checksum rawChecksum: String, manifestEntries: [ManifestEntry]?) throws -> BundleInfo {
1145
+ guard let url = URL(string: urlString) else {
1146
+ throw makePreviewError("Invalid download URL")
1147
+ }
1148
+
1149
+ var checksum = rawChecksum
1150
+ let next: BundleInfo
1151
+ if let manifestEntries = manifestEntries {
1152
+ next = try self.implementation.downloadManifest(manifest: manifestEntries, version: version, sessionKey: sessionKey)
1153
+ } else {
1154
+ next = try self.implementation.download(url: url, version: version, sessionKey: sessionKey)
1155
+ }
1156
+
1157
+ if self.implementation.publicKey != "" && checksum == "" {
1158
+ self.logger.error("Public key present but no checksum provided")
1159
+ self.implementation.sendStats(action: "checksum_required", versionName: next.getVersionName())
1160
+ let id = next.getId()
1161
+ let resDel = self.implementation.delete(id: id)
1162
+ if !resDel {
1163
+ self.logger.error("Delete failed, id \(id) doesn't exist")
1164
+ }
1165
+ throw ObjectSavableError.checksum
1166
+ }
1167
+
1168
+ checksum = try CryptoCipher.decryptChecksum(checksum: checksum, publicKey: self.implementation.publicKey)
1169
+ CryptoCipher.logChecksumInfo(label: "Bundle checksum", hexChecksum: next.getChecksum())
1170
+ CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: checksum)
1171
+ if (checksum != "" || self.implementation.publicKey != "") && next.getChecksum() != checksum {
1172
+ self.logger.error("Error checksum \(next.getChecksum()) \(checksum)")
1173
+ self.implementation.sendStats(action: "checksum_fail", versionName: next.getVersionName())
1174
+ let id = next.getId()
1175
+ let resDel = self.implementation.delete(id: id)
1176
+ if !resDel {
1177
+ self.logger.error("Delete failed, id \(id) doesn't exist")
1178
+ }
1179
+ throw ObjectSavableError.checksum
1180
+ }
1181
+
1182
+ self.logger.info("Good checksum \(next.getChecksum()) \(checksum)")
1183
+ return next
1184
+ }
1185
+
1186
+ @objc func download(_ call: CAPPluginCall) {
1187
+ guard let urlString = call.getString("url") else {
1188
+ logger.error("Download called without url")
1189
+ call.reject("Download called without url")
1190
+ return
1191
+ }
1192
+ guard let version = call.getString("version") else {
1193
+ logger.error("Download called without version")
1194
+ call.reject("Download called without version")
1195
+ return
1196
+ }
1197
+
1198
+ let sessionKey = call.getString("sessionKey", "")
1199
+ let checksum = call.getString("checksum", "")
1200
+ let manifestArray = call.getArray("manifest")
1201
+ logger.info("Downloading \(urlString)")
1202
+ self.saveCallForAsyncHandling(call)
1203
+ self.runBackgroundDownloadWork {
1204
+ do {
1205
+ let next = try self.downloadBundle(
1206
+ urlString: urlString,
1207
+ version: version,
1208
+ sessionKey: sessionKey,
1209
+ checksum: checksum,
1210
+ manifestEntries: self.manifestEntries(from: manifestArray)
1211
+ )
1212
+ var updateAvailablePayload: JSObject = [:]
1213
+ updateAvailablePayload["bundle"] = self.bundlePayload(next)
1214
+ self.notifyListenersOnMain("updateAvailable", data: updateAvailablePayload)
1215
+ self.resolveCall(call, data: next.toJSON())
1216
+ } catch {
1217
+ self.logger.error("Failed to download from: \(urlString) \(error.localizedDescription)")
1218
+ var downloadFailedPayload: JSObject = [:]
1219
+ downloadFailedPayload["version"] = version
1220
+ self.notifyListenersOnMain("downloadFailed", data: downloadFailedPayload)
1221
+ self.implementation.sendStats(action: "download_fail")
1222
+ self.rejectCall(call, message: "Failed to download from: \(urlString) - \(error.localizedDescription)")
1223
+ }
1224
+ }
1225
+ }
1226
+
1227
+ private func currentReloadDestination() -> URL {
1228
+ let id = self.implementation.getCurrentBundleId()
1229
+ if BundleInfo.ID_BUILTIN == id {
1230
+ return Bundle.main.resourceURL!.appendingPathComponent("public")
1231
+ } else {
1232
+ return self.implementation.getBundleDirectory(id: id)
1233
+ }
1234
+ }
1235
+
1236
+ private func applyCurrentBundleToBridge(_ bridge: CAPBridgeProtocol) -> Bool {
1237
+ let id = self.implementation.getCurrentBundleId()
1238
+ let dest = self.currentReloadDestination()
1239
+ logger.info("Reloading \(id)")
1240
+
1241
+ guard let vc = bridge.viewController as? CAPBridgeViewController else {
1242
+ self.logger.error("Cannot get viewController")
1243
+ return false
1244
+ }
1245
+ guard let capBridge = vc.bridge else {
1246
+ self.logger.error("Cannot get capBridge")
1247
+ return false
1248
+ }
1249
+ if self.keepUrlPathAfterReload {
1250
+ if let currentURL = vc.webView?.url {
1251
+ capBridge.setServerBasePath(dest.path)
1252
+ var urlComponents = URLComponents(url: capBridge.config.serverURL, resolvingAgainstBaseURL: false)!
1253
+ urlComponents.path = currentURL.path
1254
+ urlComponents.query = currentURL.query
1255
+ urlComponents.fragment = currentURL.fragment
1256
+ if let finalUrl = urlComponents.url {
1257
+ _ = vc.webView?.load(URLRequest(url: finalUrl))
1258
+ } else {
1259
+ self.logger.error("Unable to build final URL when keeping path after reload; falling back to base path")
1260
+ vc.setServerBasePath(path: dest.path)
1261
+ }
1262
+ } else {
1263
+ self.logger.error("vc.webView?.url is null? Falling back to base path reload.")
1264
+ vc.setServerBasePath(path: dest.path)
1265
+ }
1266
+ } else {
1267
+ vc.setServerBasePath(path: dest.path)
1268
+ }
1269
+ return true
1270
+ }
1271
+
1272
+ func restoreLiveBundleStateAfterFailedReload() {
1273
+ guard let bridge = self.bridge else {
1274
+ return
1275
+ }
1276
+
1277
+ let restoreLiveState = {
1278
+ _ = self.applyCurrentBundleToBridge(bridge)
1279
+ }
1280
+
1281
+ if Thread.isMainThread {
1282
+ restoreLiveState()
1283
+ } else {
1284
+ DispatchQueue.main.sync {
1285
+ restoreLiveState()
1286
+ }
1287
+ }
1288
+ }
1289
+
1290
+ public func _reload() -> Bool {
1291
+ guard let bridge = self.bridge else { return false }
1292
+ self.semaphoreUp()
1293
+
1294
+ let performReload: () -> Bool = {
1295
+ guard self.applyCurrentBundleToBridge(bridge) else {
1296
+ return false
1297
+ }
1298
+ self.checkAppReady()
1299
+ self.notifyListeners("appReloaded", data: [:])
1300
+ return true
1301
+ }
1302
+
1303
+ if Thread.isMainThread {
1304
+ return performReload()
1305
+ } else {
1306
+ var result = false
1307
+ DispatchQueue.main.sync {
1308
+ result = performReload()
1309
+ }
1310
+ return result
1311
+ }
1312
+ }
1313
+
1314
+ func reloadWithoutWaitingForAppReady() -> Bool {
1315
+ guard let bridge = self.bridge else { return false }
1316
+
1317
+ let performReload: () -> Bool = {
1318
+ guard self.applyCurrentBundleToBridge(bridge) else {
1319
+ return false
1320
+ }
1321
+ self.checkAppReady()
1322
+ self.notifyListeners("appReloaded", data: [:])
1323
+ return true
1324
+ }
1325
+
1326
+ if Thread.isMainThread {
1327
+ return performReload()
1328
+ } else {
1329
+ var result = false
1330
+ DispatchQueue.main.sync {
1331
+ result = performReload()
1332
+ }
1333
+ return result
1334
+ }
1335
+ }
1336
+
1337
+ @objc func reload(_ call: CAPPluginCall) {
1338
+ let current: BundleInfo = self.implementation.getCurrentBundle()
1339
+ let next: BundleInfo? = self.implementation.getNextBundle()
1340
+
1341
+ if !self.isPreviewSessionStateActive(),
1342
+ let next = next,
1343
+ !next.isErrorStatus(),
1344
+ next.getId() != current.getId() {
1345
+ let previousState = self.implementation.captureResetState()
1346
+ let previousBundleName = self.implementation.getCurrentBundle().getVersionName()
1347
+ logger.info("Applying pending bundle before reload: \(next.toString())")
1348
+ let didApplyPendingBundle: Bool
1349
+ if next.isBuiltin() {
1350
+ self.implementation.prepareResetStateForTransition()
1351
+ didApplyPendingBundle = true
1352
+ } else {
1353
+ didApplyPendingBundle = self.implementation.stagePendingReload(bundle: next)
1354
+ }
1355
+ if didApplyPendingBundle && self._reload() {
1356
+ if next.isBuiltin() {
1357
+ self.implementation.finalizeResetTransition(previousBundleName: previousBundleName, isInternal: false)
1358
+ } else {
1359
+ self.implementation.finalizePendingReload(bundle: next, previousBundleName: previousBundleName)
1360
+ }
1361
+ self.notifyBundleSet(next)
1362
+ _ = self.implementation.setNextBundle(next: Optional<String>.none)
1363
+ self.showPreviewSessionNoticeIfNeeded()
1364
+ call.resolve()
1365
+ return
1366
+ }
1367
+ self.implementation.restoreResetState(previousState)
1368
+ self.restoreLiveBundleStateAfterFailedReload()
1369
+ logger.error("Reload failed after applying pending bundle: \(next.toString())")
1370
+ call.reject("Reload failed after applying pending bundle")
1371
+ return
1372
+ }
1373
+
1374
+ if self._reload() {
1375
+ self.showPreviewSessionNoticeIfNeeded()
1376
+ call.resolve()
1377
+ } else {
1378
+ logger.error("Reload failed")
1379
+ call.reject("Reload failed")
1380
+ }
1381
+ }
1382
+
1383
+ private func applyDownloadedBundleForDirectUpdate(_ next: BundleInfo) -> Bool {
1384
+ let previousState = self.implementation.captureResetState()
1385
+ let previousBundleName = self.implementation.getCurrentBundle().getVersionName()
1386
+
1387
+ guard self.implementation.stagePendingReload(bundle: next) else {
1388
+ self.implementation.restoreResetState(previousState)
1389
+ logger.error("Direct update failed to stage downloaded bundle: \(next.toString())")
1390
+ return false
1391
+ }
1392
+
1393
+ if self._reload() {
1394
+ self.implementation.finalizePendingReload(bundle: next, previousBundleName: previousBundleName)
1395
+ _ = self.implementation.setNextBundle(next: Optional<String>.none)
1396
+ return true
1397
+ }
1398
+
1399
+ self.implementation.restoreResetState(previousState)
1400
+ self.restoreLiveBundleStateAfterFailedReload()
1401
+ logger.error("Direct update reload failed after staging bundle: \(next.toString())")
1402
+ return false
1403
+ }
1404
+
1405
+ @objc func next(_ call: CAPPluginCall) {
1406
+ guard let id = call.getString("id") else {
1407
+ logger.error("Next called without id")
1408
+ call.reject("Next called without id")
1409
+ return
1410
+ }
1411
+ logger.info("Setting next active id \(id)")
1412
+ if !self.implementation.setNextBundle(next: id) {
1413
+ logger.error("Set next version failed. id \(id) does not exist.")
1414
+ call.reject("Set next version failed. id \(id) does not exist.")
1415
+ } else {
1416
+ call.resolve(self.implementation.getBundleInfo(id: id).toJSON())
1417
+ }
1418
+ }
1419
+
1420
+ @objc func set(_ call: CAPPluginCall) {
1421
+ guard let id = call.getString("id") else {
1422
+ logger.error("Set called without id")
1423
+ call.reject("Set called without id")
1424
+ return
1425
+ }
1426
+ let res = implementation.set(id: id)
1427
+ logger.info("Set active bundle: \(id)")
1428
+ if !res {
1429
+ logger.info("Bundle successfully set to: \(id) ")
1430
+ call.reject("Update failed, id \(id) doesn't exist")
1431
+ } else if self.previewSessionEnabled {
1432
+ let bundle = self.implementation.getBundleInfo(id: id)
1433
+ _ = self.recordPreviewBundle(bundle)
1434
+ if !self.reloadWithoutWaitingForAppReady() {
1435
+ call.reject("Reload failed after setting preview bundle \(id)")
1436
+ return
1437
+ }
1438
+ self.notifyBundleSet(bundle)
1439
+ self.showPreviewSessionNoticeIfNeeded()
1440
+ call.resolve()
1441
+ } else if !self._reload() {
1442
+ call.reject("Reload failed after setting bundle \(id)")
1443
+ } else {
1444
+ self.notifyBundleSet(self.implementation.getBundleInfo(id: id))
1445
+ self.showPreviewSessionNoticeIfNeeded()
1446
+ call.resolve()
1447
+ }
1448
+ }
1449
+
1450
+ private func isPreviewSessionStateActive() -> Bool {
1451
+ self.previewSessionEnabled || self.isLeavingPreviewForIncomingLink || self.implementation.previewSession
1452
+ }
1453
+
1454
+ private func shouldBlockAutoUpdateForPreviewSession() -> Bool {
1455
+ guard self.isPreviewSessionStateActive() else {
1456
+ return false
1457
+ }
1458
+
1459
+ logger.info("Preview session is active. Skipping normal auto-update work.")
1460
+ return true
1461
+ }
1462
+
1463
+ private func clearIncomingPreviewTransition() {
1464
+ self.previewTransitionClearWorkItem?.cancel()
1465
+ self.previewTransitionClearWorkItem = nil
1466
+ self.isLeavingPreviewForIncomingLink = false
1467
+ if !self.previewSessionEnabled {
1468
+ self.implementation.previewSession = false
1469
+ }
1470
+ }
1471
+
1472
+ private func scheduleIncomingPreviewTransitionFallbackClear() {
1473
+ self.previewTransitionClearWorkItem?.cancel()
1474
+ let workItem = DispatchWorkItem { [weak self] in
1475
+ self?.clearIncomingPreviewTransition()
1476
+ }
1477
+ self.previewTransitionClearWorkItem = workItem
1478
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.appReadyTimeout), execute: workItem)
1479
+ }
1480
+
1481
+ private func preparePreviewFallbackIfNeeded() -> Bool {
1482
+ if self.previewSessionEnabled {
1483
+ return true
1484
+ }
1485
+
1486
+ let current = self.implementation.getCurrentBundle()
1487
+ guard self.implementation.setPreviewFallbackBundle(fallback: current.getId()) else {
1488
+ logger.error("Could not save current bundle as preview fallback")
1489
+ return false
1490
+ }
1491
+
1492
+ if let previousNext = self.implementation.getNextBundle(),
1493
+ !previousNext.isDeleted(),
1494
+ !previousNext.isErrorStatus() {
1495
+ UserDefaults.standard.set(previousNext.getId(), forKey: self.previewPreviousNextBundleDefaultsKey)
1496
+ } else {
1497
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousNextBundleDefaultsKey)
1498
+ }
1499
+
1500
+ UserDefaults.standard.set(self.implementation.appId, forKey: self.previewPreviousAppIdDefaultsKey)
1501
+ if let previousDefaultChannel = UserDefaults.standard.object(forKey: self.defaultChannelDefaultsKey) as? String {
1502
+ UserDefaults.standard.set(previousDefaultChannel, forKey: self.previewPreviousDefaultChannelDefaultsKey)
1503
+ UserDefaults.standard.set(true, forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
1504
+ } else {
1505
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelDefaultsKey)
1506
+ UserDefaults.standard.set(false, forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
1507
+ }
1508
+ UserDefaults.standard.set(self.shakeMenuEnabled, forKey: self.previewPreviousShakeMenuDefaultsKey)
1509
+ UserDefaults.standard.set(self.shakeChannelSelectorEnabled, forKey: self.previewPreviousShakeChannelSelectorDefaultsKey)
1510
+ logger.info("Preview session started with fallback bundle: \(current.toString())")
1511
+ return true
1512
+ }
1513
+
1514
+ private func activatePreviewSessionState() {
1515
+ self.clearIncomingPreviewTransition()
1516
+ self.hidePreviewTransitionLoader(reason: "preview-session-started")
1517
+ self.previewSessionEnabled = true
1518
+ self.previewSessionAlertPending = true
1519
+ self.implementation.previewSession = true
1520
+ self.shakeMenuEnabled = true
1521
+ self.syncShakeMenuGestureRecognizer()
1522
+ UserDefaults.standard.set(true, forKey: self.previewSessionDefaultsKey)
1523
+ UserDefaults.standard.set(true, forKey: self.previewSessionAlertPendingDefaultsKey)
1524
+ UserDefaults.standard.synchronize()
1525
+ }
1526
+
1527
+ @objc func startPreviewSession(_ call: CAPPluginCall) {
1528
+ guard self.allowPreview else {
1529
+ self.hidePreviewTransitionLoader(reason: "preview-session-not-allowed")
1530
+ logger.error("startPreviewSession called without allowPreview")
1531
+ call.reject("startPreviewSession not allowed. Set allowPreview to true in your config to enable it.")
1532
+ return
1533
+ }
1534
+ let previewAppId = self.normalizedPreviewAppId(call.getString("appId"))
1535
+ let rawPayloadUrl = call.getString("payloadUrl")
1536
+ let previewPayloadUrl = self.normalizedPreviewPayloadUrl(rawPayloadUrl)
1537
+ if let rawPayloadUrl = rawPayloadUrl, !rawPayloadUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, previewPayloadUrl == nil {
1538
+ self.hidePreviewTransitionLoader(reason: "preview-session-invalid-payload")
1539
+ logger.error("startPreviewSession called with invalid payloadUrl")
1540
+ call.reject("Invalid preview payloadUrl")
1541
+ return
1542
+ }
1543
+
1544
+ guard self.preparePreviewFallbackIfNeeded() else {
1545
+ self.hidePreviewTransitionLoader(reason: "preview-session-fallback-failed")
1546
+ call.reject("Could not save current bundle as preview fallback")
1547
+ return
1548
+ }
1549
+
1550
+ if let previewAppId = previewAppId, !previewAppId.isEmpty {
1551
+ self.implementation.appId = previewAppId
1552
+ UserDefaults.standard.set(previewAppId, forKey: self.previewAppIdDefaultsKey)
1553
+ logger.info("Preview session using appId: \(previewAppId)")
1554
+ }
1555
+
1556
+ if let previewPayloadUrl = previewPayloadUrl {
1557
+ UserDefaults.standard.set(previewPayloadUrl.absoluteString, forKey: self.previewPayloadUrlDefaultsKey)
1558
+ logger.info("Preview session using payload URL")
1559
+ } else {
1560
+ UserDefaults.standard.removeObject(forKey: self.previewPayloadUrlDefaultsKey)
1561
+ }
1562
+
1563
+ if let previewName = self.normalizedPreviewMetadataValue(call.getString("name")) {
1564
+ UserDefaults.standard.set(previewName, forKey: self.previewNameDefaultsKey)
1565
+ } else {
1566
+ UserDefaults.standard.removeObject(forKey: self.previewNameDefaultsKey)
1567
+ }
1568
+
1569
+ if let previewSource = self.normalizedPreviewMetadataValue(call.getString("source")) {
1570
+ UserDefaults.standard.set(previewSource, forKey: self.previewSourceDefaultsKey)
1571
+ } else {
1572
+ UserDefaults.standard.removeObject(forKey: self.previewSourceDefaultsKey)
1573
+ }
1574
+
1575
+ self.activatePreviewSessionState()
1576
+ call.resolve()
1577
+ }
1578
+
1579
+ @objc func listPreviews(_ call: CAPPluginCall) {
1580
+ guard self.allowPreview else {
1581
+ call.reject("listPreviews not allowed. Set allowPreview to true in your config to enable it.")
1582
+ return
1583
+ }
1584
+
1585
+ let previews = self.listPreviewInfos()
1586
+ var result: [String: Any] = [
1587
+ "previews": previews,
1588
+ "currentBundle": self.implementation.getCurrentBundle().toJSON()
1589
+ ]
1590
+ if let currentPreview = previews.first(where: { ($0["isActive"] as? Bool) == true }) {
1591
+ result["current"] = currentPreview
1592
+ }
1593
+ if let liveBundle = self.implementation.getPreviewFallbackBundle() {
1594
+ result["liveBundle"] = liveBundle.toJSON()
1595
+ }
1596
+ call.resolve(result)
1597
+ }
1598
+
1599
+ @objc func setPreview(_ call: CAPPluginCall) {
1600
+ guard self.allowPreview else {
1601
+ call.reject("setPreview not allowed. Set allowPreview to true in your config to enable it.")
1602
+ return
1603
+ }
1604
+ guard let id = call.getString("id"), !id.isEmpty else {
1605
+ call.reject("setPreview called without id")
1606
+ return
1607
+ }
1608
+ guard let preview = self.storedPreviewInfo(id: id) else {
1609
+ call.reject("Preview \(id) is not available locally")
1610
+ return
1611
+ }
1612
+
1613
+ self.showPreviewTransitionLoader(reason: "set-preview")
1614
+ DispatchQueue.global(qos: .userInitiated).async {
1615
+ guard self.preparePreviewFallbackIfNeeded() else {
1616
+ self.hidePreviewTransitionLoader(reason: "set-preview-fallback-failed")
1617
+ call.reject("Could not save current bundle as preview fallback")
1618
+ return
1619
+ }
1620
+
1621
+ guard self.implementation.set(id: id) else {
1622
+ self.hidePreviewTransitionLoader(reason: "set-preview-failed")
1623
+ call.reject("Preview \(id) cannot be applied")
1624
+ return
1625
+ }
1626
+
1627
+ let bundle = self.implementation.getBundleInfo(id: id)
1628
+ self.updateCurrentPreviewSessionMetadata(from: preview)
1629
+ self.activatePreviewSessionState()
1630
+ _ = self.recordPreviewBundle(bundle)
1631
+ guard self.reloadWithoutWaitingForAppReady() else {
1632
+ self.hidePreviewTransitionLoader(reason: "set-preview-reload-failed")
1633
+ call.reject("Reload failed after setting preview \(id)")
1634
+ return
1635
+ }
1636
+
1637
+ self.notifyBundleSet(bundle)
1638
+ self.showPreviewSessionNoticeIfNeeded()
1639
+ call.resolve()
1640
+ }
1641
+ }
1642
+
1643
+ func previewMenuPreviews() -> [[String: Any]] {
1644
+ self.listPreviewInfos()
1645
+ }
1646
+
1647
+ func setPreviewFromShakeMenu(id: String) -> Bool {
1648
+ guard self.allowPreview, let preview = self.storedPreviewInfo(id: id) else {
1649
+ return false
1650
+ }
1651
+
1652
+ self.showPreviewTransitionLoader(reason: "set-preview-menu")
1653
+ guard self.preparePreviewFallbackIfNeeded() else {
1654
+ self.hidePreviewTransitionLoader(reason: "set-preview-menu-fallback-failed")
1655
+ return false
1656
+ }
1657
+
1658
+ guard self.implementation.set(id: id) else {
1659
+ self.hidePreviewTransitionLoader(reason: "set-preview-menu-failed")
1660
+ return false
1661
+ }
1662
+
1663
+ let bundle = self.implementation.getBundleInfo(id: id)
1664
+ self.updateCurrentPreviewSessionMetadata(from: preview)
1665
+ self.activatePreviewSessionState()
1666
+ _ = self.recordPreviewBundle(bundle)
1667
+ guard self.reloadWithoutWaitingForAppReady() else {
1668
+ self.hidePreviewTransitionLoader(reason: "set-preview-menu-reload-failed")
1669
+ return false
1670
+ }
1671
+
1672
+ self.notifyBundleSet(bundle)
1673
+ self.showPreviewSessionNoticeIfNeeded()
1674
+ return true
1675
+ }
1676
+
1677
+ @objc func resetPreview(_ call: CAPPluginCall) {
1678
+ guard self.previewSessionEnabled else {
1679
+ call.resolve()
1680
+ return
1681
+ }
1682
+ DispatchQueue.global(qos: .userInitiated).async {
1683
+ if self.leavePreviewSessionFromShakeMenu() {
1684
+ call.resolve()
1685
+ } else {
1686
+ call.reject("Could not leave preview session")
1687
+ }
1688
+ }
1689
+ }
1690
+
1691
+ @objc func deletePreview(_ call: CAPPluginCall) {
1692
+ guard self.allowPreview else {
1693
+ call.reject("deletePreview not allowed. Set allowPreview to true in your config to enable it.")
1694
+ return
1695
+ }
1696
+ guard let id = call.getString("id"), !id.isEmpty else {
1697
+ call.reject("deletePreview called without id")
1698
+ return
1699
+ }
1700
+ if self.previewSessionEnabled && self.implementation.getCurrentBundleId() == id {
1701
+ call.reject("Cannot delete the active preview")
1702
+ return
1703
+ }
1704
+
1705
+ var sessions = self.previewSessions()
1706
+ let removed = sessions.removeValue(forKey: id) != nil
1707
+ self.savePreviewSessions(sessions)
1708
+
1709
+ var deleted = false
1710
+ let fallbackId = self.implementation.getPreviewFallbackBundle()?.getId()
1711
+ let nextId = self.implementation.getNextBundle()?.getId()
1712
+ if removed, id != fallbackId, id != nextId, id != BundleInfo.ID_BUILTIN {
1713
+ deleted = self.implementation.delete(id: id, removeInfo: false)
1714
+ }
1715
+
1716
+ call.resolve(["removed": removed, "deleted": deleted])
1717
+ }
1718
+
1719
+ @objc func checkPreviewUpdate(_ call: CAPPluginCall) {
1720
+ self.handlePreviewUpdate(call, shouldDownload: false)
1721
+ }
1722
+
1723
+ @objc func updatePreview(_ call: CAPPluginCall) {
1724
+ self.handlePreviewUpdate(call, shouldDownload: true)
1725
+ }
1726
+
1727
+ private func handlePreviewUpdate(_ call: CAPPluginCall, shouldDownload: Bool) {
1728
+ guard self.allowPreview else {
1729
+ call.reject("Preview updates not allowed. Set allowPreview to true in your config to enable it.")
1730
+ return
1731
+ }
1732
+ guard let id = call.getString("id"), !id.isEmpty else {
1733
+ call.reject("Preview update called without id")
1734
+ return
1735
+ }
1736
+ guard let preview = self.storedPreviewInfo(id: id),
1737
+ let payloadUrlString = preview["payloadUrl"] as? String,
1738
+ let payloadUrl = self.normalizedPreviewPayloadUrl(payloadUrlString) else {
1739
+ call.reject("Preview \(id) has no payloadUrl to update from")
1740
+ return
1741
+ }
1742
+
1743
+ DispatchQueue.global(qos: .userInitiated).async {
1744
+ do {
1745
+ let payload = try self.fetchPreviewPayload(payloadUrl)
1746
+ guard let version = payload.version, !version.isEmpty else {
1747
+ throw self.makePreviewError("Preview payload is missing a version")
1748
+ }
1749
+
1750
+ let currentPreviewBundle = self.implementation.getBundleInfo(id: id)
1751
+ let upToDate = currentPreviewBundle.getVersionName() == version
1752
+ if upToDate || !shouldDownload {
1753
+ call.resolve([
1754
+ "preview": preview,
1755
+ "latestVersion": version,
1756
+ "upToDate": upToDate,
1757
+ "updated": false,
1758
+ "bundle": currentPreviewBundle.toJSON()
1759
+ ])
1760
+ return
1761
+ }
1762
+
1763
+ guard payload.url != nil || payload.manifest?.isEmpty == false else {
1764
+ throw self.makePreviewError("Preview payload is missing download information")
1765
+ }
1766
+
1767
+ let next = try self.downloadBundle(
1768
+ // Fallback URL is only provided when payload.url is missing; when manifestEntries is present,
1769
+ // downloadBundle routes through downloadManifest and ignores urlString.
1770
+ urlString: payload.url ?? "https://404.capgo.app/no.zip",
1771
+ version: version,
1772
+ sessionKey: payload.sessionKey ?? "",
1773
+ checksum: payload.checksum ?? "",
1774
+ manifestEntries: payload.manifest
1775
+ )
1776
+
1777
+ let wasActive = self.previewSessionEnabled && self.implementation.getCurrentBundleId() == id
1778
+ if wasActive {
1779
+ guard self.implementation.set(id: next.getId()) else {
1780
+ throw self.makePreviewError("Downloaded preview bundle cannot be applied")
1781
+ }
1782
+ }
1783
+
1784
+ let savedPreview = self.recordPreviewBundle(next, replacing: id)
1785
+ if wasActive {
1786
+ guard self.reloadWithoutWaitingForAppReady() else {
1787
+ throw self.makePreviewError("Reload failed after updating preview")
1788
+ }
1789
+ self.notifyBundleSet(next)
1790
+ self.showPreviewSessionNoticeIfNeeded()
1791
+ }
1792
+
1793
+ call.resolve([
1794
+ "preview": savedPreview,
1795
+ "latestVersion": version,
1796
+ "upToDate": false,
1797
+ "updated": true,
1798
+ "bundle": next.toJSON()
1799
+ ])
1800
+ } catch {
1801
+ self.logger.error("Could not update preview: \(error.localizedDescription)")
1802
+ call.reject("Could not update preview: \(error.localizedDescription)")
1803
+ }
1804
+ }
1805
+ }
1806
+
1807
+ func leavePreviewSessionFromShakeMenu() -> Bool {
1808
+ self.showPreviewTransitionLoader(reason: "leave-preview-session")
1809
+ let didReset = self.resetToPreviewFallbackBundle()
1810
+ guard didReset else {
1811
+ self.hidePreviewTransitionLoader(reason: "leave-preview-session-failed")
1812
+ return false
1813
+ }
1814
+
1815
+ self.endPreviewSession(keepPreviewGuard: true)
1816
+ return true
1817
+ }
1818
+
1819
+ private func leavePreviewSessionForLaunchURLIfNeeded() {
1820
+ guard self.previewSessionEnabled,
1821
+ !self.isLeavingPreviewForIncomingLink,
1822
+ let launchUrl = ApplicationDelegateProxy.shared.lastURL,
1823
+ self.isPreviewDeepLink(launchUrl) else {
1824
+ return
1825
+ }
1826
+
1827
+ self.isLeavingPreviewForIncomingLink = true
1828
+ self.showPreviewTransitionLoader(reason: "preview-launch-deeplink")
1829
+ logger.info("Preview deeplink launch detected while preview session is active; restoring fallback before initial load")
1830
+ if !self.leavePreviewSessionWithoutReload() {
1831
+ logger.error("Could not leave preview session before initial preview deeplink routing")
1832
+ self.isLeavingPreviewForIncomingLink = false
1833
+ self.hidePreviewTransitionLoader(reason: "preview-launch-deeplink-failed")
1834
+ }
1835
+ }
1836
+
1837
+ private func leavePreviewSessionWithoutReload(keepPreviewGuard: Bool = false) -> Bool {
1838
+ guard let previewFallbackBundle = self.resolvePreviewFallbackBundle(reason: "preview deeplink launch") else {
1839
+ return false
1840
+ }
1841
+ guard self.implementation.stagePreviewFallbackReload(bundle: previewFallbackBundle) else {
1842
+ logger.error("Could not stage preview fallback bundle")
1843
+ return false
1844
+ }
1845
+
1846
+ self.endPreviewSession(keepPreviewGuard: keepPreviewGuard)
1847
+ return true
1848
+ }
1849
+
1850
+ private func leavePreviewSessionForIncomingPreviewLink() -> Bool {
1851
+ self.showPreviewTransitionLoader(reason: "incoming-preview-deeplink")
1852
+ guard let previewFallbackBundle = self.resolvePreviewFallbackBundle(reason: "incoming preview deeplink") else {
1853
+ self.clearIncomingPreviewTransition()
1854
+ self.hidePreviewTransitionLoader(reason: "incoming-preview-deeplink-failed")
1855
+ return false
1856
+ }
1857
+
1858
+ let previousState = self.implementation.captureResetState()
1859
+ guard self.implementation.stagePreviewFallbackReload(bundle: previewFallbackBundle) else {
1860
+ logger.error("Could not stage preview fallback bundle")
1861
+ self.clearIncomingPreviewTransition()
1862
+ self.hidePreviewTransitionLoader(reason: "incoming-preview-deeplink-failed")
1863
+ return false
1864
+ }
1865
+
1866
+ let didReload = self.reloadWithoutWaitingForAppReady()
1867
+ if didReload {
1868
+ self.endPreviewSession(keepPreviewGuard: true)
1869
+ self.scheduleIncomingPreviewTransitionFallbackClear()
1870
+ } else {
1871
+ self.implementation.restoreResetState(previousState)
1872
+ self.restoreLiveBundleStateAfterFailedReload()
1873
+ self.clearIncomingPreviewTransition()
1874
+ self.hidePreviewTransitionLoader(reason: "incoming-preview-deeplink-reload-failed")
1875
+ }
1876
+ return didReload
1877
+ }
1878
+
1879
+ func reloadPreviewSessionFromShakeMenu() -> Bool {
1880
+ self.showPreviewTransitionLoader(reason: "reload-preview-session")
1881
+ let didReload: Bool
1882
+ if let payloadUrl = self.storedPreviewPayloadUrl() {
1883
+ didReload = self.refreshPreviewSessionFromPayloadUrl(payloadUrl)
1884
+ } else {
1885
+ didReload = self.reloadWithoutWaitingForAppReady()
1886
+ }
1887
+
1888
+ if !didReload {
1889
+ self.hidePreviewTransitionLoader(reason: "reload-preview-session-failed")
1890
+ }
1891
+ return didReload
1892
+ }
1893
+
1894
+ func hasActivePreviewSession() -> Bool {
1895
+ self.previewSessionEnabled
1896
+ }
1897
+
1898
+ func resetToPreviewFallbackBundle() -> Bool {
1899
+ guard self.canPerformResetTransition() else { return false }
1900
+ guard let fallback = self.resolvePreviewFallbackBundle(reason: "leave preview") else {
1901
+ return false
1902
+ }
1903
+
1904
+ let previousState = self.implementation.captureResetState()
1905
+ let previousBundleName = self.implementation.getCurrentBundle().getVersionName()
1906
+ logger.info("Resetting to preview fallback bundle: \(fallback.toString())")
1907
+ if self.implementation.stagePreviewFallbackReload(bundle: fallback) && self.reloadWithoutWaitingForAppReady() {
1908
+ self.implementation.finalizeResetTransition(previousBundleName: previousBundleName, isInternal: false)
1909
+ self.notifyBundleSet(fallback)
1910
+ return true
1911
+ }
1912
+ self.implementation.restoreResetState(previousState)
1913
+ self.restoreLiveBundleStateAfterFailedReload()
1914
+ return false
1915
+ }
1916
+
1917
+ private func resolvePreviewFallbackBundle(reason: String) -> BundleInfo? {
1918
+ let fallback = self.implementation.getPreviewFallbackBundle()
1919
+ if let fallback, !fallback.isErrorStatus(), self.implementation.canSet(bundle: fallback) {
1920
+ return fallback
1921
+ }
1922
+
1923
+ if let fallback {
1924
+ if fallback.isErrorStatus() {
1925
+ logger.warn("Preview fallback bundle is in error state for \(reason). Falling back to builtin bundle.")
1926
+ } else {
1927
+ logger.warn("Preview fallback bundle is not installable for \(reason). Falling back to builtin bundle.")
1928
+ }
1929
+ } else {
1930
+ logger.warn("No preview fallback bundle available for \(reason). Falling back to builtin bundle.")
1931
+ }
1932
+
1933
+ let builtin = self.implementation.getBundleInfo(id: BundleInfo.ID_BUILTIN)
1934
+ if !builtin.isErrorStatus(), self.implementation.canSet(bundle: builtin) {
1935
+ return builtin
1936
+ }
1937
+
1938
+ logger.error("Builtin bundle is not available to leave preview for \(reason)")
1939
+ return nil
1940
+ }
1941
+
1942
+ private func endPreviewSession(keepPreviewGuard: Bool = false) {
1943
+ let previousShakeMenuEnabled = UserDefaults.standard.object(forKey: self.previewPreviousShakeMenuDefaultsKey) as? Bool
1944
+ ?? self.getBooleanConfig("shakeMenu", defaultValue: false)
1945
+ let previousShakeChannelSelectorEnabled = UserDefaults.standard.object(forKey: self.previewPreviousShakeChannelSelectorDefaultsKey) as? Bool
1946
+ ?? self.getBooleanConfig("allowShakeChannelSelector", defaultValue: false)
1947
+ self.restorePreviewPreviousNextBundle()
1948
+ self.restorePreviewPreviousAppId()
1949
+ self.restorePreviewPreviousDefaultChannel()
1950
+
1951
+ self.previewSessionEnabled = false
1952
+ self.previewSessionAlertPending = false
1953
+ if keepPreviewGuard {
1954
+ self.implementation.previewSession = true
1955
+ } else {
1956
+ self.clearIncomingPreviewTransition()
1957
+ }
1958
+ self.shakeMenuEnabled = previousShakeMenuEnabled
1959
+ self.shakeChannelSelectorEnabled = previousShakeChannelSelectorEnabled
1960
+ self.syncShakeMenuGestureRecognizer()
1961
+ _ = self.implementation.setPreviewFallbackBundle(fallback: nil)
1962
+ self.clearPreviewSessionPreferences()
1963
+ logger.info("Preview session ended")
1964
+ }
1965
+
1966
+ private func clearPreviewSessionBecauseDisabled() {
1967
+ logger.info("Preview session disabled by config; restoring preview fallback")
1968
+ if let bundleToRestore = self.resolvePreviewFallbackBundle(reason: "preview disabled") {
1969
+ _ = self.implementation.stagePreviewFallbackReload(bundle: bundleToRestore)
1970
+ } else {
1971
+ logger.warn("Could not restore preview fallback while disabling preview")
1972
+ }
1973
+
1974
+ self.restorePreviewPreviousNextBundle()
1975
+ self.restorePreviewPreviousAppId()
1976
+ self.restorePreviewPreviousDefaultChannel()
1977
+ self.previewSessionEnabled = false
1978
+ self.previewSessionAlertPending = false
1979
+ self.isLeavingPreviewForIncomingLink = false
1980
+ self.implementation.previewSession = false
1981
+ self.hidePreviewTransitionLoader(reason: "preview-session-disabled")
1982
+ self.shakeMenuEnabled = self.getBooleanConfig("shakeMenu", defaultValue: false)
1983
+ self.shakeChannelSelectorEnabled = self.getBooleanConfig("allowShakeChannelSelector", defaultValue: false)
1984
+ self.shakeMenuGesture = Self.normalizedShakeMenuGesture(self.getStringConfig("shakeMenuGesture", defaultValue: Self.shakeMenuGestureShake))
1985
+ self.syncShakeMenuGestureRecognizer()
1986
+ self.clearPreviewSessionPreferences()
1987
+ }
1988
+
1989
+ private func getBooleanConfig(_ key: String, defaultValue: Bool) -> Bool {
1990
+ guard self.bridge != nil else {
1991
+ return defaultValue
1992
+ }
1993
+ return getConfig().getBoolean(key, defaultValue)
1994
+ }
1995
+
1996
+ private func getStringConfig(_ key: String, defaultValue: String) -> String {
1997
+ guard self.bridge != nil else {
1998
+ return defaultValue
1999
+ }
2000
+ return getConfig().getString(key, defaultValue) ?? defaultValue
2001
+ }
2002
+
2003
+ private func clearPreviewSessionPreferences() {
2004
+ _ = self.implementation.setPreviewFallbackBundle(fallback: nil)
2005
+ UserDefaults.standard.removeObject(forKey: self.previewSessionDefaultsKey)
2006
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousShakeMenuDefaultsKey)
2007
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousShakeChannelSelectorDefaultsKey)
2008
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousNextBundleDefaultsKey)
2009
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousAppIdDefaultsKey)
2010
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelDefaultsKey)
2011
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
2012
+ UserDefaults.standard.removeObject(forKey: self.previewAppIdDefaultsKey)
2013
+ UserDefaults.standard.removeObject(forKey: self.previewPayloadUrlDefaultsKey)
2014
+ UserDefaults.standard.removeObject(forKey: self.previewNameDefaultsKey)
2015
+ UserDefaults.standard.removeObject(forKey: self.previewSourceDefaultsKey)
2016
+ UserDefaults.standard.removeObject(forKey: self.previewSessionAlertPendingDefaultsKey)
2017
+ UserDefaults.standard.synchronize()
2018
+ }
2019
+
2020
+ private func restorePreviewPreviousAppId() {
2021
+ guard let previousAppId = UserDefaults.standard.string(forKey: self.previewPreviousAppIdDefaultsKey),
2022
+ !previousAppId.isEmpty else {
2023
+ return
2024
+ }
2025
+ self.implementation.appId = previousAppId
2026
+ logger.info("Restored appId after preview: \(previousAppId)")
2027
+ }
2028
+
2029
+ private func restorePreviewPreviousDefaultChannel() {
2030
+ let configDefaultChannel = self.getStringConfig("defaultChannel", defaultValue: "")
2031
+ let hadPreviousDefaultChannel = UserDefaults.standard.object(forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey) as? Bool ?? false
2032
+
2033
+ guard hadPreviousDefaultChannel,
2034
+ let previousDefaultChannel = UserDefaults.standard.string(forKey: self.previewPreviousDefaultChannelDefaultsKey) else {
2035
+ UserDefaults.standard.removeObject(forKey: self.defaultChannelDefaultsKey)
2036
+ self.implementation.defaultChannel = configDefaultChannel
2037
+ logger.info("Restored defaultChannel after preview to config value")
2038
+ return
2039
+ }
2040
+
2041
+ UserDefaults.standard.set(previousDefaultChannel, forKey: self.defaultChannelDefaultsKey)
2042
+ self.implementation.defaultChannel = previousDefaultChannel
2043
+ logger.info("Restored defaultChannel after preview")
2044
+ }
2045
+
2046
+ private func normalizedPreviewAppId(_ rawAppId: String?) -> String? {
2047
+ guard let rawAppId else {
2048
+ return nil
2049
+ }
2050
+
2051
+ let appId = rawAppId.trimmingCharacters(in: .whitespacesAndNewlines)
2052
+ guard !appId.isEmpty else {
2053
+ return nil
2054
+ }
2055
+
2056
+ let lowercasedAppId = appId.lowercased()
2057
+ if lowercasedAppId == "undefined" || lowercasedAppId == "null" {
2058
+ return nil
2059
+ }
2060
+
2061
+ return appId
2062
+ }
2063
+
2064
+ private func normalizedPreviewPayloadUrl(_ rawPayloadUrl: String?) -> URL? {
2065
+ guard let rawPayloadUrl else {
2066
+ return nil
549
2067
  }
550
- guard let url = call.getString("url") else {
551
- logger.error("setChannelUrl called without url")
552
- call.reject("setChannelUrl called without url")
553
- return
2068
+
2069
+ let payloadUrl = rawPayloadUrl.trimmingCharacters(in: .whitespacesAndNewlines)
2070
+ guard !payloadUrl.isEmpty,
2071
+ let url = URL(string: payloadUrl),
2072
+ url.scheme == "https" || url.scheme == "http" else {
2073
+ return nil
554
2074
  }
555
- self.implementation.channelUrl = url
556
- if persistModifyUrl {
557
- UserDefaults.standard.set(url, forKey: channelUrlDefaultsKey)
558
- UserDefaults.standard.synchronize()
2075
+
2076
+ return url
2077
+ }
2078
+
2079
+ private func storedPreviewPayloadUrl() -> URL? {
2080
+ normalizedPreviewPayloadUrl(UserDefaults.standard.string(forKey: self.previewPayloadUrlDefaultsKey))
2081
+ }
2082
+
2083
+ private func previewPath(from url: URL) -> String {
2084
+ if url.scheme == self.previewDeepLinkScheme {
2085
+ var components: [String] = []
2086
+ if let host = url.host, !host.isEmpty {
2087
+ components.append(host)
2088
+ }
2089
+ components.append(contentsOf: url.path.split(separator: self.previewPathSeparator).map(String.init))
2090
+ return self.normalizedPreviewPath(components)
559
2091
  }
560
- call.resolve()
2092
+
2093
+ return url.path
561
2094
  }
562
2095
 
563
- @objc func getBuiltinVersion(_ call: CAPPluginCall) {
564
- call.resolve(["version": implementation.versionBuild])
2096
+ private func normalizedPreviewPath(_ components: [String]) -> String {
2097
+ let separator = String(self.previewPathSeparator)
2098
+ return separator + components.filter { !$0.isEmpty }.joined(separator: separator)
565
2099
  }
566
2100
 
567
- @objc func getDeviceId(_ call: CAPPluginCall) {
568
- call.resolve(["deviceId": implementation.deviceID])
2101
+ private func previewDeepLinkPath(_ leafComponent: String) -> String {
2102
+ self.normalizedPreviewPath([self.previewDeepLinkRootComponent, leafComponent])
569
2103
  }
570
2104
 
571
- @objc func getPluginVersion(_ call: CAPPluginCall) {
572
- call.resolve(["version": self.pluginVersion])
2105
+ private func isPreviewDeepLink(_ url: URL) -> Bool {
2106
+ let path = self.previewPath(from: url)
2107
+ return path == self.previewDeepLinkPath(self.previewDeepLinkChannelComponent) ||
2108
+ path == self.previewDeepLinkPath(self.previewDeepLinkBundleComponent)
573
2109
  }
574
2110
 
575
- @objc func download(_ call: CAPPluginCall) {
576
- guard let urlString = call.getString("url") else {
577
- logger.error("Download called without url")
578
- call.reject("Download called without url")
579
- return
580
- }
581
- guard let version = call.getString("version") else {
582
- logger.error("Download called without version")
583
- call.reject("Download called without version")
2111
+ @objc private func handleOpenURLForPreviewSession(notification: NSNotification) {
2112
+ let rawUrl = (notification.object as? [String: Any])?["url"]
2113
+ let url = rawUrl as? URL ?? (rawUrl as? NSURL).map { $0 as URL }
2114
+ guard self.previewSessionEnabled,
2115
+ !self.isLeavingPreviewForIncomingLink,
2116
+ let url,
2117
+ self.isPreviewDeepLink(url) else {
584
2118
  return
585
2119
  }
586
2120
 
587
- let sessionKey = call.getString("sessionKey", "")
588
- var checksum = call.getString("checksum", "")
589
- let manifestArray = call.getArray("manifest")
590
- let url = URL(string: urlString)
591
- logger.info("Downloading \(String(describing: url))")
592
- DispatchQueue.global(qos: .background).async {
593
- do {
594
- let next: BundleInfo
595
- if let manifestArray = manifestArray {
596
- // Convert JSArray to [ManifestEntry]
597
- var manifestEntries: [ManifestEntry] = []
598
- for item in manifestArray {
599
- if let manifestDict = item as? [String: Any] {
600
- let entry = ManifestEntry(
601
- file_name: manifestDict["file_name"] as? String,
602
- file_hash: manifestDict["file_hash"] as? String,
603
- download_url: manifestDict["download_url"] as? String
604
- )
605
- manifestEntries.append(entry)
606
- }
607
- }
608
- next = try self.implementation.downloadManifest(manifest: manifestEntries, version: version, sessionKey: sessionKey)
609
- } else {
610
- next = try self.implementation.download(url: url!, version: version, sessionKey: sessionKey)
611
- }
612
- // If public key is present but no checksum provided, refuse installation
613
- if self.implementation.publicKey != "" && checksum == "" {
614
- self.logger.error("Public key present but no checksum provided")
615
- self.implementation.sendStats(action: "checksum_required", versionName: next.getVersionName())
616
- let id = next.getId()
617
- let resDel = self.implementation.delete(id: id)
618
- if !resDel {
619
- self.logger.error("Delete failed, id \(id) doesn't exist")
620
- }
621
- throw ObjectSavableError.checksum
622
- }
623
-
624
- checksum = try CryptoCipher.decryptChecksum(checksum: checksum, publicKey: self.implementation.publicKey)
625
- CryptoCipher.logChecksumInfo(label: "Bundle checksum", hexChecksum: next.getChecksum())
626
- CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: checksum)
627
- if (checksum != "" || self.implementation.publicKey != "") && next.getChecksum() != checksum {
628
- self.logger.error("Error checksum \(next.getChecksum()) \(checksum)")
629
- self.implementation.sendStats(action: "checksum_fail", versionName: next.getVersionName())
630
- let id = next.getId()
631
- let resDel = self.implementation.delete(id: id)
632
- if !resDel {
633
- self.logger.error("Delete failed, id \(id) doesn't exist")
634
- }
635
- throw ObjectSavableError.checksum
636
- } else {
637
- self.logger.info("Good checksum \(next.getChecksum()) \(checksum)")
638
- }
639
- self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
640
- call.resolve(next.toJSON())
641
- } catch {
642
- self.logger.error("Failed to download from: \(String(describing: url)) \(error.localizedDescription)")
643
- self.notifyListeners("downloadFailed", data: ["version": version])
644
- self.implementation.sendStats(action: "download_fail")
645
- call.reject("Failed to download from: \(url!) - \(error.localizedDescription)")
2121
+ self.isLeavingPreviewForIncomingLink = true
2122
+ self.showPreviewTransitionLoader(reason: "incoming-preview-deeplink")
2123
+ logger.info("Preview deeplink received while preview session is active; restoring fallback before routing")
2124
+ DispatchQueue.global(qos: .userInitiated).async {
2125
+ let didLeave = self.leavePreviewSessionForIncomingPreviewLink()
2126
+ if !didLeave {
2127
+ self.logger.error("Could not leave preview session before routing incoming preview deeplink")
2128
+ self.isLeavingPreviewForIncomingLink = false
2129
+ self.hidePreviewTransitionLoader(reason: "incoming-preview-deeplink-failed")
646
2130
  }
647
2131
  }
648
2132
  }
649
2133
 
650
- public func _reload() -> Bool {
651
- guard let bridge = self.bridge else { return false }
652
- self.semaphoreUp()
653
- let id = self.implementation.getCurrentBundleId()
654
- let dest: URL
655
- if BundleInfo.ID_BUILTIN == id {
656
- dest = Bundle.main.resourceURL!.appendingPathComponent("public")
657
- } else {
658
- dest = self.implementation.getBundleDirectory(id: id)
2134
+ private func fetchPreviewPayload(_ payloadUrl: URL) throws -> PreviewPayload {
2135
+ var request = URLRequest(url: payloadUrl)
2136
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
2137
+
2138
+ let semaphore = DispatchSemaphore(value: 0)
2139
+ var responseData: Data?
2140
+ var response: URLResponse?
2141
+ var responseError: Error?
2142
+
2143
+ URLSession.shared.dataTask(with: request) { data, urlResponse, error in
2144
+ responseData = data
2145
+ response = urlResponse
2146
+ responseError = error
2147
+ semaphore.signal()
2148
+ }.resume()
2149
+
2150
+ if semaphore.wait(timeout: .now() + 60) == .timedOut {
2151
+ throw makePreviewError("Preview payload request timed out")
659
2152
  }
660
- logger.info("Reloading \(id)")
661
2153
 
662
- let performReload: () -> Bool = {
663
- guard let vc = bridge.viewController as? CAPBridgeViewController else {
664
- self.logger.error("Cannot get viewController")
665
- return false
2154
+ if let responseError = responseError {
2155
+ throw responseError
2156
+ }
2157
+
2158
+ let data = responseData ?? Data()
2159
+ if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
2160
+ if let payload = try? JSONDecoder().decode(PreviewPayload.self, from: data) {
2161
+ throw makePreviewError(payload.message ?? payload.error ?? "Preview payload request failed with HTTP \(httpResponse.statusCode)")
666
2162
  }
667
- guard let capBridge = vc.bridge else {
668
- self.logger.error("Cannot get capBridge")
669
- return false
2163
+ let message = String(data: data, encoding: .utf8) ?? "Preview payload request failed with HTTP \(httpResponse.statusCode)"
2164
+ throw makePreviewError(message)
2165
+ }
2166
+
2167
+ return try JSONDecoder().decode(PreviewPayload.self, from: data)
2168
+ }
2169
+
2170
+ private func refreshPreviewSessionFromPayloadUrl(_ payloadUrl: URL) -> Bool {
2171
+ do {
2172
+ let payload = try self.fetchPreviewPayload(payloadUrl)
2173
+ guard let version = payload.version, !version.isEmpty else {
2174
+ throw makePreviewError("Preview payload is missing a version")
670
2175
  }
671
- if self.keepUrlPathAfterReload {
672
- if let currentURL = vc.webView?.url {
673
- capBridge.setServerBasePath(dest.path)
674
- var urlComponents = URLComponents(url: capBridge.config.serverURL, resolvingAgainstBaseURL: false)!
675
- urlComponents.path = currentURL.path
676
- urlComponents.query = currentURL.query
677
- urlComponents.fragment = currentURL.fragment
678
- if let finalUrl = urlComponents.url {
679
- _ = vc.webView?.load(URLRequest(url: finalUrl))
680
- } else {
681
- self.logger.error("Unable to build final URL when keeping path after reload; falling back to base path")
682
- vc.setServerBasePath(path: dest.path)
683
- }
684
- } else {
685
- self.logger.error("vc.webView?.url is null? Falling back to base path reload.")
686
- vc.setServerBasePath(path: dest.path)
687
- }
688
- } else {
689
- vc.setServerBasePath(path: dest.path)
2176
+ guard payload.url != nil || payload.manifest?.isEmpty == false else {
2177
+ throw makePreviewError("Preview payload is missing download information")
690
2178
  }
691
- self.checkAppReady()
692
- self.notifyListeners("appReloaded", data: [:])
693
- return true
694
- }
695
2179
 
696
- if Thread.isMainThread {
697
- return performReload()
698
- } else {
699
- var result = false
700
- DispatchQueue.main.sync {
701
- result = performReload()
2180
+ let current = self.implementation.getCurrentBundle()
2181
+ if current.getVersionName() == version {
2182
+ self.logger.info("Preview payload unchanged, reloading current bundle")
2183
+ return self.reloadWithoutWaitingForAppReady()
702
2184
  }
703
- return result
2185
+
2186
+ let next = try self.downloadBundle(
2187
+ // Fallback URL is only provided when payload.url is missing; when manifestEntries is present,
2188
+ // downloadBundle routes through downloadManifest and ignores urlString.
2189
+ urlString: payload.url ?? "https://404.capgo.app/no.zip",
2190
+ version: version,
2191
+ sessionKey: payload.sessionKey ?? "",
2192
+ checksum: payload.checksum ?? "",
2193
+ manifestEntries: payload.manifest
2194
+ )
2195
+
2196
+ guard self.implementation.set(id: next.getId()) else {
2197
+ throw makePreviewError("Downloaded preview bundle cannot be applied")
2198
+ }
2199
+
2200
+ _ = self.recordPreviewBundle(next, replacing: current.getId())
2201
+ self.notifyBundleSet(next)
2202
+ return self.reloadWithoutWaitingForAppReady()
2203
+ } catch {
2204
+ self.logger.error("Could not refresh preview session: \(error.localizedDescription)")
2205
+ return false
704
2206
  }
705
2207
  }
706
2208
 
707
- @objc func reload(_ call: CAPPluginCall) {
708
- if self._reload() {
709
- call.resolve()
710
- } else {
711
- logger.error("Reload failed")
712
- call.reject("Reload failed")
2209
+ private func clearPreviewSessionForNativeBuildChange() {
2210
+ guard self.previewSessionEnabled || self.implementation.getPreviewFallbackBundle() != nil || !self.previewSessions().isEmpty else {
2211
+ return
713
2212
  }
2213
+ logger.info("Native build changed; clearing preview session state")
2214
+ self.previewSessionEnabled = false
2215
+ self.previewSessionAlertPending = false
2216
+ self.isLeavingPreviewForIncomingLink = false
2217
+ self.implementation.previewSession = false
2218
+ self.shakeMenuEnabled = self.getBooleanConfig("shakeMenu", defaultValue: false)
2219
+ self.shakeChannelSelectorEnabled = self.getBooleanConfig("allowShakeChannelSelector", defaultValue: false)
2220
+ self.shakeMenuGesture = Self.normalizedShakeMenuGesture(self.getStringConfig("shakeMenuGesture", defaultValue: Self.shakeMenuGestureShake))
2221
+ self.syncShakeMenuGestureRecognizer()
2222
+ self.restorePreviewPreviousAppId()
2223
+ self.restorePreviewPreviousDefaultChannel()
2224
+ _ = self.implementation.setPreviewFallbackBundle(fallback: nil)
2225
+ _ = self.implementation.setNextBundle(next: Optional<String>.none)
2226
+ self.clearPreviewSessionPreferences()
2227
+ UserDefaults.standard.removeObject(forKey: self.previewSessionsDefaultsKey)
2228
+ UserDefaults.standard.synchronize()
714
2229
  }
715
2230
 
716
- @objc func next(_ call: CAPPluginCall) {
717
- guard let id = call.getString("id") else {
718
- logger.error("Next called without id")
719
- call.reject("Next called without id")
2231
+ private func restorePreviewPreviousNextBundle() {
2232
+ guard let previousNextBundleId = UserDefaults.standard.string(forKey: self.previewPreviousNextBundleDefaultsKey),
2233
+ !previousNextBundleId.isEmpty else {
2234
+ _ = self.implementation.setNextBundle(next: Optional<String>.none)
720
2235
  return
721
2236
  }
722
- logger.info("Setting next active id \(id)")
723
- if !self.implementation.setNextBundle(next: id) {
724
- logger.error("Set next version failed. id \(id) does not exist.")
725
- call.reject("Set next version failed. id \(id) does not exist.")
726
- } else {
727
- call.resolve(self.implementation.getBundleInfo(id: id).toJSON())
2237
+ if !self.implementation.setNextBundle(next: previousNextBundleId) {
2238
+ logger.warn("Could not restore pre-preview next bundle: \(previousNextBundleId)")
2239
+ _ = self.implementation.setNextBundle(next: Optional<String>.none)
728
2240
  }
729
2241
  }
730
2242
 
731
- @objc func set(_ call: CAPPluginCall) {
732
- guard let id = call.getString("id") else {
733
- logger.error("Set called without id")
734
- call.reject("Set called without id")
2243
+ private func showPreviewSessionNoticeIfNeeded() {
2244
+ guard self.previewSessionEnabled && self.previewSessionAlertPending else {
735
2245
  return
736
2246
  }
737
- let res = implementation.set(id: id)
738
- logger.info("Set active bundle: \(id)")
739
- if !res {
740
- logger.info("Bundle successfully set to: \(id) ")
741
- call.reject("Update failed, id \(id) doesn't exist")
742
- } else {
743
- self.reload(call)
2247
+ self.previewSessionAlertPending = false
2248
+ UserDefaults.standard.set(false, forKey: self.previewSessionAlertPendingDefaultsKey)
2249
+ UserDefaults.standard.synchronize()
2250
+
2251
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(600)) {
2252
+ guard self.previewSessionEnabled else {
2253
+ return
2254
+ }
2255
+ if let topVC = UIApplication.topViewController(),
2256
+ topVC.isKind(of: UIAlertController.self) {
2257
+ self.previewSessionAlertPending = true
2258
+ UserDefaults.standard.set(true, forKey: self.previewSessionAlertPendingDefaultsKey)
2259
+ UserDefaults.standard.synchronize()
2260
+ return
2261
+ }
2262
+
2263
+ let alert = UIAlertController(
2264
+ title: "Preview started",
2265
+ message: "Shake your device anytime to reload or leave the test app.",
2266
+ preferredStyle: .alert
2267
+ )
2268
+ alert.addAction(UIAlertAction(title: "Got it", style: .default))
2269
+ if let topVC = UIApplication.topViewController() {
2270
+ topVC.present(alert, animated: true)
2271
+ } else {
2272
+ self.previewSessionAlertPending = true
2273
+ UserDefaults.standard.set(true, forKey: self.previewSessionAlertPendingDefaultsKey)
2274
+ UserDefaults.standard.synchronize()
2275
+ }
744
2276
  }
745
2277
  }
746
2278
 
@@ -803,25 +2335,132 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
803
2335
 
804
2336
  @objc func getLatest(_ call: CAPPluginCall) {
805
2337
  let channel = call.getString("channel")
806
- DispatchQueue.global(qos: .background).async {
807
- let res = self.implementation.getLatest(url: URL(string: self.updateUrl)!, channel: channel)
808
- if res.error != nil {
809
- call.reject( res.error!)
810
- } else if res.message != nil {
811
- call.reject( res.message!)
2338
+ let includeBundleSize = call.getBool("includeBundleSize", false)
2339
+ let appId = self.normalizedPreviewAppId(call.getString("appId"))
2340
+ if appId != nil && !self.allowPreview {
2341
+ logger.error("getLatest preview override called without allowPreview")
2342
+ call.reject("getLatest preview override not allowed. Set allowPreview to true in your config to enable it.")
2343
+ return
2344
+ }
2345
+ self.saveCallForAsyncHandling(call)
2346
+ runGetLatestWork {
2347
+ let res = self.implementation.getLatest(
2348
+ url: URL(string: self.updateUrl)!,
2349
+ channel: channel,
2350
+ appIdOverride: appId
2351
+ )
2352
+ if includeBundleSize {
2353
+ self.attachBundleSize(to: res)
2354
+ }
2355
+ if let error = res.error, !error.isEmpty {
2356
+ let responseKind = self.updateResponseKind(kind: res.kind)
2357
+ res.kind = responseKind
2358
+ self.notifyBreakingEventsIfNeeded(response: res, version: res.version)
2359
+ if responseKind == "failed" {
2360
+ self.rejectCall(call, message: error)
2361
+ } else {
2362
+ if res.version.isEmpty {
2363
+ res.version = self.implementation.getCurrentBundle().getVersionName()
2364
+ }
2365
+ self.resolveCall(call, data: res.toDict())
2366
+ }
2367
+ } else if let kind = res.kind, !kind.isEmpty {
2368
+ let responseKind = self.updateResponseKind(kind: kind)
2369
+ res.kind = responseKind
2370
+ self.notifyBreakingEventsIfNeeded(response: res, version: res.version)
2371
+ if responseKind != "failed" {
2372
+ if res.version.isEmpty {
2373
+ res.version = self.implementation.getCurrentBundle().getVersionName()
2374
+ }
2375
+ self.resolveCall(call, data: res.toDict())
2376
+ } else {
2377
+ self.rejectCall(call, message: res.message ?? "server did not provide a message")
2378
+ }
2379
+ } else if let message = res.message, !message.isEmpty {
2380
+ self.notifyBreakingEventsIfNeeded(response: res, version: res.version)
2381
+ self.rejectCall(call, message: message)
812
2382
  } else {
813
- call.resolve(res.toDict())
2383
+ self.resolveCall(call, data: res.toDict())
814
2384
  }
815
2385
  }
816
2386
  }
817
2387
 
2388
+ private func attachBundleSize(to res: AppVersion) {
2389
+ guard let manifest = res.manifest, !manifest.isEmpty, let updateUrl = URL(string: self.updateUrl) else {
2390
+ return
2391
+ }
2392
+ let missing = self.implementation.getMissingBundleFiles(manifest: manifest, sessionKey: res.sessionKey ?? "")
2393
+ res.missing = [
2394
+ "missing": missing.map { $0.toDict() },
2395
+ "total": manifest.count,
2396
+ "missingCount": missing.count,
2397
+ "reusableCount": manifest.count - missing.count
2398
+ ]
2399
+ res.downloadSize = self.implementation.getBundleDownloadSize(updateUrl: updateUrl, version: res.version, manifest: missing)
2400
+ }
2401
+
2402
+ @objc func getMissingBundleFiles(_ call: CAPPluginCall) {
2403
+ guard let manifest = manifestEntries(from: call.getArray("manifest")) else {
2404
+ call.reject("getMissingBundleFiles called without manifest")
2405
+ return
2406
+ }
2407
+ let sessionKey = call.getString("sessionKey", "")
2408
+ self.saveCallForAsyncHandling(call)
2409
+ DispatchQueue.global(qos: .utility).async {
2410
+ let res = self.implementation.missingBundleFilesResult(manifest: manifest, sessionKey: sessionKey)
2411
+ self.resolveCall(call, data: res)
2412
+ }
2413
+ }
2414
+
2415
+ @objc func getBundleDownloadSize(_ call: CAPPluginCall) {
2416
+ guard let manifest = manifestEntries(from: call.getArray("manifest")) else {
2417
+ call.reject("getBundleDownloadSize called without manifest")
2418
+ return
2419
+ }
2420
+ guard let updateUrl = URL(string: self.updateUrl) else {
2421
+ call.reject("getBundleDownloadSize called without valid updateUrl")
2422
+ return
2423
+ }
2424
+ let version = call.getString("version")
2425
+ self.saveCallForAsyncHandling(call)
2426
+ DispatchQueue.global(qos: .utility).async {
2427
+ let res = self.implementation.getBundleDownloadSize(updateUrl: updateUrl, version: version, manifest: manifest)
2428
+ self.resolveCall(call, data: res)
2429
+ }
2430
+ }
2431
+
2432
+ public func triggerBackgroundUpdateCheck() -> String {
2433
+ guard !self.updateUrl.isEmpty, URL(string: self.updateUrl) != nil else {
2434
+ logger.error("Error no url or wrong format")
2435
+ return "unavailable"
2436
+ }
2437
+ if self.shouldBlockAutoUpdateForPreviewSession() {
2438
+ return "preview_session"
2439
+ }
2440
+ if self.isDownloadStuckOrTimedOut() {
2441
+ logger.info("Download already in progress, skipping duplicate download request")
2442
+ return "already_running"
2443
+ }
2444
+ self.backgroundDownload()
2445
+ return "queued"
2446
+ }
2447
+
2448
+ @objc func triggerUpdateCheck(_ call: CAPPluginCall) {
2449
+ let status = self.triggerBackgroundUpdateCheck()
2450
+ call.resolve([
2451
+ "status": status,
2452
+ "queued": status == "queued"
2453
+ ])
2454
+ }
2455
+
818
2456
  @objc func unsetChannel(_ call: CAPPluginCall) {
819
2457
  let triggerAutoUpdate = call.getBool("triggerAutoUpdate", false)
820
- DispatchQueue.global(qos: .background).async {
2458
+ self.saveCallForAsyncHandling(call)
2459
+ DispatchQueue.global(qos: .utility).async {
821
2460
  let configDefaultChannel = self.getConfig().getString("defaultChannel", "")!
822
2461
  let res = self.implementation.unsetChannel(defaultChannelKey: self.defaultChannelDefaultsKey, configDefaultChannel: configDefaultChannel)
823
2462
  if res.error != "" {
824
- call.reject(res.error, "UNSETCHANNEL_FAILED", nil, [
2463
+ self.rejectCall(call, message: res.error, code: "UNSETCHANNEL_FAILED", data: [
825
2464
  "message": res.error,
826
2465
  "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
827
2466
  ])
@@ -835,7 +2474,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
835
2474
  self.logger.info("Download already in progress, skipping duplicate download request")
836
2475
  }
837
2476
  }
838
- call.resolve(res.toDict())
2477
+ self.resolveCall(call, data: res.toDict())
839
2478
  }
840
2479
  }
841
2480
  }
@@ -850,17 +2489,24 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
850
2489
  return
851
2490
  }
852
2491
  let triggerAutoUpdate = call.getBool("triggerAutoUpdate") ?? false
853
- DispatchQueue.global(qos: .background).async {
854
- let res = self.implementation.setChannel(channel: channel, defaultChannelKey: self.defaultChannelDefaultsKey, allowSetDefaultChannel: self.allowSetDefaultChannel)
2492
+ self.saveCallForAsyncHandling(call)
2493
+ DispatchQueue.global(qos: .utility).async {
2494
+ let configDefaultChannel = self.getConfig().getString("defaultChannel", "")!
2495
+ let res = self.implementation.setChannel(
2496
+ channel: channel,
2497
+ defaultChannelKey: self.defaultChannelDefaultsKey,
2498
+ allowSetDefaultChannel: self.allowSetDefaultChannel,
2499
+ configDefaultChannel: configDefaultChannel
2500
+ )
855
2501
  if res.error != "" {
856
2502
  // Fire channelPrivate event if channel doesn't allow self-assignment
857
2503
  if res.error.contains("cannot_update_via_private_channel") || res.error.contains("channel_self_set_not_allowed") {
858
- self.notifyListeners("channelPrivate", data: [
2504
+ self.notifyListenersOnMain("channelPrivate", data: [
859
2505
  "channel": channel,
860
2506
  "message": res.error
861
2507
  ])
862
2508
  }
863
- call.reject(res.error, "SETCHANNEL_FAILED", nil, [
2509
+ self.rejectCall(call, message: res.error, code: "SETCHANNEL_FAILED", data: [
864
2510
  "message": res.error,
865
2511
  "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"
866
2512
  ])
@@ -874,35 +2520,39 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
874
2520
  self.logger.info("Download already in progress, skipping duplicate download request")
875
2521
  }
876
2522
  }
877
- call.resolve(res.toDict())
2523
+ self.resolveCall(call, data: res.toDict())
878
2524
  }
879
2525
  }
880
2526
  }
881
2527
 
882
2528
  @objc func getChannel(_ call: CAPPluginCall) {
883
- DispatchQueue.global(qos: .background).async {
884
- let res = self.implementation.getChannel()
2529
+ self.saveCallForAsyncHandling(call)
2530
+ DispatchQueue.global(qos: .utility).async {
2531
+ let res = self.implementation.getChannel(defaultChannelKey: self.defaultChannelDefaultsKey)
885
2532
  if res.error != "" {
886
- call.reject(res.error, "GETCHANNEL_FAILED", nil, [
2533
+ self.rejectCall(call, message: res.error, code: "GETCHANNEL_FAILED", data: [
887
2534
  "message": res.error,
888
2535
  "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
889
2536
  ])
890
2537
  } else {
891
- call.resolve(res.toDict())
2538
+ self.resolveCall(call, data: res.toDict())
892
2539
  }
893
2540
  }
894
2541
  }
895
2542
 
896
2543
  @objc func listChannels(_ call: CAPPluginCall) {
897
- DispatchQueue.global(qos: .background).async {
2544
+ self.saveCallForAsyncHandling(call)
2545
+ DispatchQueue.global(qos: .utility).async {
898
2546
  let res = self.implementation.listChannels()
899
2547
  if res.error != "" {
900
- call.reject(res.error, "LISTCHANNELS_FAILED", nil, [
2548
+ self.rejectCall(call, message: res.error, code: "LISTCHANNELS_FAILED", data: [
901
2549
  "message": res.error,
902
2550
  "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
903
2551
  ])
904
2552
  } else {
905
- call.resolve(res.toDict())
2553
+ var payload: JSObject = [:]
2554
+ payload["channels"] = res.channels
2555
+ self.resolveCall(call, data: payload)
906
2556
  }
907
2557
  }
908
2558
  }
@@ -925,32 +2575,90 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
925
2575
  call.resolve()
926
2576
  }
927
2577
 
928
- @objc func _reset(toLastSuccessful: Bool) -> Bool {
929
- guard let bridge = self.bridge else { return false }
2578
+ @objc func _reset(toLastSuccessful: Bool, usePendingBundle: Bool) -> Bool {
2579
+ self.performReset(toLastSuccessful: toLastSuccessful, usePendingBundle: usePendingBundle, isInternal: false)
2580
+ }
930
2581
 
931
- if (bridge.viewController as? CAPBridgeViewController) != nil {
932
- let fallback: BundleInfo = self.implementation.getFallbackBundle()
2582
+ func performReset(toLastSuccessful: Bool, usePendingBundle: Bool, isInternal: Bool) -> Bool {
2583
+ guard self.canPerformResetTransition() else { return false }
933
2584
 
934
- // If developer wants to reset to the last successful bundle, and that bundle is not
935
- // the built-in bundle, set it as the bundle to use and reload.
936
- if toLastSuccessful && !fallback.isBuiltin() {
937
- logger.info("Resetting to: \(fallback.toString())")
938
- return self.implementation.set(bundle: fallback) && self._reload()
939
- }
2585
+ let fallback: BundleInfo = self.implementation.getFallbackBundle()
2586
+ let pending: BundleInfo? = self.implementation.getNextBundle()
2587
+ let previousState = self.implementation.captureResetState()
2588
+ let previousBundleName = self.implementation.getCurrentBundle().getVersionName()
940
2589
 
941
- logger.info("Resetting to builtin version")
2590
+ if usePendingBundle {
2591
+ guard let pending = pending, !pending.isErrorStatus() else {
2592
+ logger.error("No pending bundle available to reset to")
2593
+ return false
2594
+ }
2595
+ guard self.implementation.canSet(bundle: pending) else {
2596
+ logger.error("Pending bundle is not installable")
2597
+ return false
2598
+ }
2599
+ self.implementation.prepareResetStateForTransition()
2600
+ logger.info("Resetting to pending bundle: \(pending.toString())")
2601
+ let didApplyPendingBundle: Bool
2602
+ if pending.isBuiltin() {
2603
+ didApplyPendingBundle = true
2604
+ } else {
2605
+ didApplyPendingBundle = self.implementation.set(bundle: pending)
2606
+ }
2607
+ if didApplyPendingBundle && self._reload() {
2608
+ self.implementation.finalizeResetTransition(previousBundleName: previousBundleName, isInternal: isInternal)
2609
+ self.notifyBundleSet(pending)
2610
+ _ = self.implementation.setNextBundle(next: Optional<String>.none)
2611
+ return true
2612
+ }
2613
+ self.implementation.restoreResetState(previousState)
2614
+ self.restoreLiveBundleStateAfterFailedReload()
2615
+ return false
2616
+ }
942
2617
 
943
- // Otherwise, reset back to the built-in bundle and reload.
944
- self.implementation.reset()
945
- return self._reload()
2618
+ // If developer wants to reset to the last successful bundle, and that bundle is not
2619
+ // the built-in bundle, set it as the bundle to use and reload.
2620
+ if toLastSuccessful && !fallback.isBuiltin() {
2621
+ if self.implementation.canSet(bundle: fallback) {
2622
+ self.implementation.prepareResetStateForTransition()
2623
+ logger.info("Resetting to: \(fallback.toString())")
2624
+ if self.implementation.set(bundle: fallback) && self._reload() {
2625
+ self.implementation.finalizeResetTransition(previousBundleName: previousBundleName, isInternal: isInternal)
2626
+ self.notifyBundleSet(fallback)
2627
+ return true
2628
+ }
2629
+ if !isInternal {
2630
+ self.implementation.restoreResetState(previousState)
2631
+ self.restoreLiveBundleStateAfterFailedReload()
2632
+ return false
2633
+ }
2634
+ logger.warn("Fallback reload failed during internal reset, resetting to builtin instead")
2635
+ } else {
2636
+ logger.warn("Fallback bundle is not installable, resetting to builtin instead")
2637
+ }
946
2638
  }
947
2639
 
2640
+ self.implementation.prepareResetStateForTransition()
2641
+ logger.info("Resetting to builtin version")
2642
+ if self._reload() {
2643
+ self.implementation.finalizeResetTransition(previousBundleName: previousBundleName, isInternal: isInternal)
2644
+ return true
2645
+ }
2646
+ if !isInternal {
2647
+ self.implementation.restoreResetState(previousState)
2648
+ self.restoreLiveBundleStateAfterFailedReload()
2649
+ }
948
2650
  return false
949
2651
  }
950
2652
 
2653
+ func canPerformResetTransition() -> Bool {
2654
+ guard let bridge = self.bridge else { return false }
2655
+ return (bridge.viewController as? CAPBridgeViewController) != nil
2656
+ }
2657
+
951
2658
  @objc func reset(_ call: CAPPluginCall) {
952
2659
  let toLastSuccessful = call.getBool("toLastSuccessful") ?? false
953
- if self._reset(toLastSuccessful: toLastSuccessful) {
2660
+ let usePendingBundle = call.getBool("usePendingBundle") ?? false
2661
+ if self._reset(toLastSuccessful: toLastSuccessful, usePendingBundle: usePendingBundle) {
954
2662
  call.resolve()
955
2663
  } else {
956
2664
  logger.error("Reset failed")
@@ -971,6 +2679,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
971
2679
  let bundle = self.implementation.getCurrentBundle()
972
2680
  self.implementation.setSuccess(bundle: bundle, autoDeletePrevious: self.autoDeletePrevious)
973
2681
  logger.info("Current bundle loaded successfully. [notifyAppReady was called] \(bundle.toString())")
2682
+ self.clearIncomingPreviewTransition()
2683
+ self.hidePreviewTransitionLoader(reason: "notify-app-ready")
2684
+
974
2685
  call.resolve(["bundle": bundle.toJSON()])
975
2686
  }
976
2687
 
@@ -1020,6 +2731,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1020
2731
  // Note: _checkCancelDelay method has been moved to DelayUpdateUtils class
1021
2732
 
1022
2733
  private func _isAutoUpdateEnabled() -> Bool {
2734
+ if self.isPreviewSessionStateActive() {
2735
+ return false
2736
+ }
1023
2737
  let instanceDescriptor = (self.bridge?.viewController as? CAPBridgeViewController)?.instanceDescriptor()
1024
2738
  if instanceDescriptor?.serverURL != nil {
1025
2739
  logger.warn("AutoUpdate is automatic disabled when serverUrl is set.")
@@ -1057,10 +2771,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1057
2771
  logger.info("Built-in bundle is active. We skip the check for notifyAppReady.")
1058
2772
  return
1059
2773
  }
2774
+ if self.isPreviewSessionStateActive() {
2775
+ logger.info("Preview session is active. We skip the check for notifyAppReady.")
2776
+ return
2777
+ }
1060
2778
 
1061
2779
  logger.info("Current bundle is: \(current.toString())")
1062
2780
 
1063
- if BundleStatus.SUCCESS.localizedString != current.getStatus() {
2781
+ if BundleStatus.SUCCESS.storedValue != current.getStatus() {
1064
2782
  logger.error("notifyAppReady was not called, roll back current bundle: \(current.toString())")
1065
2783
  logger.error("Did you forget to call 'notifyAppReady()' in your Capacitor App code?")
1066
2784
  self.notifyListeners("updateFailed", data: [
@@ -1069,7 +2787,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1069
2787
  self.persistLastFailedBundle(current)
1070
2788
  self.implementation.sendStats(action: "update_fail", versionName: current.getVersionName())
1071
2789
  self.implementation.setError(bundle: current)
1072
- _ = self._reset(toLastSuccessful: true)
2790
+ _ = self.performReset(toLastSuccessful: true, usePendingBundle: false, isInternal: true)
1073
2791
  if self.autoDeleteFailed && !current.isBuiltin() {
1074
2792
  logger.info("Deleting failing bundle: \(current.toString())")
1075
2793
  let res = self.implementation.delete(id: current.getId(), removeInfo: false)
@@ -1094,6 +2812,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1094
2812
  self.backgroundTaskID = UIBackgroundTaskIdentifier.invalid
1095
2813
  }
1096
2814
 
2815
+ private func notifyBundleSet(_ bundle: BundleInfo) {
2816
+ self.notifyListeners("set", data: ["bundle": bundle.toJSON()], retainUntilConsumed: true)
2817
+ }
2818
+
1097
2819
  func sendReadyToJs(current: BundleInfo, msg: String) {
1098
2820
  logger.info("sendReadyToJs")
1099
2821
  DispatchQueue.global().async {
@@ -1105,6 +2827,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1105
2827
  if self.autoSplashscreen {
1106
2828
  self.hideSplashscreen()
1107
2829
  }
2830
+ self.hidePreviewTransitionLoader(reason: "app-ready")
1108
2831
  }
1109
2832
  }
1110
2833
 
@@ -1121,32 +2844,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1121
2844
  private func performHideSplashscreen() {
1122
2845
  self.cancelSplashscreenTimeout()
1123
2846
  self.removeSplashscreenLoader()
1124
-
1125
- guard let bridge = self.bridge else {
1126
- self.logger.warn("Bridge not available for hiding splashscreen with autoSplashscreen")
1127
- return
1128
- }
1129
-
1130
- // Create a plugin call for the hide method
1131
- let call = CAPPluginCall(callbackId: "autoHideSplashscreen", options: [:], success: { (_, _) in
1132
- self.logger.info("Splashscreen hidden automatically")
1133
- }, error: { (_) in
1134
- self.logger.error("Failed to auto-hide splashscreen")
1135
- })
1136
-
1137
- // Try to call the SplashScreen hide method directly through the bridge
1138
- if let splashScreenPlugin = bridge.plugin(withName: "SplashScreen") {
1139
- // Use runtime method invocation to call hide method
1140
- let selector = NSSelectorFromString("hide:")
1141
- if splashScreenPlugin.responds(to: selector) {
1142
- _ = splashScreenPlugin.perform(selector, with: call)
1143
- self.logger.info("Called SplashScreen hide method")
1144
- } else {
1145
- self.logger.warn("autoSplashscreen: SplashScreen plugin does not respond to hide: method. Make sure @capacitor/splash-screen plugin is properly installed.")
1146
- }
1147
- } else {
1148
- self.logger.warn("autoSplashscreen: SplashScreen plugin not found. Install @capacitor/splash-screen plugin.")
1149
- }
2847
+ self.splashscreenInvocationToken += 1
2848
+ self.invokeSplashscreenMethod(
2849
+ methodName: "hide",
2850
+ callbackId: "autoHideSplashscreen",
2851
+ options: self.splashscreenOptions(methodName: "hide"),
2852
+ retriesRemaining: self.splashscreenMaxRetries,
2853
+ requestToken: self.splashscreenInvocationToken
2854
+ )
1150
2855
  }
1151
2856
 
1152
2857
  private func showSplashscreen() {
@@ -1157,40 +2862,182 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1157
2862
  self.performShowSplashscreen()
1158
2863
  }
1159
2864
  }
1160
- }
2865
+ }
2866
+
2867
+ private func performShowSplashscreen() {
2868
+ self.cancelSplashscreenTimeout()
2869
+ self.autoSplashscreenTimedOut = false
2870
+ self.splashscreenInvocationToken += 1
2871
+ self.invokeSplashscreenMethod(
2872
+ methodName: "show",
2873
+ callbackId: "autoShowSplashscreen",
2874
+ options: self.splashscreenOptions(methodName: "show"),
2875
+ retriesRemaining: self.splashscreenMaxRetries,
2876
+ requestToken: self.splashscreenInvocationToken
2877
+ )
2878
+
2879
+ self.addSplashscreenLoaderIfNeeded()
2880
+ self.scheduleSplashscreenTimeout()
2881
+ }
2882
+
2883
+ private func splashscreenOptions(methodName: String) -> [String: Any] {
2884
+ methodName == "show" ? ["autoHide": false] : [:]
2885
+ }
2886
+
2887
+ private func splashscreenCompletedMessage(methodName: String) -> String {
2888
+ methodName == "show" ? "Splashscreen shown automatically" : "Splashscreen hidden automatically"
2889
+ }
2890
+
2891
+ func splashscreenOptionsForTesting(methodName: String) -> [String: Any] {
2892
+ self.splashscreenOptions(methodName: methodName)
2893
+ }
2894
+
2895
+ func isCurrentSplashscreenInvocationTokenForTesting(_ requestToken: Int) -> Bool {
2896
+ requestToken == self.splashscreenInvocationToken
2897
+ }
2898
+
2899
+ func advanceSplashscreenInvocationTokenForTesting() {
2900
+ self.splashscreenInvocationToken += 1
2901
+ }
2902
+
2903
+ private func makeSplashscreenCall(callbackId: String, options: [String: Any], methodName: String) -> CAPPluginCall {
2904
+ CAPPluginCall(callbackId: callbackId, options: options, success: { [weak self] (_, _) in
2905
+ guard let self = self else { return }
2906
+ self.logger.info(self.splashscreenCompletedMessage(methodName: methodName))
2907
+ }, error: { [weak self] (_) in
2908
+ guard let self = self else { return }
2909
+ self.logger.error("Failed to auto-\(methodName) splashscreen")
2910
+ })
2911
+ }
2912
+
2913
+ private func invokeSplashscreenMethod(
2914
+ methodName: String,
2915
+ callbackId: String,
2916
+ options: [String: Any],
2917
+ retriesRemaining: Int,
2918
+ requestToken: Int
2919
+ ) {
2920
+ guard requestToken == self.splashscreenInvocationToken else {
2921
+ return
2922
+ }
2923
+
2924
+ guard let bridge = self.bridge else {
2925
+ self.retrySplashscreenMethod(
2926
+ methodName: methodName,
2927
+ callbackId: callbackId,
2928
+ options: options,
2929
+ retriesRemaining: retriesRemaining,
2930
+ requestToken: requestToken,
2931
+ message: "Bridge not available for \(methodName == "show" ? "showing" : "hiding") splashscreen with autoSplashscreen"
2932
+ )
2933
+ return
2934
+ }
1161
2935
 
1162
- private func performShowSplashscreen() {
1163
- self.cancelSplashscreenTimeout()
1164
- self.autoSplashscreenTimedOut = false
2936
+ guard let splashScreenPlugin = bridge.plugin(withName: self.splashscreenPluginName) else {
2937
+ self.retrySplashscreenMethod(
2938
+ methodName: methodName,
2939
+ callbackId: callbackId,
2940
+ options: options,
2941
+ retriesRemaining: retriesRemaining,
2942
+ requestToken: requestToken,
2943
+ message: "autoSplashscreen: SplashScreen plugin not found. Install @capacitor/splash-screen plugin."
2944
+ )
2945
+ return
2946
+ }
1165
2947
 
1166
- guard let bridge = self.bridge else {
1167
- self.logger.warn("Bridge not available for showing splashscreen with autoSplashscreen")
2948
+ let selector = NSSelectorFromString("\(methodName):")
2949
+ guard splashScreenPlugin.responds(to: selector) else {
2950
+ self.retrySplashscreenMethod(
2951
+ methodName: methodName,
2952
+ callbackId: callbackId,
2953
+ options: options,
2954
+ retriesRemaining: retriesRemaining,
2955
+ requestToken: requestToken,
2956
+ message: "autoSplashscreen: SplashScreen plugin does not respond to \(methodName): method. Make sure @capacitor/splash-screen plugin is properly installed."
2957
+ )
1168
2958
  return
1169
2959
  }
1170
2960
 
1171
- // Create a plugin call for the show method
1172
- let call = CAPPluginCall(callbackId: "autoShowSplashscreen", options: [:], success: { (_, _) in
1173
- self.logger.info("Splashscreen shown automatically")
1174
- }, error: { (_) in
1175
- self.logger.error("Failed to auto-show splashscreen")
1176
- })
2961
+ let call = self.makeSplashscreenCall(callbackId: callbackId, options: options, methodName: methodName)
2962
+ _ = splashScreenPlugin.perform(selector, with: call)
2963
+ self.logger.info("Called SplashScreen \(methodName) method")
2964
+ }
1177
2965
 
1178
- // Try to call the SplashScreen show method directly through the bridge
1179
- if let splashScreenPlugin = bridge.plugin(withName: "SplashScreen") {
1180
- // Use runtime method invocation to call show method
1181
- let selector = NSSelectorFromString("show:")
1182
- if splashScreenPlugin.responds(to: selector) {
1183
- _ = splashScreenPlugin.perform(selector, with: call)
1184
- self.logger.info("Called SplashScreen show method")
2966
+ private func retrySplashscreenMethod(
2967
+ methodName: String,
2968
+ callbackId: String,
2969
+ options: [String: Any],
2970
+ retriesRemaining: Int,
2971
+ requestToken: Int,
2972
+ message: String
2973
+ ) {
2974
+ guard retriesRemaining > 0 else {
2975
+ if methodName == "show" {
2976
+ self.logger.warn(message)
1185
2977
  } else {
1186
- self.logger.warn("autoSplashscreen: SplashScreen plugin does not respond to show: method. Make sure @capacitor/splash-screen plugin is properly installed.")
2978
+ self.logger.error(message)
2979
+ }
2980
+ return
2981
+ }
2982
+
2983
+ self.logger.info("\(message). Retrying.")
2984
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.splashscreenRetryDelayMilliseconds)) { [weak self] in
2985
+ guard let self = self, requestToken == self.splashscreenInvocationToken else {
2986
+ return
1187
2987
  }
2988
+ self.invokeSplashscreenMethod(
2989
+ methodName: methodName,
2990
+ callbackId: callbackId,
2991
+ options: options,
2992
+ retriesRemaining: retriesRemaining - 1,
2993
+ requestToken: requestToken
2994
+ )
2995
+ }
2996
+ }
2997
+
2998
+ private func createLoaderOverlay(
2999
+ backgroundColor: UIColor,
3000
+ isUserInteractionEnabled: Bool,
3001
+ indicatorColor: UIColor?
3002
+ ) -> (container: UIView, indicator: UIActivityIndicatorView) {
3003
+ let container = UIView()
3004
+ container.translatesAutoresizingMaskIntoConstraints = false
3005
+ container.backgroundColor = backgroundColor
3006
+ container.isUserInteractionEnabled = isUserInteractionEnabled
3007
+
3008
+ let indicatorStyle: UIActivityIndicatorView.Style
3009
+ if #available(iOS 13.0, *) {
3010
+ indicatorStyle = .large
1188
3011
  } else {
1189
- self.logger.warn("autoSplashscreen: SplashScreen plugin not found. Install @capacitor/splash-screen plugin.")
3012
+ indicatorStyle = .whiteLarge
1190
3013
  }
1191
3014
 
1192
- self.addSplashscreenLoaderIfNeeded()
1193
- self.scheduleSplashscreenTimeout()
3015
+ let indicator = UIActivityIndicatorView(style: indicatorStyle)
3016
+ indicator.translatesAutoresizingMaskIntoConstraints = false
3017
+ indicator.hidesWhenStopped = false
3018
+ if let indicatorColor = indicatorColor {
3019
+ indicator.color = indicatorColor
3020
+ }
3021
+ indicator.startAnimating()
3022
+
3023
+ return (container, indicator)
3024
+ }
3025
+
3026
+ private func attachLoaderOverlay(
3027
+ _ overlay: (container: UIView, indicator: UIActivityIndicatorView),
3028
+ to rootView: UIView
3029
+ ) {
3030
+ overlay.container.addSubview(overlay.indicator)
3031
+ rootView.addSubview(overlay.container)
3032
+
3033
+ NSLayoutConstraint.activate([
3034
+ overlay.container.leadingAnchor.constraint(equalTo: rootView.leadingAnchor),
3035
+ overlay.container.trailingAnchor.constraint(equalTo: rootView.trailingAnchor),
3036
+ overlay.container.topAnchor.constraint(equalTo: rootView.topAnchor),
3037
+ overlay.container.bottomAnchor.constraint(equalTo: rootView.bottomAnchor),
3038
+ overlay.indicator.centerXAnchor.constraint(equalTo: overlay.container.centerXAnchor),
3039
+ overlay.indicator.centerYAnchor.constraint(equalTo: overlay.container.centerYAnchor)
3040
+ ])
1194
3041
  }
1195
3042
 
1196
3043
  private func addSplashscreenLoaderIfNeeded() {
@@ -1207,40 +3054,20 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1207
3054
  return
1208
3055
  }
1209
3056
 
1210
- let container = UIView()
1211
- container.translatesAutoresizingMaskIntoConstraints = false
1212
- container.backgroundColor = UIColor.clear
1213
- container.isUserInteractionEnabled = false
1214
-
1215
- let indicatorStyle: UIActivityIndicatorView.Style
3057
+ let indicatorColor: UIColor?
1216
3058
  if #available(iOS 13.0, *) {
1217
- indicatorStyle = .large
3059
+ indicatorColor = UIColor.label
1218
3060
  } else {
1219
- indicatorStyle = .whiteLarge
1220
- }
1221
-
1222
- let indicator = UIActivityIndicatorView(style: indicatorStyle)
1223
- indicator.translatesAutoresizingMaskIntoConstraints = false
1224
- indicator.hidesWhenStopped = false
1225
- if #available(iOS 13.0, *) {
1226
- indicator.color = UIColor.label
3061
+ indicatorColor = nil
1227
3062
  }
1228
- indicator.startAnimating()
1229
-
1230
- container.addSubview(indicator)
1231
- rootView.addSubview(container)
1232
-
1233
- NSLayoutConstraint.activate([
1234
- container.leadingAnchor.constraint(equalTo: rootView.leadingAnchor),
1235
- container.trailingAnchor.constraint(equalTo: rootView.trailingAnchor),
1236
- container.topAnchor.constraint(equalTo: rootView.topAnchor),
1237
- container.bottomAnchor.constraint(equalTo: rootView.bottomAnchor),
1238
- indicator.centerXAnchor.constraint(equalTo: container.centerXAnchor),
1239
- indicator.centerYAnchor.constraint(equalTo: container.centerYAnchor)
1240
- ])
1241
-
1242
- self.splashscreenLoaderContainer = container
1243
- self.splashscreenLoaderView = indicator
3063
+ let overlay = self.createLoaderOverlay(
3064
+ backgroundColor: UIColor.clear,
3065
+ isUserInteractionEnabled: false,
3066
+ indicatorColor: indicatorColor
3067
+ )
3068
+ self.attachLoaderOverlay(overlay, to: rootView)
3069
+ self.splashscreenLoaderContainer = overlay.container
3070
+ self.splashscreenLoaderView = overlay.indicator
1244
3071
  }
1245
3072
 
1246
3073
  if Thread.isMainThread {
@@ -1269,6 +3096,97 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1269
3096
  }
1270
3097
  }
1271
3098
 
3099
+ private func showPreviewTransitionLoader(reason: String) {
3100
+ self.previewTransitionLoaderRequested = true
3101
+ let showLoader = {
3102
+ guard self.previewTransitionLoaderRequested else {
3103
+ return
3104
+ }
3105
+
3106
+ if let container = self.previewTransitionLoaderContainer {
3107
+ self.previewTransitionLoaderTimeoutWorkItem?.cancel()
3108
+ self.schedulePreviewTransitionLoaderTimeout()
3109
+ container.superview?.bringSubviewToFront(container)
3110
+ return
3111
+ }
3112
+
3113
+ guard let rootView = self.bridge?.viewController?.view else {
3114
+ self.logger.warn("Preview transition loader unavailable: root view missing for \(reason)")
3115
+ self.previewTransitionLoaderRequested = false
3116
+ return
3117
+ }
3118
+
3119
+ self.previewTransitionLoaderTimeoutWorkItem?.cancel()
3120
+ self.schedulePreviewTransitionLoaderTimeout()
3121
+
3122
+ let indicatorColor: UIColor?
3123
+ if #available(iOS 13.0, *) {
3124
+ indicatorColor = UIColor.white
3125
+ } else {
3126
+ indicatorColor = nil
3127
+ }
3128
+ let overlay = self.createLoaderOverlay(
3129
+ backgroundColor: UIColor.black.withAlphaComponent(0.18),
3130
+ isUserInteractionEnabled: true,
3131
+ indicatorColor: indicatorColor
3132
+ )
3133
+ self.attachLoaderOverlay(overlay, to: rootView)
3134
+ self.previewTransitionLoaderContainer = overlay.container
3135
+ self.previewTransitionLoaderView = overlay.indicator
3136
+ self.logger.info("Preview transition loader shown: \(reason)")
3137
+ }
3138
+
3139
+ if Thread.isMainThread {
3140
+ showLoader()
3141
+ } else {
3142
+ DispatchQueue.main.async {
3143
+ showLoader()
3144
+ }
3145
+ }
3146
+ }
3147
+
3148
+ private func hidePreviewTransitionLoader(reason: String) {
3149
+ if !self.previewTransitionLoaderRequested &&
3150
+ self.previewTransitionLoaderContainer == nil &&
3151
+ self.previewTransitionLoaderTimeoutWorkItem == nil {
3152
+ return
3153
+ }
3154
+
3155
+ let hideLoader = {
3156
+ self.previewTransitionLoaderRequested = false
3157
+ self.previewTransitionLoaderTimeoutWorkItem?.cancel()
3158
+ self.previewTransitionLoaderTimeoutWorkItem = nil
3159
+ guard self.previewTransitionLoaderContainer != nil else {
3160
+ return
3161
+ }
3162
+ self.previewTransitionLoaderView?.stopAnimating()
3163
+ self.previewTransitionLoaderContainer?.removeFromSuperview()
3164
+ self.previewTransitionLoaderView = nil
3165
+ self.previewTransitionLoaderContainer = nil
3166
+ self.logger.info("Preview transition loader hidden: \(reason)")
3167
+ }
3168
+
3169
+ if Thread.isMainThread {
3170
+ hideLoader()
3171
+ } else {
3172
+ DispatchQueue.main.async {
3173
+ hideLoader()
3174
+ }
3175
+ }
3176
+ }
3177
+
3178
+ private func schedulePreviewTransitionLoaderTimeout() {
3179
+ self.previewTransitionLoaderTimeoutWorkItem?.cancel()
3180
+ let workItem = DispatchWorkItem { [weak self] in
3181
+ self?.hidePreviewTransitionLoader(reason: "preview-transition-timeout")
3182
+ }
3183
+ self.previewTransitionLoaderTimeoutWorkItem = workItem
3184
+ DispatchQueue.main.asyncAfter(
3185
+ deadline: .now() + .milliseconds(Self.previewLoaderTimeoutMs),
3186
+ execute: workItem
3187
+ )
3188
+ }
3189
+
1272
3190
  private func scheduleSplashscreenTimeout() {
1273
3191
  guard self.autoSplashscreenTimeout > 0 else {
1274
3192
  return
@@ -1328,6 +3246,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1328
3246
  }
1329
3247
 
1330
3248
  private func shouldUseDirectUpdate() -> Bool {
3249
+ if !self.autoUpdate || self.autoUpdateMode == Self.autoUpdateModeOnlyDownload {
3250
+ return false
3251
+ }
1331
3252
  if self.autoSplashscreenTimedOut {
1332
3253
  return false
1333
3254
  }
@@ -1344,7 +3265,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1344
3265
  }
1345
3266
  return false
1346
3267
  case "onLaunch":
1347
- if !self.onLaunchDirectUpdateUsed {
3268
+ if !self.getOnLaunchDirectUpdateUsed() {
1348
3269
  return true
1349
3270
  }
1350
3271
  return false
@@ -1354,6 +3275,183 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1354
3275
  }
1355
3276
  }
1356
3277
 
3278
+ private func configureAutoUpdateModeFromConfig() {
3279
+ if let configuredMode = getConfig().getString("autoUpdate"),
3280
+ configuredMode != "",
3281
+ configuredMode != "true",
3282
+ configuredMode != "false" {
3283
+ autoUpdateMode = Self.normalizedAutoUpdateMode(configuredMode)
3284
+ if autoUpdateMode != configuredMode {
3285
+ logger.error(
3286
+ "Invalid autoUpdate value: \"\(configuredMode)\". Supported values are: true, false, " +
3287
+ "\"off\", \"atBackground\", \"atInstall\", \"onLaunch\", \"always\", \"onlyDownload\". Defaulting to \"atBackground\"."
3288
+ )
3289
+ }
3290
+ } else {
3291
+ let configuredMode = getConfig().getString("autoUpdate")
3292
+ let enabled = configuredMode != nil ? configuredMode == "true" : getConfig().getBoolean("autoUpdate", true)
3293
+ autoUpdateMode = enabled
3294
+ ? Self.autoUpdateModeForLegacyDirectUpdateMode(resolveLegacyDirectUpdateModeFromConfig())
3295
+ : Self.autoUpdateModeOff
3296
+ }
3297
+
3298
+ autoUpdate = Self.isAutoUpdateModeEnabled(autoUpdateMode)
3299
+ directUpdateMode = Self.directUpdateModeForAutoUpdateMode(autoUpdateMode)
3300
+ directUpdate = Self.isDirectUpdateMode(directUpdateMode)
3301
+ }
3302
+
3303
+ private func resolveLegacyDirectUpdateModeFromConfig() -> String {
3304
+ if let directUpdateString = getConfig().getString("directUpdate") {
3305
+ if directUpdateString == "true" {
3306
+ return Self.autoUpdateModeAlways
3307
+ }
3308
+ if directUpdateString == "false" || Self.isDirectUpdateMode(directUpdateString) {
3309
+ return directUpdateString
3310
+ }
3311
+ logger.error(
3312
+ "Invalid directUpdate value: \"\(directUpdateString)\". Supported values are: false, true, " +
3313
+ "\"always\", \"atInstall\", \"onLaunch\". Defaulting to \"false\"."
3314
+ )
3315
+ return "false"
3316
+ }
3317
+
3318
+ return getConfig().getBoolean("directUpdate", false) ? Self.autoUpdateModeAlways : "false"
3319
+ }
3320
+
3321
+ static func normalizedAutoUpdateMode(_ value: String?) -> String {
3322
+ guard let value else {
3323
+ return autoUpdateModeBackground
3324
+ }
3325
+ switch value {
3326
+ case "false", autoUpdateModeOff:
3327
+ return autoUpdateModeOff
3328
+ case "true", autoUpdateModeBackground:
3329
+ return autoUpdateModeBackground
3330
+ case autoUpdateModeInstall, autoUpdateModeLaunch, autoUpdateModeAlways, autoUpdateModeOnlyDownload:
3331
+ return value
3332
+ default:
3333
+ return autoUpdateModeBackground
3334
+ }
3335
+ }
3336
+
3337
+ static func normalizedShakeMenuGesture(_ value: String?) -> String {
3338
+ guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
3339
+ return shakeMenuGestureShake
3340
+ }
3341
+ let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
3342
+ if normalized == shakeMenuGestureThreeFingerPinch {
3343
+ return shakeMenuGestureThreeFingerPinch
3344
+ }
3345
+ return shakeMenuGestureShake
3346
+ }
3347
+
3348
+ static func isSupportedShakeMenuGesture(_ value: String?) -> Bool {
3349
+ guard let value else {
3350
+ return true
3351
+ }
3352
+ let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
3353
+ if normalized.isEmpty {
3354
+ return false
3355
+ }
3356
+ return normalized == shakeMenuGestureShake || normalized == shakeMenuGestureThreeFingerPinch
3357
+ }
3358
+
3359
+ static func autoUpdateModeForLegacyDirectUpdateMode(_ directUpdateMode: String) -> String {
3360
+ switch directUpdateMode {
3361
+ case autoUpdateModeInstall, autoUpdateModeLaunch, autoUpdateModeAlways:
3362
+ return directUpdateMode
3363
+ default:
3364
+ return autoUpdateModeBackground
3365
+ }
3366
+ }
3367
+
3368
+ static func directUpdateModeForAutoUpdateMode(_ autoUpdateMode: String) -> String {
3369
+ switch autoUpdateMode {
3370
+ case autoUpdateModeInstall, autoUpdateModeLaunch, autoUpdateModeAlways:
3371
+ return autoUpdateMode
3372
+ default:
3373
+ return "false"
3374
+ }
3375
+ }
3376
+
3377
+ static func isAutoUpdateModeEnabled(_ autoUpdateMode: String) -> Bool {
3378
+ autoUpdateMode != autoUpdateModeOff
3379
+ }
3380
+
3381
+ static func shouldAutoUpdateModeSetNextBundle(_ autoUpdateMode: String) -> Bool {
3382
+ isAutoUpdateModeEnabled(autoUpdateMode) && autoUpdateMode != autoUpdateModeOnlyDownload
3383
+ }
3384
+
3385
+ static func isDirectUpdateMode(_ directUpdateMode: String) -> Bool {
3386
+ directUpdateMode == autoUpdateModeInstall || directUpdateMode == autoUpdateModeLaunch || directUpdateMode == autoUpdateModeAlways
3387
+ }
3388
+
3389
+ private func shouldAutoSetNextBundle() -> Bool {
3390
+ Self.shouldAutoUpdateModeSetNextBundle(autoUpdateMode)
3391
+ }
3392
+
3393
+ static func shouldConsumeOnLaunchDirectUpdate(directUpdateMode: String, plannedDirectUpdate: Bool) -> Bool {
3394
+ plannedDirectUpdate && directUpdateMode == "onLaunch"
3395
+ }
3396
+
3397
+ static func normalizedPeriodCheckDelaySeconds(_ value: Int) -> Int {
3398
+ guard value > 0 else {
3399
+ return 0
3400
+ }
3401
+ return max(600, value)
3402
+ }
3403
+
3404
+ private func getOnLaunchDirectUpdateUsed() -> Bool {
3405
+ self.onLaunchDirectUpdateStateLock.lock()
3406
+ defer { self.onLaunchDirectUpdateStateLock.unlock() }
3407
+ return self.onLaunchDirectUpdateUsed
3408
+ }
3409
+
3410
+ private func setOnLaunchDirectUpdateUsed(_ used: Bool) {
3411
+ self.onLaunchDirectUpdateStateLock.lock()
3412
+ self.onLaunchDirectUpdateUsed = used
3413
+ self.onLaunchDirectUpdateStateLock.unlock()
3414
+ }
3415
+
3416
+ private func consumeOnLaunchDirectUpdateAttempt(plannedDirectUpdate: Bool) {
3417
+ guard Self.shouldConsumeOnLaunchDirectUpdate(directUpdateMode: self.directUpdateMode, plannedDirectUpdate: plannedDirectUpdate) else {
3418
+ return
3419
+ }
3420
+
3421
+ self.setOnLaunchDirectUpdateUsed(true)
3422
+ }
3423
+
3424
+ func configureDirectUpdateModeForTesting(_ directUpdateMode: String, onLaunchDirectUpdateUsed: Bool = false) {
3425
+ self.directUpdateMode = directUpdateMode
3426
+ self.autoUpdateMode = Self.autoUpdateModeForLegacyDirectUpdateMode(directUpdateMode)
3427
+ self.autoUpdate = Self.isAutoUpdateModeEnabled(self.autoUpdateMode)
3428
+ self.directUpdate = Self.isDirectUpdateMode(self.directUpdateMode)
3429
+ self.setOnLaunchDirectUpdateUsed(onLaunchDirectUpdateUsed)
3430
+ }
3431
+
3432
+ func setUpdateUrlForTesting(_ updateUrl: String) {
3433
+ self.updateUrl = updateUrl
3434
+ }
3435
+
3436
+ func setAutoUpdateModeForTesting(_ autoUpdateMode: String) {
3437
+ self.autoUpdateMode = Self.normalizedAutoUpdateMode(autoUpdateMode)
3438
+ self.autoUpdate = Self.isAutoUpdateModeEnabled(self.autoUpdateMode)
3439
+ self.directUpdateMode = Self.directUpdateModeForAutoUpdateMode(self.autoUpdateMode)
3440
+ self.directUpdate = Self.isDirectUpdateMode(self.directUpdateMode)
3441
+ }
3442
+
3443
+ func setCurrentBuildVersionForTesting(_ currentBuildVersion: String) {
3444
+ self.currentBuildVersion = currentBuildVersion
3445
+ }
3446
+
3447
+ func shouldUseDirectUpdateForTesting() -> Bool {
3448
+ self.shouldUseDirectUpdate()
3449
+ }
3450
+
3451
+ var hasConsumedOnLaunchDirectUpdateForTesting: Bool {
3452
+ self.getOnLaunchDirectUpdateUsed()
3453
+ }
3454
+
1357
3455
  private func notifyBreakingEvents(version: String) {
1358
3456
  guard !version.isEmpty else {
1359
3457
  return
@@ -1363,14 +3461,82 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1363
3461
  self.notifyListeners("majorAvailable", data: payload)
1364
3462
  }
1365
3463
 
3464
+ private func shouldNotifyBreakingEvents(response: AppVersion) -> Bool {
3465
+ if response.breaking == true {
3466
+ return true
3467
+ }
3468
+
3469
+ return response.error == "disable_auto_update_to_major" || response.message == "store_update_required"
3470
+ }
3471
+
3472
+ private func notifyBreakingEventsIfNeeded(response: AppVersion, version: String) {
3473
+ if self.shouldNotifyBreakingEvents(response: response) {
3474
+ let eventVersion = version.isEmpty ? self.implementation.getCurrentBundle().getVersionName() : version
3475
+ self.notifyBreakingEvents(version: eventVersion)
3476
+ }
3477
+ }
3478
+
3479
+ static func normalizedUpdateResponseKind(kind: String?) -> String {
3480
+ if let kind, ["up_to_date", "blocked", "failed"].contains(kind) {
3481
+ return kind
3482
+ }
3483
+ return "failed"
3484
+ }
3485
+
3486
+ private func updateResponseKind(kind: String?) -> String {
3487
+ Self.normalizedUpdateResponseKind(kind: kind)
3488
+ }
3489
+
3490
+ private func endBackgroundDownloadAfterLatestError(
3491
+ backendError: String,
3492
+ res: AppVersion,
3493
+ current: BundleInfo,
3494
+ plannedDirectUpdate: Bool
3495
+ ) {
3496
+ let statusCode = res.statusCode
3497
+ let responseKind = self.updateResponseKind(kind: res.kind)
3498
+ let responseMessage = res.message?.isEmpty == false ? res.message : nil
3499
+ let message = responseMessage ?? (backendError.isEmpty ? "server did not provide a message" : backendError)
3500
+ let latestVersionName = res.version.isEmpty ? current.getVersionName() : res.version
3501
+ self.notifyListeners("updateCheckResult", data: [
3502
+ "kind": responseKind,
3503
+ "error": backendError,
3504
+ "message": message,
3505
+ "statusCode": statusCode,
3506
+ "version": latestVersionName,
3507
+ "bundle": current.toJSON()
3508
+ ])
3509
+ self.notifyBreakingEventsIfNeeded(response: res, version: res.version)
3510
+
3511
+ if responseKind == "up_to_date" {
3512
+ self.logger.info("No new version available")
3513
+ } else if responseKind == "blocked" {
3514
+ self.logger.info("Update check blocked with error: \(backendError)")
3515
+ } else {
3516
+ self.logger.error("getLatest failed with error: \(backendError)")
3517
+ }
3518
+
3519
+ let isFailure = responseKind == "failed"
3520
+ self.endBackGroundTaskWithNotif(
3521
+ msg: message,
3522
+ latestVersionName: latestVersionName,
3523
+ current: current,
3524
+ error: isFailure,
3525
+ plannedDirectUpdate: plannedDirectUpdate,
3526
+ sendStats: isFailure
3527
+ )
3528
+ }
3529
+
1366
3530
  func endBackGroundTaskWithNotif(
1367
3531
  msg: String,
1368
3532
  latestVersionName: String,
1369
3533
  current: BundleInfo,
1370
3534
  error: Bool = true,
3535
+ plannedDirectUpdate: Bool = false,
1371
3536
  failureAction: String = "download_fail",
1372
3537
  failureEvent: String = "downloadFailed",
1373
- sendStats: Bool = true
3538
+ sendStats: Bool = true,
3539
+ notifyNoNeedUpdate: Bool = true
1374
3540
  ) {
1375
3541
  // Clear download in progress flag - this is called at the end of every download attempt
1376
3542
  // whether it succeeds, fails, or is skipped (e.g., already up to date)
@@ -1379,13 +3545,17 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1379
3545
  downloadInProgress = false
1380
3546
  downloadStartTime = nil
1381
3547
 
3548
+ self.consumeOnLaunchDirectUpdateAttempt(plannedDirectUpdate: plannedDirectUpdate)
3549
+
1382
3550
  if error {
1383
3551
  if sendStats {
1384
3552
  self.implementation.sendStats(action: failureAction, versionName: current.getVersionName())
1385
3553
  }
1386
3554
  self.notifyListeners(failureEvent, data: ["version": latestVersionName])
1387
3555
  }
1388
- self.notifyListeners("noNeedUpdate", data: ["bundle": current.toJSON()])
3556
+ if notifyNoNeedUpdate {
3557
+ self.notifyListeners("noNeedUpdate", data: ["bundle": current.toJSON()])
3558
+ }
1389
3559
  self.sendReadyToJs(current: current, msg: msg)
1390
3560
  logger.info("endBackGroundTaskWithNotif \(msg) current: \(current.getVersionName()) latestVersionName: \(latestVersionName)")
1391
3561
  self.endBackGroundTask()
@@ -1413,7 +3583,41 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1413
3583
  return true
1414
3584
  }
1415
3585
 
3586
+ private func clearDownloadInProgressState() {
3587
+ downloadLock.lock()
3588
+ defer { downloadLock.unlock() }
3589
+ downloadInProgress = false
3590
+ downloadStartTime = nil
3591
+ }
3592
+
3593
+ func runBackgroundDownloadWork(_ work: @escaping () -> Void) {
3594
+ // Live update checks/downloads are user-visible work. Using `.background`
3595
+ // lets the scheduler starve them for minutes while the app is active.
3596
+ DispatchQueue.global(qos: .utility).async(execute: work)
3597
+ }
3598
+
3599
+ private func beginDownloadBackgroundTask() {
3600
+ let registerTask = {
3601
+ self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "Finish Download Tasks") {
3602
+ self.endBackGroundTask()
3603
+ }
3604
+ }
3605
+
3606
+ if Thread.isMainThread {
3607
+ registerTask()
3608
+ } else {
3609
+ DispatchQueue.main.sync(execute: registerTask)
3610
+ }
3611
+ }
3612
+
3613
+ func runGetLatestWork(_ work: @escaping () -> Void) {
3614
+ DispatchQueue.global(qos: .background).async(execute: work)
3615
+ }
3616
+
1416
3617
  func backgroundDownload() {
3618
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3619
+ return
3620
+ }
1417
3621
  // Set download in progress flag (thread-safe)
1418
3622
  downloadLock.lock()
1419
3623
  downloadInProgress = true
@@ -1421,7 +3625,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1421
3625
  downloadLock.unlock()
1422
3626
 
1423
3627
  let plannedDirectUpdate = self.shouldUseDirectUpdate()
1424
- let messageUpdate = plannedDirectUpdate ? "Update will occur now." : "Update will occur next time app moves to background."
3628
+ let messageUpdate: String
3629
+ if plannedDirectUpdate {
3630
+ messageUpdate = "Update will occur now."
3631
+ } else if self.shouldAutoSetNextBundle() {
3632
+ messageUpdate = "Update will occur next time app moves to background."
3633
+ } else {
3634
+ messageUpdate = "Update will be downloaded and made available."
3635
+ }
1425
3636
  guard let url = URL(string: self.updateUrl) else {
1426
3637
  logger.error("Error no url or wrong format")
1427
3638
  // Clear the flag if we return early
@@ -1432,28 +3643,32 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1432
3643
  return
1433
3644
  }
1434
3645
 
1435
- DispatchQueue.global(qos: .background).async {
3646
+ self.runBackgroundDownloadWork {
1436
3647
  // Wait for cleanup to complete before starting download
1437
3648
  self.waitForCleanupIfNeeded()
1438
- self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "Finish Download Tasks") {
1439
- // End the task if time expires.
1440
- self.endBackGroundTask()
3649
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3650
+ self.clearDownloadInProgressState()
3651
+ return
1441
3652
  }
3653
+ self.beginDownloadBackgroundTask()
1442
3654
  self.logger.info("Check for update via \(self.updateUrl)")
1443
3655
  let res = self.implementation.getLatest(url: url, channel: nil)
1444
3656
  let current = self.implementation.getCurrentBundle()
3657
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3658
+ self.clearDownloadInProgressState()
3659
+ self.endBackGroundTask()
3660
+ return
3661
+ }
1445
3662
 
1446
3663
  // Handle network errors and other failures first
1447
- if let backendError = res.error, !backendError.isEmpty {
1448
- self.logger.error("getLatest failed with error: \(backendError)")
1449
- let statusCode = res.statusCode
1450
- let responseIsOk = statusCode >= 200 && statusCode < 300
1451
- self.endBackGroundTaskWithNotif(
1452
- msg: res.message ?? backendError,
1453
- latestVersionName: res.version,
3664
+ let backendError = res.error ?? ""
3665
+ let backendKind = res.kind ?? ""
3666
+ if !backendError.isEmpty || !backendKind.isEmpty {
3667
+ self.endBackgroundDownloadAfterLatestError(
3668
+ backendError: backendError,
3669
+ res: res,
1454
3670
  current: current,
1455
- error: true,
1456
- sendStats: !responseIsOk
3671
+ plannedDirectUpdate: plannedDirectUpdate
1457
3672
  )
1458
3673
  return
1459
3674
  }
@@ -1462,29 +3677,58 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1462
3677
  let directUpdateAllowed = plannedDirectUpdate && !self.autoSplashscreenTimedOut
1463
3678
  if directUpdateAllowed {
1464
3679
  self.logger.info("Direct update to builtin version")
1465
- if self.directUpdateMode == "onLaunch" {
1466
- self.onLaunchDirectUpdateUsed = true
1467
- self.directUpdate = false
1468
- }
1469
- _ = self._reset(toLastSuccessful: false)
1470
- self.endBackGroundTaskWithNotif(msg: "Updated to builtin version", latestVersionName: res.version, current: self.implementation.getCurrentBundle(), error: false)
1471
- } else {
3680
+ _ = self._reset(toLastSuccessful: false, usePendingBundle: false)
3681
+ self.endBackGroundTaskWithNotif(
3682
+ msg: "Updated to builtin version",
3683
+ latestVersionName: res.version,
3684
+ current: self.implementation.getCurrentBundle(),
3685
+ error: false,
3686
+ plannedDirectUpdate: plannedDirectUpdate
3687
+ )
3688
+ } else if self.shouldAutoSetNextBundle() {
1472
3689
  if plannedDirectUpdate && !directUpdateAllowed {
1473
3690
  self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will apply later.")
1474
3691
  }
1475
3692
  self.logger.info("Setting next bundle to builtin")
1476
3693
  _ = self.implementation.setNextBundle(next: BundleInfo.ID_BUILTIN)
1477
- self.endBackGroundTaskWithNotif(msg: "Next update will be to builtin version", latestVersionName: res.version, current: current, error: false)
3694
+ self.endBackGroundTaskWithNotif(
3695
+ msg: "Next update will be to builtin version",
3696
+ latestVersionName: res.version,
3697
+ current: current,
3698
+ error: false,
3699
+ plannedDirectUpdate: plannedDirectUpdate
3700
+ )
3701
+ } else {
3702
+ self.logger.info("autoUpdate is set to onlyDownload, builtin version will not be set as next bundle")
3703
+ let builtinUpdateAvailable = !current.isBuiltin()
3704
+ if builtinUpdateAvailable {
3705
+ let builtinBundle = self.implementation.getBundleInfo(id: BundleInfo.ID_BUILTIN)
3706
+ self.notifyListeners("updateAvailable", data: ["bundle": builtinBundle.toJSON()], retainUntilConsumed: true)
3707
+ }
3708
+ self.endBackGroundTaskWithNotif(
3709
+ msg: "Latest version is builtin, autoUpdate onlyDownload",
3710
+ latestVersionName: res.version,
3711
+ current: current,
3712
+ error: false,
3713
+ plannedDirectUpdate: plannedDirectUpdate,
3714
+ notifyNoNeedUpdate: !builtinUpdateAvailable
3715
+ )
1478
3716
  }
1479
3717
  return
1480
3718
  }
1481
3719
  let sessionKey = res.sessionKey ?? ""
3720
+ let latestVersionName = res.version
1482
3721
  guard let downloadUrl = URL(string: res.url) else {
3722
+ self.notifyBreakingEventsIfNeeded(response: res, version: latestVersionName)
1483
3723
  self.logger.error("Error no url or wrong format")
1484
- self.endBackGroundTaskWithNotif(msg: "Error no url or wrong format", latestVersionName: res.version, current: current)
3724
+ self.endBackGroundTaskWithNotif(
3725
+ msg: "Error no url or wrong format",
3726
+ latestVersionName: latestVersionName,
3727
+ current: current,
3728
+ plannedDirectUpdate: plannedDirectUpdate
3729
+ )
1485
3730
  return
1486
3731
  }
1487
- let latestVersionName = res.version
1488
3732
  if latestVersionName != "" && current.getVersionName() != latestVersionName {
1489
3733
  do {
1490
3734
  self.logger.info("New bundle: \(latestVersionName) found. Current is: \(current.getVersionName()). \(messageUpdate)")
@@ -1499,6 +3743,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1499
3743
  self.logger.error("Failed to delete failed bundle: \(nextImpl!.toString())")
1500
3744
  }
1501
3745
  }
3746
+ self.consumeOnLaunchDirectUpdateAttempt(plannedDirectUpdate: plannedDirectUpdate)
1502
3747
  if res.manifest != nil {
1503
3748
  nextImpl = try self.implementation.downloadManifest(manifest: res.manifest!, version: latestVersionName, sessionKey: sessionKey, link: res.link, comment: res.comment)
1504
3749
  } else {
@@ -1507,12 +3752,27 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1507
3752
  }
1508
3753
  guard let next = nextImpl else {
1509
3754
  self.logger.error("Error downloading file")
1510
- self.endBackGroundTaskWithNotif(msg: "Error downloading file", latestVersionName: latestVersionName, current: current)
3755
+ self.endBackGroundTaskWithNotif(
3756
+ msg: "Error downloading file",
3757
+ latestVersionName: latestVersionName,
3758
+ current: current,
3759
+ plannedDirectUpdate: plannedDirectUpdate
3760
+ )
1511
3761
  return
1512
3762
  }
1513
3763
  if next.isErrorStatus() {
1514
3764
  self.logger.error("Latest bundle already exists and is in error state. Aborting update.")
1515
- self.endBackGroundTaskWithNotif(msg: "Latest version is in error state. Aborting update.", latestVersionName: latestVersionName, current: current)
3765
+ self.endBackGroundTaskWithNotif(
3766
+ msg: "Latest version is in error state. Aborting update.",
3767
+ latestVersionName: latestVersionName,
3768
+ current: current,
3769
+ plannedDirectUpdate: plannedDirectUpdate
3770
+ )
3771
+ return
3772
+ }
3773
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3774
+ self.clearDownloadInProgressState()
3775
+ self.endBackGroundTask()
1516
3776
  return
1517
3777
  }
1518
3778
  res.checksum = try CryptoCipher.decryptChecksum(checksum: res.checksum, publicKey: self.implementation.publicKey)
@@ -1526,7 +3786,17 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1526
3786
  if !resDel {
1527
3787
  self.logger.error("Delete failed, id \(id) doesn't exist")
1528
3788
  }
1529
- self.endBackGroundTaskWithNotif(msg: "Error checksum", latestVersionName: latestVersionName, current: current)
3789
+ self.endBackGroundTaskWithNotif(
3790
+ msg: "Error checksum",
3791
+ latestVersionName: latestVersionName,
3792
+ current: current,
3793
+ plannedDirectUpdate: plannedDirectUpdate
3794
+ )
3795
+ return
3796
+ }
3797
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3798
+ self.clearDownloadInProgressState()
3799
+ self.endBackGroundTask()
1530
3800
  return
1531
3801
  }
1532
3802
  let directUpdateAllowed = plannedDirectUpdate && !self.autoSplashscreenTimedOut
@@ -1539,40 +3809,90 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1539
3809
  }
1540
3810
  if !delayConditionList.isEmpty {
1541
3811
  self.logger.info("Update delayed until delay conditions met")
1542
- self.endBackGroundTaskWithNotif(msg: "Update delayed until delay conditions met", latestVersionName: latestVersionName, current: next, error: false)
3812
+ self.endBackGroundTaskWithNotif(
3813
+ msg: "Update delayed until delay conditions met",
3814
+ latestVersionName: latestVersionName,
3815
+ current: next,
3816
+ error: false,
3817
+ plannedDirectUpdate: plannedDirectUpdate
3818
+ )
1543
3819
  return
1544
3820
  }
1545
- if self.directUpdateMode == "onLaunch" {
1546
- self.onLaunchDirectUpdateUsed = true
1547
- self.directUpdate = false
3821
+ if self.applyDownloadedBundleForDirectUpdate(next) {
3822
+ self.notifyBundleSet(next)
3823
+ self.endBackGroundTaskWithNotif(
3824
+ msg: "update installed",
3825
+ latestVersionName: latestVersionName,
3826
+ current: next,
3827
+ error: false,
3828
+ plannedDirectUpdate: plannedDirectUpdate
3829
+ )
3830
+ } else {
3831
+ _ = self.implementation.setNextBundle(next: next.getId())
3832
+ self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
3833
+ self.endBackGroundTaskWithNotif(
3834
+ msg: "Direct update reload failed, update will install next background",
3835
+ latestVersionName: latestVersionName,
3836
+ current: self.implementation.getCurrentBundle(),
3837
+ error: false,
3838
+ plannedDirectUpdate: plannedDirectUpdate
3839
+ )
1548
3840
  }
1549
- _ = self.implementation.set(bundle: next)
1550
- _ = self._reload()
1551
- self.endBackGroundTaskWithNotif(msg: "update installed", latestVersionName: latestVersionName, current: next, error: false)
1552
- } else {
3841
+ } else if self.shouldAutoSetNextBundle() {
1553
3842
  if plannedDirectUpdate && !directUpdateAllowed {
1554
3843
  self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will install on next app background.")
1555
3844
  }
1556
3845
  self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
1557
3846
  _ = self.implementation.setNextBundle(next: next.getId())
1558
- self.endBackGroundTaskWithNotif(msg: "update downloaded, will install next background", latestVersionName: latestVersionName, current: current, error: false)
3847
+ self.endBackGroundTaskWithNotif(
3848
+ msg: "update downloaded, will install next background",
3849
+ latestVersionName: latestVersionName,
3850
+ current: current,
3851
+ error: false,
3852
+ plannedDirectUpdate: plannedDirectUpdate
3853
+ )
3854
+ } else {
3855
+ self.logger.info("autoUpdate is set to onlyDownload, downloaded update will not be set as next bundle")
3856
+ self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()], retainUntilConsumed: true)
3857
+ self.endBackGroundTaskWithNotif(
3858
+ msg: "update downloaded, autoUpdate onlyDownload",
3859
+ latestVersionName: latestVersionName,
3860
+ current: current,
3861
+ error: false,
3862
+ plannedDirectUpdate: plannedDirectUpdate,
3863
+ notifyNoNeedUpdate: false
3864
+ )
1559
3865
  }
1560
3866
  return
1561
3867
  } catch {
1562
3868
  self.logger.error("Error downloading file \(error.localizedDescription)")
1563
3869
  let current: BundleInfo = self.implementation.getCurrentBundle()
1564
- self.endBackGroundTaskWithNotif(msg: "Error downloading file", latestVersionName: latestVersionName, current: current)
3870
+ self.endBackGroundTaskWithNotif(
3871
+ msg: "Error downloading file",
3872
+ latestVersionName: latestVersionName,
3873
+ current: current,
3874
+ plannedDirectUpdate: plannedDirectUpdate
3875
+ )
1565
3876
  return
1566
3877
  }
1567
3878
  } else {
1568
3879
  self.logger.info("No need to update, \(current.getId()) is the latest bundle.")
1569
- self.endBackGroundTaskWithNotif(msg: "No need to update, \(current.getId()) is the latest bundle.", latestVersionName: latestVersionName, current: current, error: false)
3880
+ self.endBackGroundTaskWithNotif(
3881
+ msg: "No need to update, \(current.getId()) is the latest bundle.",
3882
+ latestVersionName: latestVersionName,
3883
+ current: current,
3884
+ error: false,
3885
+ plannedDirectUpdate: plannedDirectUpdate
3886
+ )
1570
3887
  return
1571
3888
  }
1572
3889
  }
1573
3890
  }
1574
3891
 
1575
3892
  private func installNext() {
3893
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3894
+ return
3895
+ }
1576
3896
  let delayUpdatePreferences = UserDefaults.standard.string(forKey: DelayUpdateUtils.DELAY_CONDITION_PREFERENCES) ?? "[]"
1577
3897
  let delayConditionList: [DelayCondition] = fromJsonArr(json: delayUpdatePreferences).map { obj -> DelayCondition in
1578
3898
  let kind: String = obj.value(forKey: "kind") as! String
@@ -1590,6 +3910,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1590
3910
  logger.info("Next bundle is: \(next!.toString())")
1591
3911
  if self.implementation.set(bundle: next!) && self._reload() {
1592
3912
  logger.info("Updated to bundle: \(next!.toString())")
3913
+ self.notifyBundleSet(next!)
1593
3914
  _ = self.implementation.setNextBundle(next: Optional<String>.none)
1594
3915
  } else {
1595
3916
  logger.error("Update to bundle: \(next!.toString()) Failed!")
@@ -1616,6 +3937,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1616
3937
  }
1617
3938
 
1618
3939
  @objc func appMovedToForeground() {
3940
+ appHealthTracker?.markForeground(true)
1619
3941
  let current: BundleInfo = self.implementation.getCurrentBundle()
1620
3942
  self.implementation.sendStats(action: "app_moved_to_foreground", versionName: current.getVersionName())
1621
3943
  self.delayUpdateUtils.checkCancelDelay(source: .foreground)
@@ -1661,9 +3983,15 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1661
3983
  timer.invalidate()
1662
3984
  return
1663
3985
  }
1664
- DispatchQueue.global(qos: .background).async {
3986
+ DispatchQueue.global(qos: .utility).async {
3987
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3988
+ return
3989
+ }
1665
3990
  let res = self.implementation.getLatest(url: url, channel: nil)
1666
3991
  let current = self.implementation.getCurrentBundle()
3992
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3993
+ return
3994
+ }
1667
3995
 
1668
3996
  if res.version != current.getVersionName() {
1669
3997
  self.logger.info("New version found: \(res.version)")
@@ -1682,6 +4010,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1682
4010
  @objc func appMovedToBackground() {
1683
4011
  // Reset timeout flag at start of each background cycle
1684
4012
  self.autoSplashscreenTimedOut = false
4013
+ appHealthTracker?.markForeground(false)
1685
4014
 
1686
4015
  let current: BundleInfo = self.implementation.getCurrentBundle()
1687
4016
  self.implementation.sendStats(action: "app_moved_to_background", versionName: current.getVersionName())
@@ -1748,13 +4077,15 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1748
4077
  }
1749
4078
 
1750
4079
  self.shakeMenuEnabled = enabled
1751
- logger.info("Shake menu \(enabled ? "enabled" : "disabled")")
4080
+ self.syncShakeMenuGestureRecognizer()
4081
+ logger.info("Shake menu \(enabled ? "enabled" : "disabled") with \(self.shakeMenuGesture) gesture")
1752
4082
  call.resolve()
1753
4083
  }
1754
4084
 
1755
4085
  @objc func isShakeMenuEnabled(_ call: CAPPluginCall) {
1756
4086
  call.resolve([
1757
- "enabled": self.shakeMenuEnabled
4087
+ "enabled": self.shakeMenuEnabled,
4088
+ "gesture": self.shakeMenuGesture
1758
4089
  ])
1759
4090
  }
1760
4091
 
@@ -1766,6 +4097,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1766
4097
  }
1767
4098
 
1768
4099
  self.shakeChannelSelectorEnabled = enabled
4100
+ self.syncShakeMenuGestureRecognizer()
1769
4101
  logger.info("Shake channel selector \(enabled ? "enabled" : "disabled")")
1770
4102
  call.resolve()
1771
4103
  }
@@ -1813,29 +4145,30 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1813
4145
 
1814
4146
  logger.info("Getting App Store update info for \(bundleId) in country \(country)")
1815
4147
 
4148
+ self.saveCallForAsyncHandling(call)
1816
4149
  DispatchQueue.global(qos: .background).async {
1817
4150
  let urlString = "https://itunes.apple.com/lookup?bundleId=\(bundleId)&country=\(country)"
1818
4151
  guard let url = URL(string: urlString) else {
1819
- call.reject("Invalid URL for App Store lookup")
4152
+ self.rejectCall(call, message: "Invalid URL for App Store lookup")
1820
4153
  return
1821
4154
  }
1822
4155
 
1823
4156
  let task = URLSession.shared.dataTask(with: url) { data, _, error in
1824
4157
  if let error = error {
1825
4158
  self.logger.error("App Store lookup failed: \(error.localizedDescription)")
1826
- call.reject("App Store lookup failed: \(error.localizedDescription)")
4159
+ self.rejectCall(call, message: "App Store lookup failed: \(error.localizedDescription)")
1827
4160
  return
1828
4161
  }
1829
4162
 
1830
4163
  guard let data = data else {
1831
- call.reject("No data received from App Store")
4164
+ self.rejectCall(call, message: "No data received from App Store")
1832
4165
  return
1833
4166
  }
1834
4167
 
1835
4168
  do {
1836
4169
  guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
1837
4170
  let resultCount = json["resultCount"] as? Int else {
1838
- call.reject("Invalid response from App Store")
4171
+ self.rejectCall(call, message: "Invalid response from App Store")
1839
4172
  return
1840
4173
  }
1841
4174
 
@@ -1892,10 +4225,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1892
4225
  self.logger.info("App not found in App Store for bundleId: \(bundleId)")
1893
4226
  }
1894
4227
 
1895
- call.resolve(result)
4228
+ self.resolveCall(call, data: result)
1896
4229
  } catch {
1897
4230
  self.logger.error("Failed to parse App Store response: \(error.localizedDescription)")
1898
- call.reject("Failed to parse App Store response: \(error.localizedDescription)")
4231
+ self.rejectCall(call, message: "Failed to parse App Store response: \(error.localizedDescription)")
1899
4232
  }
1900
4233
  }
1901
4234
  task.resume()
@@ -1904,37 +4237,48 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1904
4237
 
1905
4238
  @objc func openAppStore(_ call: CAPPluginCall) {
1906
4239
  let appId = call.getString("appId")
4240
+ let bundleId = implementation.appId
4241
+ self.saveCallForAsyncHandling(call)
1907
4242
 
1908
- if let appId = appId {
1909
- // Open App Store with provided app ID
1910
- let urlString = "https://apps.apple.com/app/id\(appId)"
4243
+ func openAppStorePage(urlString: String, invalidMessage: String = "Invalid App Store URL", failureMessage: String = "Failed to open App Store") {
1911
4244
  guard let url = URL(string: urlString) else {
1912
- call.reject("Invalid App Store URL")
4245
+ self.rejectCall(call, message: invalidMessage)
1913
4246
  return
1914
4247
  }
1915
4248
  DispatchQueue.main.async {
1916
4249
  UIApplication.shared.open(url) { success in
1917
4250
  if success {
1918
- call.resolve()
4251
+ self.resolveCall(call)
1919
4252
  } else {
1920
- call.reject("Failed to open App Store")
4253
+ self.rejectCall(call, message: failureMessage)
1921
4254
  }
1922
4255
  }
1923
4256
  }
4257
+ }
4258
+
4259
+ func openFallbackAppStorePage() {
4260
+ guard let encodedBundleId = bundleId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
4261
+ self.rejectCall(call, message: "Failed to build App Store fallback URL")
4262
+ return
4263
+ }
4264
+ openAppStorePage(urlString: "https://apps.apple.com/app/\(encodedBundleId)")
4265
+ }
4266
+
4267
+ if let appId = appId {
4268
+ openAppStorePage(urlString: "https://apps.apple.com/app/id\(appId)")
1924
4269
  } else {
1925
- // Look up app ID using bundle identifier
1926
- let bundleId = implementation.appId
1927
4270
  let lookupUrl = "https://itunes.apple.com/lookup?bundleId=\(bundleId)"
1928
4271
 
1929
4272
  DispatchQueue.global(qos: .background).async {
1930
4273
  guard let url = URL(string: lookupUrl) else {
1931
- call.reject("Invalid lookup URL")
4274
+ openFallbackAppStorePage()
1932
4275
  return
1933
4276
  }
1934
4277
 
1935
4278
  let task = URLSession.shared.dataTask(with: url) { data, _, error in
1936
4279
  if let error = error {
1937
- call.reject("Failed to lookup app: \(error.localizedDescription)")
4280
+ self.logger.error("App Store lookup failed: \(error.localizedDescription)")
4281
+ openFallbackAppStorePage()
1938
4282
  return
1939
4283
  }
1940
4284
 
@@ -1943,39 +4287,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1943
4287
  let results = json["results"] as? [[String: Any]],
1944
4288
  let appInfo = results.first,
1945
4289
  let trackId = appInfo["trackId"] as? Int else {
1946
- // If lookup fails, try opening the generic App Store app page using bundle ID
1947
- let fallbackUrlString = "https://apps.apple.com/app/\(bundleId)"
1948
- guard let fallbackUrl = URL(string: fallbackUrlString) else {
1949
- call.reject("Failed to find app in App Store and fallback URL is invalid")
1950
- return
1951
- }
1952
- DispatchQueue.main.async {
1953
- UIApplication.shared.open(fallbackUrl) { success in
1954
- if success {
1955
- call.resolve()
1956
- } else {
1957
- call.reject("Failed to open App Store")
1958
- }
1959
- }
1960
- }
1961
- return
1962
- }
1963
-
1964
- let appStoreUrl = "https://apps.apple.com/app/id\(trackId)"
1965
- guard let url = URL(string: appStoreUrl) else {
1966
- call.reject("Invalid App Store URL")
4290
+ openFallbackAppStorePage()
1967
4291
  return
1968
4292
  }
1969
4293
 
1970
- DispatchQueue.main.async {
1971
- UIApplication.shared.open(url) { success in
1972
- if success {
1973
- call.resolve()
1974
- } else {
1975
- call.reject("Failed to open App Store")
1976
- }
1977
- }
1978
- }
4294
+ openAppStorePage(urlString: "https://apps.apple.com/app/id\(trackId)")
1979
4295
  }
1980
4296
  task.resume()
1981
4297
  }