@capgo/capacitor-updater 7.43.3 → 7.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 (36) hide show
  1. package/Package.swift +5 -2
  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 -353
  6. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +924 -290
  7. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +49 -13
  8. package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +45 -30
  9. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +75 -32
  10. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +2 -0
  11. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +3 -3
  12. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +401 -27
  13. package/android/src/main/java/ee/forgr/capacitor_updater/ThreeFingerPinchDetector.java +323 -0
  14. package/dist/docs.json +1590 -196
  15. package/dist/esm/definitions.d.ts +755 -66
  16. package/dist/esm/definitions.js.map +1 -1
  17. package/dist/esm/web.d.ts +11 -2
  18. package/dist/esm/web.js +59 -5
  19. package/dist/esm/web.js.map +1 -1
  20. package/dist/plugin.cjs.js +59 -5
  21. package/dist/plugin.cjs.js.map +1 -1
  22. package/dist/plugin.js +59 -5
  23. package/dist/plugin.js.map +1 -1
  24. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +0 -1
  25. package/ios/Sources/CapacitorUpdaterPlugin/AppHealthTracker.swift +82 -0
  26. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +0 -16
  27. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +2 -2
  28. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +78 -2
  29. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +2732 -417
  30. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1191 -363
  31. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +0 -1
  32. package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +37 -16
  33. package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +80 -1
  34. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +438 -39
  35. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +276 -0
  36. package/package.json +24 -9
@@ -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),
@@ -44,12 +51,15 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
44
51
  CAPPluginMethod(name: "current", returnType: CAPPluginReturnPromise),
45
52
  CAPPluginMethod(name: "reload", returnType: CAPPluginReturnPromise),
46
53
  CAPPluginMethod(name: "notifyAppReady", returnType: CAPPluginReturnPromise),
47
- CAPPluginMethod(name: "setDelay", returnType: CAPPluginReturnPromise),
48
54
  CAPPluginMethod(name: "setMultiDelay", returnType: CAPPluginReturnPromise),
49
55
  CAPPluginMethod(name: "cancelDelay", returnType: CAPPluginReturnPromise),
50
56
  CAPPluginMethod(name: "getLatest", returnType: CAPPluginReturnPromise),
57
+ CAPPluginMethod(name: "getMissingBundleFiles", returnType: CAPPluginReturnPromise),
58
+ CAPPluginMethod(name: "getBundleDownloadSize", returnType: CAPPluginReturnPromise),
59
+ CAPPluginMethod(name: "triggerUpdateCheck", returnType: CAPPluginReturnPromise),
51
60
  CAPPluginMethod(name: "setChannel", returnType: CAPPluginReturnPromise),
52
61
  CAPPluginMethod(name: "unsetChannel", returnType: CAPPluginReturnPromise),
62
+ CAPPluginMethod(name: "reportWebViewError", returnType: CAPPluginReturnPromise),
53
63
  CAPPluginMethod(name: "getChannel", returnType: CAPPluginReturnPromise),
54
64
  CAPPluginMethod(name: "listChannels", returnType: CAPPluginReturnPromise),
55
65
  CAPPluginMethod(name: "setCustomId", returnType: CAPPluginReturnPromise),
@@ -65,6 +75,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
65
75
  CAPPluginMethod(name: "isShakeMenuEnabled", returnType: CAPPluginReturnPromise),
66
76
  CAPPluginMethod(name: "setShakeChannelSelector", returnType: CAPPluginReturnPromise),
67
77
  CAPPluginMethod(name: "isShakeChannelSelectorEnabled", returnType: CAPPluginReturnPromise),
78
+ CAPPluginMethod(name: "getAppId", returnType: CAPPluginReturnPromise),
79
+ CAPPluginMethod(name: "setAppId", returnType: CAPPluginReturnPromise),
68
80
  // App Store update methods
69
81
  CAPPluginMethod(name: "getAppUpdateInfo", returnType: CAPPluginReturnPromise),
70
82
  CAPPluginMethod(name: "openAppStore", returnType: CAPPluginReturnPromise),
@@ -73,10 +85,19 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
73
85
  CAPPluginMethod(name: "completeFlexibleUpdate", returnType: CAPPluginReturnPromise)
74
86
  ]
75
87
  public var implementation = CapgoUpdater()
76
- private let pluginVersion: String = "7.43.3"
88
+ private let pluginVersion: String = "7.50.1"
77
89
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
78
90
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
79
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
80
101
  private let keepUrlPathFlagKey = "__capgo_keep_url_path_after_reload"
81
102
  private let customIdDefaultsKey = "CapacitorUpdater.customId"
82
103
  private let updateUrlDefaultsKey = "CapacitorUpdater.updateUrl"
@@ -84,12 +105,36 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
84
105
  private let channelUrlDefaultsKey = "CapacitorUpdater.channelUrl"
85
106
  private let defaultChannelDefaultsKey = "CapacitorUpdater.defaultChannel"
86
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)))
87
131
  // Note: DELAY_CONDITION_PREFERENCES is now defined in DelayUpdateUtils.DELAY_CONDITION_PREFERENCES
88
132
  private var updateUrl = ""
89
133
  private var backgroundTaskID: UIBackgroundTaskIdentifier = UIBackgroundTaskIdentifier.invalid
90
134
  private var currentVersionNative: Version = "0.0.0"
91
135
  private var currentBuildVersion: String = "0"
92
136
  private var autoUpdate = false
137
+ private var autoUpdateMode = CapacitorUpdaterPlugin.autoUpdateModeOff
93
138
  private var appReadyTimeout = 10000
94
139
  private var appReadyCheck: DispatchWorkItem?
95
140
  private var resetWhenUpdate = true
@@ -103,7 +148,15 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
103
148
  private var autoSplashscreenTimeoutWorkItem: DispatchWorkItem?
104
149
  private var splashscreenLoaderView: UIActivityIndicatorView?
105
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
106
158
  private var autoSplashscreenTimedOut = false
159
+ private var splashscreenInvocationToken = 0
107
160
  private var autoDeleteFailed = false
108
161
  private var autoDeletePrevious = false
109
162
  var allowSetDefaultChannel = true
@@ -112,9 +165,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
112
165
  private var taskRunning = false
113
166
  private var periodCheckDelay = 0
114
167
  private let downloadLock = NSLock()
168
+ private let onLaunchDirectUpdateStateLock = NSLock()
115
169
  private var downloadInProgress = false
116
170
  private var downloadStartTime: Date?
117
- private let downloadTimeout: TimeInterval = 3600 // 1 hour timeout
171
+ private let downloadTimeout: TimeInterval = 600 // 10 minute timeout
118
172
 
119
173
  // Lock to ensure cleanup completes before downloads start
120
174
  private let cleanupLock = NSLock()
@@ -123,9 +177,19 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
123
177
  private var persistCustomId = false
124
178
  private var persistModifyUrl = false
125
179
  private var allowManualBundleError = false
180
+ private var allowPreview = false
126
181
  private var keepUrlPathFlagLastValue: Bool?
182
+ private var appHealthTracker: AppHealthTracker?
183
+ private var webViewStatsReporter: WebViewStatsReporter?
127
184
  public var shakeMenuEnabled = false
128
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?
129
193
  let semaphoreReady = DispatchSemaphore(value: 0)
130
194
 
131
195
  private var delayUpdateUtils: DelayUpdateUtils!
@@ -139,6 +203,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
139
203
  } else {
140
204
  logger.error("Failed to get webView for logging")
141
205
  }
206
+ let webViewStatsReporter = WebViewStatsReporter(implementation: implementation)
207
+ self.webViewStatsReporter = webViewStatsReporter
208
+ webViewStatsReporter.install(on: self.bridge?.webView)
142
209
  #if targetEnvironment(simulator)
143
210
  logger.info("::::: SIMULATOR :::::")
144
211
  logger.info("Application directory: \(NSHomeDirectory())")
@@ -158,6 +225,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
158
225
  }
159
226
  persistModifyUrl = getConfig().getBoolean("persistModifyUrl", false)
160
227
  allowManualBundleError = getConfig().getBoolean("allowManualBundleError", false)
228
+ allowPreview = getConfig().getBoolean("allowPreview", false)
161
229
  logger.info("init for device \(self.implementation.deviceID)")
162
230
  guard let versionName = getConfig().getString("version", Bundle.main.versionName) else {
163
231
  logger.error("Cannot get version name")
@@ -177,33 +245,6 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
177
245
  keepUrlPathAfterReload = getConfig().getBoolean("keepUrlPathAfterReload", false)
178
246
  syncKeepUrlPathFlag(enabled: keepUrlPathAfterReload)
179
247
 
180
- // Handle directUpdate configuration - support string values and backward compatibility
181
- if let directUpdateString = getConfig().getString("directUpdate") {
182
- // Handle backward compatibility for boolean true
183
- if directUpdateString == "true" {
184
- directUpdateMode = "always"
185
- directUpdate = true
186
- } else {
187
- directUpdateMode = directUpdateString
188
- directUpdate = directUpdateString == "always" || directUpdateString == "atInstall" || directUpdateString == "onLaunch"
189
- // Validate directUpdate value
190
- if directUpdateString != "false" && directUpdateString != "always" && directUpdateString != "atInstall" && directUpdateString != "onLaunch" {
191
- logger.error("Invalid directUpdate value: \"\(directUpdateString)\". Supported values are: \"false\", \"true\", \"always\", \"atInstall\", \"onLaunch\". Defaulting to \"false\".")
192
- directUpdateMode = "false"
193
- directUpdate = false
194
- }
195
- }
196
- } else {
197
- let directUpdateBool = getConfig().getBoolean("directUpdate", false)
198
- if directUpdateBool {
199
- directUpdateMode = "always" // backward compatibility: true = always
200
- directUpdate = true
201
- } else {
202
- directUpdateMode = "false"
203
- directUpdate = false
204
- }
205
- }
206
-
207
248
  autoSplashscreen = getConfig().getBoolean("autoSplashscreen", false)
208
249
  autoSplashscreenLoader = getConfig().getBoolean("autoSplashscreenLoader", false)
209
250
  let splashscreenTimeoutValue = getConfig().getInt("autoSplashscreenTimeout", 10000)
@@ -213,21 +254,40 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
213
254
  updateUrl = storedUpdateUrl
214
255
  logger.info("Loaded persisted updateUrl")
215
256
  }
216
- autoUpdate = getConfig().getBoolean("autoUpdate", true)
257
+ configureAutoUpdateModeFromConfig()
217
258
  appReadyTimeout = max(1000, getConfig().getInt("appReadyTimeout", 10000)) // Minimum 1 second
218
259
  implementation.timeout = Double(getConfig().getInt("responseTimeout", 20))
219
260
  resetWhenUpdate = getConfig().getBoolean("resetWhenUpdate", true)
220
261
  shakeMenuEnabled = getConfig().getBoolean("shakeMenu", false)
221
262
  shakeChannelSelectorEnabled = getConfig().getBoolean("allowShakeChannelSelector", false)
222
- let periodCheckDelayValue = getConfig().getInt("periodCheckDelay", 0)
223
- if periodCheckDelayValue >= 0 && periodCheckDelayValue > 600 {
224
- periodCheckDelay = 600
225
- } else {
226
- 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
227
273
  }
274
+ syncShakeMenuGestureRecognizer()
275
+ periodCheckDelay = Self.normalizedPeriodCheckDelaySeconds(getConfig().getInt("periodCheckDelay", 0))
228
276
 
229
277
  implementation.setPublicKey(getConfig().getString("publicKey") ?? "")
230
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
+ }
231
291
  implementation.pluginVersion = self.pluginVersion
232
292
 
233
293
  // Set logger for shared classes
@@ -249,6 +309,15 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
249
309
  // crash the app on purpose it should not happen
250
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")
251
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
+ }
252
321
  logger.info("appId \(implementation.appId)")
253
322
  implementation.statsUrl = getConfig().getString("statsUrl", CapacitorUpdaterPlugin.statsUrlDefault)!
254
323
  implementation.channelUrl = getConfig().getString("channelUrl", CapacitorUpdaterPlugin.channelUrlDefault)!
@@ -271,13 +340,24 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
271
340
  implementation.defaultChannel = getConfig().getString("defaultChannel", "")!
272
341
  }
273
342
  self.implementation.autoReset()
343
+ let appHealthTracker = AppHealthTracker(implementation: self.implementation)
344
+ self.appHealthTracker = appHealthTracker
345
+ appHealthTracker.reportPreviousUncleanForegroundExit()
346
+ appHealthTracker.startSession()
274
347
 
275
- // 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.
276
349
  self.wasRecentlyInstalledOrUpdated = self.checkIfRecentlyInstalledOrUpdated()
350
+ let nativeBuildVersionChanged = self.hasNativeBuildVersionChanged()
351
+ if nativeBuildVersionChanged {
352
+ self.clearPreviewSessionForNativeBuildChange()
353
+ }
354
+ self.leavePreviewSessionForLaunchURLIfNeeded()
277
355
 
278
356
  if resetWhenUpdate {
279
- self.cleanupObsoleteVersions()
357
+ let didResetCurrentBundle = self.resetCurrentBundleForNativeBuildChangeIfNeeded()
358
+ self.cleanupObsoleteVersions(didResetCurrentBundle: didResetCurrentBundle)
280
359
  }
360
+ self.reportNativeVersionStatsIfChanged()
281
361
 
282
362
  // Load the server
283
363
  // This is very much swift specific, android does not do that
@@ -290,9 +370,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
290
370
  logger.error("unable to force reload, the plugin might fallback to the builtin version")
291
371
  }
292
372
 
293
- let nc = NotificationCenter.default
294
- nc.addObserver(self, selector: #selector(appMovedToBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
295
- nc.addObserver(self, selector: #selector(appMovedToForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
373
+ self.registerNotificationObservers()
296
374
 
297
375
  // Check for 'kill' delay condition on app launch
298
376
  // This handles cases where the app was killed (willTerminateNotification is not reliable for system kills)
@@ -300,6 +378,47 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
300
378
 
301
379
  self.appMovedToForeground()
302
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
+ )
303
422
  }
304
423
 
305
424
  private func syncKeepUrlPathFlag(enabled: Bool) {
@@ -349,6 +468,22 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
349
468
  }
350
469
  }
351
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
+
352
487
  private func initialLoad() -> Bool {
353
488
  guard let bridge = self.bridge else { return false }
354
489
  if keepUrlPathAfterReload {
@@ -392,7 +527,109 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
392
527
  semaphoreReady.signal()
393
528
  }
394
529
 
395
- 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) {
396
633
  cleanupThread = Thread {
397
634
  self.cleanupLock.lock()
398
635
  defer {
@@ -427,9 +664,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
427
664
  // 1. Write "LatestVersionNative" - this fixes the part 1 of this bug
428
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
429
666
 
430
- let previous = UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
667
+ let previous = self.storedNativeBuildVersion()
431
668
  if previous != "0" && self.currentBuildVersion != previous {
432
- _ = 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
+ }
433
673
  let res = self.implementation.list()
434
674
  for version in res {
435
675
  // Check if thread was cancelled
@@ -489,11 +729,88 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
489
729
  logger.info("Cleanup finished, proceeding with download")
490
730
  }
491
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
+
492
804
  @objc func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
493
805
  let bundleInfo = bundle ?? self.implementation.getBundleInfo(id: id)
494
- 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)
495
810
  if percent == 100 {
496
- self.notifyListeners("downloadComplete", data: ["bundle": bundleInfo.toJSON()])
811
+ var downloadCompletePayload: JSObject = [:]
812
+ downloadCompletePayload["bundle"] = bundlePayload(bundleInfo)
813
+ self.notifyListenersOnMain("downloadComplete", data: downloadCompletePayload)
497
814
  self.implementation.sendStats(action: "download_complete", versionName: bundleInfo.getVersionName())
498
815
  } else if percent.isMultiple(of: 10) || ignoreMultipleOfTen {
499
816
  self.implementation.sendStats(action: "download_\(percent)", versionName: bundleInfo.getVersionName())
@@ -542,206 +859,1420 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
542
859
  call.resolve()
543
860
  }
544
861
 
545
- @objc func setChannelUrl(_ call: CAPPluginCall) {
546
- if !getConfig().getBoolean("allowModifyUrl", false) {
547
- logger.error("setChannelUrl called without allowModifyUrl")
548
- call.reject("setChannelUrl called without allowModifyUrl set allowModifyUrl in your config to true to allow it")
549
- 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
550
2067
  }
551
- guard let url = call.getString("url") else {
552
- logger.error("setChannelUrl called without url")
553
- call.reject("setChannelUrl called without url")
554
- 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
555
2074
  }
556
- self.implementation.channelUrl = url
557
- if persistModifyUrl {
558
- UserDefaults.standard.set(url, forKey: channelUrlDefaultsKey)
559
- 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)
560
2091
  }
561
- call.resolve()
2092
+
2093
+ return url.path
562
2094
  }
563
2095
 
564
- @objc func getBuiltinVersion(_ call: CAPPluginCall) {
565
- 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)
566
2099
  }
567
2100
 
568
- @objc func getDeviceId(_ call: CAPPluginCall) {
569
- call.resolve(["deviceId": implementation.deviceID])
2101
+ private func previewDeepLinkPath(_ leafComponent: String) -> String {
2102
+ self.normalizedPreviewPath([self.previewDeepLinkRootComponent, leafComponent])
570
2103
  }
571
2104
 
572
- @objc func getPluginVersion(_ call: CAPPluginCall) {
573
- 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)
574
2109
  }
575
2110
 
576
- @objc func download(_ call: CAPPluginCall) {
577
- guard let urlString = call.getString("url") else {
578
- logger.error("Download called without url")
579
- call.reject("Download called without url")
580
- return
581
- }
582
- guard let version = call.getString("version") else {
583
- logger.error("Download called without version")
584
- 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 {
585
2118
  return
586
2119
  }
587
2120
 
588
- let sessionKey = call.getString("sessionKey", "")
589
- var checksum = call.getString("checksum", "")
590
- let manifestArray = call.getArray("manifest")
591
- let url = URL(string: urlString)
592
- logger.info("Downloading \(String(describing: url))")
593
- DispatchQueue.global(qos: .background).async {
594
- do {
595
- let next: BundleInfo
596
- if let manifestArray = manifestArray {
597
- // Convert JSArray to [ManifestEntry]
598
- var manifestEntries: [ManifestEntry] = []
599
- for item in manifestArray {
600
- if let manifestDict = item as? [String: Any] {
601
- let entry = ManifestEntry(
602
- file_name: manifestDict["file_name"] as? String,
603
- file_hash: manifestDict["file_hash"] as? String,
604
- download_url: manifestDict["download_url"] as? String
605
- )
606
- manifestEntries.append(entry)
607
- }
608
- }
609
- next = try self.implementation.downloadManifest(manifest: manifestEntries, version: version, sessionKey: sessionKey)
610
- } else {
611
- next = try self.implementation.download(url: url!, version: version, sessionKey: sessionKey)
612
- }
613
- // If public key is present but no checksum provided, refuse installation
614
- if self.implementation.publicKey != "" && checksum == "" {
615
- self.logger.error("Public key present but no checksum provided")
616
- self.implementation.sendStats(action: "checksum_required", versionName: next.getVersionName())
617
- let id = next.getId()
618
- let resDel = self.implementation.delete(id: id)
619
- if !resDel {
620
- self.logger.error("Delete failed, id \(id) doesn't exist")
621
- }
622
- throw ObjectSavableError.checksum
623
- }
624
-
625
- checksum = try CryptoCipher.decryptChecksum(checksum: checksum, publicKey: self.implementation.publicKey)
626
- CryptoCipher.logChecksumInfo(label: "Bundle checksum", hexChecksum: next.getChecksum())
627
- CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: checksum)
628
- if (checksum != "" || self.implementation.publicKey != "") && next.getChecksum() != checksum {
629
- self.logger.error("Error checksum \(next.getChecksum()) \(checksum)")
630
- self.implementation.sendStats(action: "checksum_fail", versionName: next.getVersionName())
631
- let id = next.getId()
632
- let resDel = self.implementation.delete(id: id)
633
- if !resDel {
634
- self.logger.error("Delete failed, id \(id) doesn't exist")
635
- }
636
- throw ObjectSavableError.checksum
637
- } else {
638
- self.logger.info("Good checksum \(next.getChecksum()) \(checksum)")
639
- }
640
- self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
641
- call.resolve(next.toJSON())
642
- } catch {
643
- self.logger.error("Failed to download from: \(String(describing: url)) \(error.localizedDescription)")
644
- self.notifyListeners("downloadFailed", data: ["version": version])
645
- self.implementation.sendStats(action: "download_fail")
646
- 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")
647
2130
  }
648
2131
  }
649
2132
  }
650
2133
 
651
- public func _reload() -> Bool {
652
- guard let bridge = self.bridge else { return false }
653
- self.semaphoreUp()
654
- let id = self.implementation.getCurrentBundleId()
655
- let dest: URL
656
- if BundleInfo.ID_BUILTIN == id {
657
- dest = Bundle.main.resourceURL!.appendingPathComponent("public")
658
- } else {
659
- 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")
660
2152
  }
661
- logger.info("Reloading \(id)")
662
2153
 
663
- let performReload: () -> Bool = {
664
- guard let vc = bridge.viewController as? CAPBridgeViewController else {
665
- self.logger.error("Cannot get viewController")
666
- 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)")
667
2162
  }
668
- guard let capBridge = vc.bridge else {
669
- self.logger.error("Cannot get capBridge")
670
- 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")
671
2175
  }
672
- if self.keepUrlPathAfterReload {
673
- if let currentURL = vc.webView?.url {
674
- capBridge.setServerBasePath(dest.path)
675
- var urlComponents = URLComponents(url: capBridge.config.serverURL, resolvingAgainstBaseURL: false)!
676
- urlComponents.path = currentURL.path
677
- urlComponents.query = currentURL.query
678
- urlComponents.fragment = currentURL.fragment
679
- if let finalUrl = urlComponents.url {
680
- _ = vc.webView?.load(URLRequest(url: finalUrl))
681
- } else {
682
- self.logger.error("Unable to build final URL when keeping path after reload; falling back to base path")
683
- vc.setServerBasePath(path: dest.path)
684
- }
685
- } else {
686
- self.logger.error("vc.webView?.url is null? Falling back to base path reload.")
687
- vc.setServerBasePath(path: dest.path)
688
- }
689
- } else {
690
- vc.setServerBasePath(path: dest.path)
2176
+ guard payload.url != nil || payload.manifest?.isEmpty == false else {
2177
+ throw makePreviewError("Preview payload is missing download information")
691
2178
  }
692
- self.checkAppReady()
693
- self.notifyListeners("appReloaded", data: [:])
694
- return true
695
- }
696
2179
 
697
- if Thread.isMainThread {
698
- return performReload()
699
- } else {
700
- var result = false
701
- DispatchQueue.main.sync {
702
- 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()
703
2184
  }
704
- 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
705
2206
  }
706
2207
  }
707
2208
 
708
- @objc func reload(_ call: CAPPluginCall) {
709
- if self._reload() {
710
- call.resolve()
711
- } else {
712
- logger.error("Reload failed")
713
- call.reject("Reload failed")
2209
+ private func clearPreviewSessionForNativeBuildChange() {
2210
+ guard self.previewSessionEnabled || self.implementation.getPreviewFallbackBundle() != nil || !self.previewSessions().isEmpty else {
2211
+ return
714
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()
715
2229
  }
716
2230
 
717
- @objc func next(_ call: CAPPluginCall) {
718
- guard let id = call.getString("id") else {
719
- logger.error("Next called without id")
720
- 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)
721
2235
  return
722
2236
  }
723
- logger.info("Setting next active id \(id)")
724
- if !self.implementation.setNextBundle(next: id) {
725
- logger.error("Set next version failed. id \(id) does not exist.")
726
- call.reject("Set next version failed. id \(id) does not exist.")
727
- } else {
728
- 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)
729
2240
  }
730
2241
  }
731
2242
 
732
- @objc func set(_ call: CAPPluginCall) {
733
- guard let id = call.getString("id") else {
734
- logger.error("Set called without id")
735
- call.reject("Set called without id")
2243
+ private func showPreviewSessionNoticeIfNeeded() {
2244
+ guard self.previewSessionEnabled && self.previewSessionAlertPending else {
736
2245
  return
737
2246
  }
738
- let res = implementation.set(id: id)
739
- logger.info("Set active bundle: \(id)")
740
- if !res {
741
- logger.info("Bundle successfully set to: \(id) ")
742
- call.reject("Update failed, id \(id) doesn't exist")
743
- } else {
744
- 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
+ }
745
2276
  }
746
2277
  }
747
2278
 
@@ -804,25 +2335,132 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
804
2335
 
805
2336
  @objc func getLatest(_ call: CAPPluginCall) {
806
2337
  let channel = call.getString("channel")
807
- DispatchQueue.global(qos: .background).async {
808
- let res = self.implementation.getLatest(url: URL(string: self.updateUrl)!, channel: channel)
809
- if res.error != nil {
810
- call.reject( res.error!)
811
- } else if res.message != nil {
812
- 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)
813
2382
  } else {
814
- call.resolve(res.toDict())
2383
+ self.resolveCall(call, data: res.toDict())
815
2384
  }
816
2385
  }
817
2386
  }
818
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
+
819
2456
  @objc func unsetChannel(_ call: CAPPluginCall) {
820
2457
  let triggerAutoUpdate = call.getBool("triggerAutoUpdate", false)
821
- DispatchQueue.global(qos: .background).async {
2458
+ self.saveCallForAsyncHandling(call)
2459
+ DispatchQueue.global(qos: .utility).async {
822
2460
  let configDefaultChannel = self.getConfig().getString("defaultChannel", "")!
823
2461
  let res = self.implementation.unsetChannel(defaultChannelKey: self.defaultChannelDefaultsKey, configDefaultChannel: configDefaultChannel)
824
2462
  if res.error != "" {
825
- call.reject(res.error, "UNSETCHANNEL_FAILED", nil, [
2463
+ self.rejectCall(call, message: res.error, code: "UNSETCHANNEL_FAILED", data: [
826
2464
  "message": res.error,
827
2465
  "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
828
2466
  ])
@@ -836,7 +2474,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
836
2474
  self.logger.info("Download already in progress, skipping duplicate download request")
837
2475
  }
838
2476
  }
839
- call.resolve(res.toDict())
2477
+ self.resolveCall(call, data: res.toDict())
840
2478
  }
841
2479
  }
842
2480
  }
@@ -851,17 +2489,24 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
851
2489
  return
852
2490
  }
853
2491
  let triggerAutoUpdate = call.getBool("triggerAutoUpdate") ?? false
854
- DispatchQueue.global(qos: .background).async {
855
- 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
+ )
856
2501
  if res.error != "" {
857
2502
  // Fire channelPrivate event if channel doesn't allow self-assignment
858
2503
  if res.error.contains("cannot_update_via_private_channel") || res.error.contains("channel_self_set_not_allowed") {
859
- self.notifyListeners("channelPrivate", data: [
2504
+ self.notifyListenersOnMain("channelPrivate", data: [
860
2505
  "channel": channel,
861
2506
  "message": res.error
862
2507
  ])
863
2508
  }
864
- call.reject(res.error, "SETCHANNEL_FAILED", nil, [
2509
+ self.rejectCall(call, message: res.error, code: "SETCHANNEL_FAILED", data: [
865
2510
  "message": res.error,
866
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"
867
2512
  ])
@@ -875,35 +2520,39 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
875
2520
  self.logger.info("Download already in progress, skipping duplicate download request")
876
2521
  }
877
2522
  }
878
- call.resolve(res.toDict())
2523
+ self.resolveCall(call, data: res.toDict())
879
2524
  }
880
2525
  }
881
2526
  }
882
2527
 
883
2528
  @objc func getChannel(_ call: CAPPluginCall) {
884
- DispatchQueue.global(qos: .background).async {
885
- let res = self.implementation.getChannel()
2529
+ self.saveCallForAsyncHandling(call)
2530
+ DispatchQueue.global(qos: .utility).async {
2531
+ let res = self.implementation.getChannel(defaultChannelKey: self.defaultChannelDefaultsKey)
886
2532
  if res.error != "" {
887
- call.reject(res.error, "GETCHANNEL_FAILED", nil, [
2533
+ self.rejectCall(call, message: res.error, code: "GETCHANNEL_FAILED", data: [
888
2534
  "message": res.error,
889
2535
  "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
890
2536
  ])
891
2537
  } else {
892
- call.resolve(res.toDict())
2538
+ self.resolveCall(call, data: res.toDict())
893
2539
  }
894
2540
  }
895
2541
  }
896
2542
 
897
2543
  @objc func listChannels(_ call: CAPPluginCall) {
898
- DispatchQueue.global(qos: .background).async {
2544
+ self.saveCallForAsyncHandling(call)
2545
+ DispatchQueue.global(qos: .utility).async {
899
2546
  let res = self.implementation.listChannels()
900
2547
  if res.error != "" {
901
- call.reject(res.error, "LISTCHANNELS_FAILED", nil, [
2548
+ self.rejectCall(call, message: res.error, code: "LISTCHANNELS_FAILED", data: [
902
2549
  "message": res.error,
903
2550
  "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
904
2551
  ])
905
2552
  } else {
906
- call.resolve(res.toDict())
2553
+ var payload: JSObject = [:]
2554
+ payload["channels"] = res.channels
2555
+ self.resolveCall(call, data: payload)
907
2556
  }
908
2557
  }
909
2558
  }
@@ -926,32 +2575,90 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
926
2575
  call.resolve()
927
2576
  }
928
2577
 
929
- @objc func _reset(toLastSuccessful: Bool) -> Bool {
930
- 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
+ }
931
2581
 
932
- if (bridge.viewController as? CAPBridgeViewController) != nil {
933
- let fallback: BundleInfo = self.implementation.getFallbackBundle()
2582
+ func performReset(toLastSuccessful: Bool, usePendingBundle: Bool, isInternal: Bool) -> Bool {
2583
+ guard self.canPerformResetTransition() else { return false }
934
2584
 
935
- // If developer wants to reset to the last successful bundle, and that bundle is not
936
- // the built-in bundle, set it as the bundle to use and reload.
937
- if toLastSuccessful && !fallback.isBuiltin() {
938
- logger.info("Resetting to: \(fallback.toString())")
939
- return self.implementation.set(bundle: fallback) && self._reload()
940
- }
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()
941
2589
 
942
- 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
+ }
943
2617
 
944
- // Otherwise, reset back to the built-in bundle and reload.
945
- self.implementation.reset()
946
- 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
+ }
947
2638
  }
948
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
+ }
949
2650
  return false
950
2651
  }
951
2652
 
2653
+ func canPerformResetTransition() -> Bool {
2654
+ guard let bridge = self.bridge else { return false }
2655
+ return (bridge.viewController as? CAPBridgeViewController) != nil
2656
+ }
2657
+
952
2658
  @objc func reset(_ call: CAPPluginCall) {
953
2659
  let toLastSuccessful = call.getBool("toLastSuccessful") ?? false
954
- if self._reset(toLastSuccessful: toLastSuccessful) {
2660
+ let usePendingBundle = call.getBool("usePendingBundle") ?? false
2661
+ if self._reset(toLastSuccessful: toLastSuccessful, usePendingBundle: usePendingBundle) {
955
2662
  call.resolve()
956
2663
  } else {
957
2664
  logger.error("Reset failed")
@@ -972,6 +2679,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
972
2679
  let bundle = self.implementation.getCurrentBundle()
973
2680
  self.implementation.setSuccess(bundle: bundle, autoDeletePrevious: self.autoDeletePrevious)
974
2681
  logger.info("Current bundle loaded successfully. [notifyAppReady was called] \(bundle.toString())")
2682
+ self.clearIncomingPreviewTransition()
2683
+ self.hidePreviewTransitionLoader(reason: "notify-app-ready")
2684
+
975
2685
  call.resolve(["bundle": bundle.toJSON()])
976
2686
  }
977
2687
 
@@ -1021,6 +2731,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1021
2731
  // Note: _checkCancelDelay method has been moved to DelayUpdateUtils class
1022
2732
 
1023
2733
  private func _isAutoUpdateEnabled() -> Bool {
2734
+ if self.isPreviewSessionStateActive() {
2735
+ return false
2736
+ }
1024
2737
  let instanceDescriptor = (self.bridge?.viewController as? CAPBridgeViewController)?.instanceDescriptor()
1025
2738
  if instanceDescriptor?.serverURL != nil {
1026
2739
  logger.warn("AutoUpdate is automatic disabled when serverUrl is set.")
@@ -1058,10 +2771,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1058
2771
  logger.info("Built-in bundle is active. We skip the check for notifyAppReady.")
1059
2772
  return
1060
2773
  }
2774
+ if self.isPreviewSessionStateActive() {
2775
+ logger.info("Preview session is active. We skip the check for notifyAppReady.")
2776
+ return
2777
+ }
1061
2778
 
1062
2779
  logger.info("Current bundle is: \(current.toString())")
1063
2780
 
1064
- if BundleStatus.SUCCESS.localizedString != current.getStatus() {
2781
+ if BundleStatus.SUCCESS.storedValue != current.getStatus() {
1065
2782
  logger.error("notifyAppReady was not called, roll back current bundle: \(current.toString())")
1066
2783
  logger.error("Did you forget to call 'notifyAppReady()' in your Capacitor App code?")
1067
2784
  self.notifyListeners("updateFailed", data: [
@@ -1070,7 +2787,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1070
2787
  self.persistLastFailedBundle(current)
1071
2788
  self.implementation.sendStats(action: "update_fail", versionName: current.getVersionName())
1072
2789
  self.implementation.setError(bundle: current)
1073
- _ = self._reset(toLastSuccessful: true)
2790
+ _ = self.performReset(toLastSuccessful: true, usePendingBundle: false, isInternal: true)
1074
2791
  if self.autoDeleteFailed && !current.isBuiltin() {
1075
2792
  logger.info("Deleting failing bundle: \(current.toString())")
1076
2793
  let res = self.implementation.delete(id: current.getId(), removeInfo: false)
@@ -1095,6 +2812,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1095
2812
  self.backgroundTaskID = UIBackgroundTaskIdentifier.invalid
1096
2813
  }
1097
2814
 
2815
+ private func notifyBundleSet(_ bundle: BundleInfo) {
2816
+ self.notifyListeners("set", data: ["bundle": bundle.toJSON()], retainUntilConsumed: true)
2817
+ }
2818
+
1098
2819
  func sendReadyToJs(current: BundleInfo, msg: String) {
1099
2820
  logger.info("sendReadyToJs")
1100
2821
  DispatchQueue.global().async {
@@ -1106,6 +2827,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1106
2827
  if self.autoSplashscreen {
1107
2828
  self.hideSplashscreen()
1108
2829
  }
2830
+ self.hidePreviewTransitionLoader(reason: "app-ready")
1109
2831
  }
1110
2832
  }
1111
2833
 
@@ -1122,32 +2844,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1122
2844
  private func performHideSplashscreen() {
1123
2845
  self.cancelSplashscreenTimeout()
1124
2846
  self.removeSplashscreenLoader()
1125
-
1126
- guard let bridge = self.bridge else {
1127
- self.logger.warn("Bridge not available for hiding splashscreen with autoSplashscreen")
1128
- return
1129
- }
1130
-
1131
- // Create a plugin call for the hide method
1132
- let call = CAPPluginCall(callbackId: "autoHideSplashscreen", options: [:], success: { (_, _) in
1133
- self.logger.info("Splashscreen hidden automatically")
1134
- }, error: { (_) in
1135
- self.logger.error("Failed to auto-hide splashscreen")
1136
- })
1137
-
1138
- // Try to call the SplashScreen hide method directly through the bridge
1139
- if let splashScreenPlugin = bridge.plugin(withName: "SplashScreen") {
1140
- // Use runtime method invocation to call hide method
1141
- let selector = NSSelectorFromString("hide:")
1142
- if splashScreenPlugin.responds(to: selector) {
1143
- _ = splashScreenPlugin.perform(selector, with: call)
1144
- self.logger.info("Called SplashScreen hide method")
1145
- } else {
1146
- self.logger.warn("autoSplashscreen: SplashScreen plugin does not respond to hide: method. Make sure @capacitor/splash-screen plugin is properly installed.")
1147
- }
1148
- } else {
1149
- self.logger.warn("autoSplashscreen: SplashScreen plugin not found. Install @capacitor/splash-screen plugin.")
1150
- }
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
+ )
1151
2855
  }
1152
2856
 
1153
2857
  private func showSplashscreen() {
@@ -1158,40 +2862,182 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1158
2862
  self.performShowSplashscreen()
1159
2863
  }
1160
2864
  }
1161
- }
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
+ }
1162
2935
 
1163
- private func performShowSplashscreen() {
1164
- self.cancelSplashscreenTimeout()
1165
- 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
+ }
1166
2947
 
1167
- guard let bridge = self.bridge else {
1168
- 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
+ )
1169
2958
  return
1170
2959
  }
1171
2960
 
1172
- // Create a plugin call for the show method
1173
- let call = CAPPluginCall(callbackId: "autoShowSplashscreen", options: [:], success: { (_, _) in
1174
- self.logger.info("Splashscreen shown automatically")
1175
- }, error: { (_) in
1176
- self.logger.error("Failed to auto-show splashscreen")
1177
- })
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
+ }
1178
2965
 
1179
- // Try to call the SplashScreen show method directly through the bridge
1180
- if let splashScreenPlugin = bridge.plugin(withName: "SplashScreen") {
1181
- // Use runtime method invocation to call show method
1182
- let selector = NSSelectorFromString("show:")
1183
- if splashScreenPlugin.responds(to: selector) {
1184
- _ = splashScreenPlugin.perform(selector, with: call)
1185
- 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)
1186
2977
  } else {
1187
- 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
1188
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
1189
3011
  } else {
1190
- self.logger.warn("autoSplashscreen: SplashScreen plugin not found. Install @capacitor/splash-screen plugin.")
3012
+ indicatorStyle = .whiteLarge
1191
3013
  }
1192
3014
 
1193
- self.addSplashscreenLoaderIfNeeded()
1194
- 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
+ ])
1195
3041
  }
1196
3042
 
1197
3043
  private func addSplashscreenLoaderIfNeeded() {
@@ -1208,40 +3054,20 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1208
3054
  return
1209
3055
  }
1210
3056
 
1211
- let container = UIView()
1212
- container.translatesAutoresizingMaskIntoConstraints = false
1213
- container.backgroundColor = UIColor.clear
1214
- container.isUserInteractionEnabled = false
1215
-
1216
- let indicatorStyle: UIActivityIndicatorView.Style
3057
+ let indicatorColor: UIColor?
1217
3058
  if #available(iOS 13.0, *) {
1218
- indicatorStyle = .large
3059
+ indicatorColor = UIColor.label
1219
3060
  } else {
1220
- indicatorStyle = .whiteLarge
1221
- }
1222
-
1223
- let indicator = UIActivityIndicatorView(style: indicatorStyle)
1224
- indicator.translatesAutoresizingMaskIntoConstraints = false
1225
- indicator.hidesWhenStopped = false
1226
- if #available(iOS 13.0, *) {
1227
- indicator.color = UIColor.label
3061
+ indicatorColor = nil
1228
3062
  }
1229
- indicator.startAnimating()
1230
-
1231
- container.addSubview(indicator)
1232
- rootView.addSubview(container)
1233
-
1234
- NSLayoutConstraint.activate([
1235
- container.leadingAnchor.constraint(equalTo: rootView.leadingAnchor),
1236
- container.trailingAnchor.constraint(equalTo: rootView.trailingAnchor),
1237
- container.topAnchor.constraint(equalTo: rootView.topAnchor),
1238
- container.bottomAnchor.constraint(equalTo: rootView.bottomAnchor),
1239
- indicator.centerXAnchor.constraint(equalTo: container.centerXAnchor),
1240
- indicator.centerYAnchor.constraint(equalTo: container.centerYAnchor)
1241
- ])
1242
-
1243
- self.splashscreenLoaderContainer = container
1244
- 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
1245
3071
  }
1246
3072
 
1247
3073
  if Thread.isMainThread {
@@ -1270,6 +3096,97 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1270
3096
  }
1271
3097
  }
1272
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
+
1273
3190
  private func scheduleSplashscreenTimeout() {
1274
3191
  guard self.autoSplashscreenTimeout > 0 else {
1275
3192
  return
@@ -1329,6 +3246,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1329
3246
  }
1330
3247
 
1331
3248
  private func shouldUseDirectUpdate() -> Bool {
3249
+ if !self.autoUpdate || self.autoUpdateMode == Self.autoUpdateModeOnlyDownload {
3250
+ return false
3251
+ }
1332
3252
  if self.autoSplashscreenTimedOut {
1333
3253
  return false
1334
3254
  }
@@ -1345,7 +3265,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1345
3265
  }
1346
3266
  return false
1347
3267
  case "onLaunch":
1348
- if !self.onLaunchDirectUpdateUsed {
3268
+ if !self.getOnLaunchDirectUpdateUsed() {
1349
3269
  return true
1350
3270
  }
1351
3271
  return false
@@ -1355,6 +3275,183 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1355
3275
  }
1356
3276
  }
1357
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
+
1358
3455
  private func notifyBreakingEvents(version: String) {
1359
3456
  guard !version.isEmpty else {
1360
3457
  return
@@ -1364,14 +3461,82 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1364
3461
  self.notifyListeners("majorAvailable", data: payload)
1365
3462
  }
1366
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
+
1367
3530
  func endBackGroundTaskWithNotif(
1368
3531
  msg: String,
1369
3532
  latestVersionName: String,
1370
3533
  current: BundleInfo,
1371
3534
  error: Bool = true,
3535
+ plannedDirectUpdate: Bool = false,
1372
3536
  failureAction: String = "download_fail",
1373
3537
  failureEvent: String = "downloadFailed",
1374
- sendStats: Bool = true
3538
+ sendStats: Bool = true,
3539
+ notifyNoNeedUpdate: Bool = true
1375
3540
  ) {
1376
3541
  // Clear download in progress flag - this is called at the end of every download attempt
1377
3542
  // whether it succeeds, fails, or is skipped (e.g., already up to date)
@@ -1380,13 +3545,17 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1380
3545
  downloadInProgress = false
1381
3546
  downloadStartTime = nil
1382
3547
 
3548
+ self.consumeOnLaunchDirectUpdateAttempt(plannedDirectUpdate: plannedDirectUpdate)
3549
+
1383
3550
  if error {
1384
3551
  if sendStats {
1385
3552
  self.implementation.sendStats(action: failureAction, versionName: current.getVersionName())
1386
3553
  }
1387
3554
  self.notifyListeners(failureEvent, data: ["version": latestVersionName])
1388
3555
  }
1389
- self.notifyListeners("noNeedUpdate", data: ["bundle": current.toJSON()])
3556
+ if notifyNoNeedUpdate {
3557
+ self.notifyListeners("noNeedUpdate", data: ["bundle": current.toJSON()])
3558
+ }
1390
3559
  self.sendReadyToJs(current: current, msg: msg)
1391
3560
  logger.info("endBackGroundTaskWithNotif \(msg) current: \(current.getVersionName()) latestVersionName: \(latestVersionName)")
1392
3561
  self.endBackGroundTask()
@@ -1414,7 +3583,41 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1414
3583
  return true
1415
3584
  }
1416
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
+
1417
3617
  func backgroundDownload() {
3618
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3619
+ return
3620
+ }
1418
3621
  // Set download in progress flag (thread-safe)
1419
3622
  downloadLock.lock()
1420
3623
  downloadInProgress = true
@@ -1422,7 +3625,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1422
3625
  downloadLock.unlock()
1423
3626
 
1424
3627
  let plannedDirectUpdate = self.shouldUseDirectUpdate()
1425
- 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
+ }
1426
3636
  guard let url = URL(string: self.updateUrl) else {
1427
3637
  logger.error("Error no url or wrong format")
1428
3638
  // Clear the flag if we return early
@@ -1433,28 +3643,32 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1433
3643
  return
1434
3644
  }
1435
3645
 
1436
- DispatchQueue.global(qos: .background).async {
3646
+ self.runBackgroundDownloadWork {
1437
3647
  // Wait for cleanup to complete before starting download
1438
3648
  self.waitForCleanupIfNeeded()
1439
- self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "Finish Download Tasks") {
1440
- // End the task if time expires.
1441
- self.endBackGroundTask()
3649
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3650
+ self.clearDownloadInProgressState()
3651
+ return
1442
3652
  }
3653
+ self.beginDownloadBackgroundTask()
1443
3654
  self.logger.info("Check for update via \(self.updateUrl)")
1444
3655
  let res = self.implementation.getLatest(url: url, channel: nil)
1445
3656
  let current = self.implementation.getCurrentBundle()
3657
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3658
+ self.clearDownloadInProgressState()
3659
+ self.endBackGroundTask()
3660
+ return
3661
+ }
1446
3662
 
1447
3663
  // Handle network errors and other failures first
1448
- if let backendError = res.error, !backendError.isEmpty {
1449
- self.logger.error("getLatest failed with error: \(backendError)")
1450
- let statusCode = res.statusCode
1451
- let responseIsOk = statusCode >= 200 && statusCode < 300
1452
- self.endBackGroundTaskWithNotif(
1453
- msg: res.message ?? backendError,
1454
- 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,
1455
3670
  current: current,
1456
- error: true,
1457
- sendStats: !responseIsOk
3671
+ plannedDirectUpdate: plannedDirectUpdate
1458
3672
  )
1459
3673
  return
1460
3674
  }
@@ -1463,29 +3677,58 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1463
3677
  let directUpdateAllowed = plannedDirectUpdate && !self.autoSplashscreenTimedOut
1464
3678
  if directUpdateAllowed {
1465
3679
  self.logger.info("Direct update to builtin version")
1466
- if self.directUpdateMode == "onLaunch" {
1467
- self.onLaunchDirectUpdateUsed = true
1468
- self.directUpdate = false
1469
- }
1470
- _ = self._reset(toLastSuccessful: false)
1471
- self.endBackGroundTaskWithNotif(msg: "Updated to builtin version", latestVersionName: res.version, current: self.implementation.getCurrentBundle(), error: false)
1472
- } 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() {
1473
3689
  if plannedDirectUpdate && !directUpdateAllowed {
1474
3690
  self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will apply later.")
1475
3691
  }
1476
3692
  self.logger.info("Setting next bundle to builtin")
1477
3693
  _ = self.implementation.setNextBundle(next: BundleInfo.ID_BUILTIN)
1478
- 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
+ )
1479
3716
  }
1480
3717
  return
1481
3718
  }
1482
3719
  let sessionKey = res.sessionKey ?? ""
3720
+ let latestVersionName = res.version
1483
3721
  guard let downloadUrl = URL(string: res.url) else {
3722
+ self.notifyBreakingEventsIfNeeded(response: res, version: latestVersionName)
1484
3723
  self.logger.error("Error no url or wrong format")
1485
- 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
+ )
1486
3730
  return
1487
3731
  }
1488
- let latestVersionName = res.version
1489
3732
  if latestVersionName != "" && current.getVersionName() != latestVersionName {
1490
3733
  do {
1491
3734
  self.logger.info("New bundle: \(latestVersionName) found. Current is: \(current.getVersionName()). \(messageUpdate)")
@@ -1500,6 +3743,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1500
3743
  self.logger.error("Failed to delete failed bundle: \(nextImpl!.toString())")
1501
3744
  }
1502
3745
  }
3746
+ self.consumeOnLaunchDirectUpdateAttempt(plannedDirectUpdate: plannedDirectUpdate)
1503
3747
  if res.manifest != nil {
1504
3748
  nextImpl = try self.implementation.downloadManifest(manifest: res.manifest!, version: latestVersionName, sessionKey: sessionKey, link: res.link, comment: res.comment)
1505
3749
  } else {
@@ -1508,12 +3752,27 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1508
3752
  }
1509
3753
  guard let next = nextImpl else {
1510
3754
  self.logger.error("Error downloading file")
1511
- 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
+ )
1512
3761
  return
1513
3762
  }
1514
3763
  if next.isErrorStatus() {
1515
3764
  self.logger.error("Latest bundle already exists and is in error state. Aborting update.")
1516
- 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()
1517
3776
  return
1518
3777
  }
1519
3778
  res.checksum = try CryptoCipher.decryptChecksum(checksum: res.checksum, publicKey: self.implementation.publicKey)
@@ -1527,7 +3786,17 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1527
3786
  if !resDel {
1528
3787
  self.logger.error("Delete failed, id \(id) doesn't exist")
1529
3788
  }
1530
- 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()
1531
3800
  return
1532
3801
  }
1533
3802
  let directUpdateAllowed = plannedDirectUpdate && !self.autoSplashscreenTimedOut
@@ -1540,40 +3809,90 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1540
3809
  }
1541
3810
  if !delayConditionList.isEmpty {
1542
3811
  self.logger.info("Update delayed until delay conditions met")
1543
- 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
+ )
1544
3819
  return
1545
3820
  }
1546
- if self.directUpdateMode == "onLaunch" {
1547
- self.onLaunchDirectUpdateUsed = true
1548
- 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
+ )
1549
3840
  }
1550
- _ = self.implementation.set(bundle: next)
1551
- _ = self._reload()
1552
- self.endBackGroundTaskWithNotif(msg: "update installed", latestVersionName: latestVersionName, current: next, error: false)
1553
- } else {
3841
+ } else if self.shouldAutoSetNextBundle() {
1554
3842
  if plannedDirectUpdate && !directUpdateAllowed {
1555
3843
  self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will install on next app background.")
1556
3844
  }
1557
3845
  self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
1558
3846
  _ = self.implementation.setNextBundle(next: next.getId())
1559
- 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
+ )
1560
3865
  }
1561
3866
  return
1562
3867
  } catch {
1563
3868
  self.logger.error("Error downloading file \(error.localizedDescription)")
1564
3869
  let current: BundleInfo = self.implementation.getCurrentBundle()
1565
- 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
+ )
1566
3876
  return
1567
3877
  }
1568
3878
  } else {
1569
3879
  self.logger.info("No need to update, \(current.getId()) is the latest bundle.")
1570
- 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
+ )
1571
3887
  return
1572
3888
  }
1573
3889
  }
1574
3890
  }
1575
3891
 
1576
3892
  private func installNext() {
3893
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3894
+ return
3895
+ }
1577
3896
  let delayUpdatePreferences = UserDefaults.standard.string(forKey: DelayUpdateUtils.DELAY_CONDITION_PREFERENCES) ?? "[]"
1578
3897
  let delayConditionList: [DelayCondition] = fromJsonArr(json: delayUpdatePreferences).map { obj -> DelayCondition in
1579
3898
  let kind: String = obj.value(forKey: "kind") as! String
@@ -1591,6 +3910,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1591
3910
  logger.info("Next bundle is: \(next!.toString())")
1592
3911
  if self.implementation.set(bundle: next!) && self._reload() {
1593
3912
  logger.info("Updated to bundle: \(next!.toString())")
3913
+ self.notifyBundleSet(next!)
1594
3914
  _ = self.implementation.setNextBundle(next: Optional<String>.none)
1595
3915
  } else {
1596
3916
  logger.error("Update to bundle: \(next!.toString()) Failed!")
@@ -1617,6 +3937,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1617
3937
  }
1618
3938
 
1619
3939
  @objc func appMovedToForeground() {
3940
+ appHealthTracker?.markForeground(true)
1620
3941
  let current: BundleInfo = self.implementation.getCurrentBundle()
1621
3942
  self.implementation.sendStats(action: "app_moved_to_foreground", versionName: current.getVersionName())
1622
3943
  self.delayUpdateUtils.checkCancelDelay(source: .foreground)
@@ -1662,9 +3983,15 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1662
3983
  timer.invalidate()
1663
3984
  return
1664
3985
  }
1665
- DispatchQueue.global(qos: .background).async {
3986
+ DispatchQueue.global(qos: .utility).async {
3987
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3988
+ return
3989
+ }
1666
3990
  let res = self.implementation.getLatest(url: url, channel: nil)
1667
3991
  let current = self.implementation.getCurrentBundle()
3992
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3993
+ return
3994
+ }
1668
3995
 
1669
3996
  if res.version != current.getVersionName() {
1670
3997
  self.logger.info("New version found: \(res.version)")
@@ -1683,6 +4010,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1683
4010
  @objc func appMovedToBackground() {
1684
4011
  // Reset timeout flag at start of each background cycle
1685
4012
  self.autoSplashscreenTimedOut = false
4013
+ appHealthTracker?.markForeground(false)
1686
4014
 
1687
4015
  let current: BundleInfo = self.implementation.getCurrentBundle()
1688
4016
  self.implementation.sendStats(action: "app_moved_to_background", versionName: current.getVersionName())
@@ -1749,13 +4077,15 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1749
4077
  }
1750
4078
 
1751
4079
  self.shakeMenuEnabled = enabled
1752
- logger.info("Shake menu \(enabled ? "enabled" : "disabled")")
4080
+ self.syncShakeMenuGestureRecognizer()
4081
+ logger.info("Shake menu \(enabled ? "enabled" : "disabled") with \(self.shakeMenuGesture) gesture")
1753
4082
  call.resolve()
1754
4083
  }
1755
4084
 
1756
4085
  @objc func isShakeMenuEnabled(_ call: CAPPluginCall) {
1757
4086
  call.resolve([
1758
- "enabled": self.shakeMenuEnabled
4087
+ "enabled": self.shakeMenuEnabled,
4088
+ "gesture": self.shakeMenuGesture
1759
4089
  ])
1760
4090
  }
1761
4091
 
@@ -1767,6 +4097,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1767
4097
  }
1768
4098
 
1769
4099
  self.shakeChannelSelectorEnabled = enabled
4100
+ self.syncShakeMenuGestureRecognizer()
1770
4101
  logger.info("Shake channel selector \(enabled ? "enabled" : "disabled")")
1771
4102
  call.resolve()
1772
4103
  }
@@ -1814,29 +4145,30 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1814
4145
 
1815
4146
  logger.info("Getting App Store update info for \(bundleId) in country \(country)")
1816
4147
 
4148
+ self.saveCallForAsyncHandling(call)
1817
4149
  DispatchQueue.global(qos: .background).async {
1818
4150
  let urlString = "https://itunes.apple.com/lookup?bundleId=\(bundleId)&country=\(country)"
1819
4151
  guard let url = URL(string: urlString) else {
1820
- call.reject("Invalid URL for App Store lookup")
4152
+ self.rejectCall(call, message: "Invalid URL for App Store lookup")
1821
4153
  return
1822
4154
  }
1823
4155
 
1824
4156
  let task = URLSession.shared.dataTask(with: url) { data, _, error in
1825
4157
  if let error = error {
1826
4158
  self.logger.error("App Store lookup failed: \(error.localizedDescription)")
1827
- call.reject("App Store lookup failed: \(error.localizedDescription)")
4159
+ self.rejectCall(call, message: "App Store lookup failed: \(error.localizedDescription)")
1828
4160
  return
1829
4161
  }
1830
4162
 
1831
4163
  guard let data = data else {
1832
- call.reject("No data received from App Store")
4164
+ self.rejectCall(call, message: "No data received from App Store")
1833
4165
  return
1834
4166
  }
1835
4167
 
1836
4168
  do {
1837
4169
  guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
1838
4170
  let resultCount = json["resultCount"] as? Int else {
1839
- call.reject("Invalid response from App Store")
4171
+ self.rejectCall(call, message: "Invalid response from App Store")
1840
4172
  return
1841
4173
  }
1842
4174
 
@@ -1893,10 +4225,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1893
4225
  self.logger.info("App not found in App Store for bundleId: \(bundleId)")
1894
4226
  }
1895
4227
 
1896
- call.resolve(result)
4228
+ self.resolveCall(call, data: result)
1897
4229
  } catch {
1898
4230
  self.logger.error("Failed to parse App Store response: \(error.localizedDescription)")
1899
- call.reject("Failed to parse App Store response: \(error.localizedDescription)")
4231
+ self.rejectCall(call, message: "Failed to parse App Store response: \(error.localizedDescription)")
1900
4232
  }
1901
4233
  }
1902
4234
  task.resume()
@@ -1905,37 +4237,48 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1905
4237
 
1906
4238
  @objc func openAppStore(_ call: CAPPluginCall) {
1907
4239
  let appId = call.getString("appId")
4240
+ let bundleId = implementation.appId
4241
+ self.saveCallForAsyncHandling(call)
1908
4242
 
1909
- if let appId = appId {
1910
- // Open App Store with provided app ID
1911
- 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") {
1912
4244
  guard let url = URL(string: urlString) else {
1913
- call.reject("Invalid App Store URL")
4245
+ self.rejectCall(call, message: invalidMessage)
1914
4246
  return
1915
4247
  }
1916
4248
  DispatchQueue.main.async {
1917
4249
  UIApplication.shared.open(url) { success in
1918
4250
  if success {
1919
- call.resolve()
4251
+ self.resolveCall(call)
1920
4252
  } else {
1921
- call.reject("Failed to open App Store")
4253
+ self.rejectCall(call, message: failureMessage)
1922
4254
  }
1923
4255
  }
1924
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)")
1925
4269
  } else {
1926
- // Look up app ID using bundle identifier
1927
- let bundleId = implementation.appId
1928
4270
  let lookupUrl = "https://itunes.apple.com/lookup?bundleId=\(bundleId)"
1929
4271
 
1930
4272
  DispatchQueue.global(qos: .background).async {
1931
4273
  guard let url = URL(string: lookupUrl) else {
1932
- call.reject("Invalid lookup URL")
4274
+ openFallbackAppStorePage()
1933
4275
  return
1934
4276
  }
1935
4277
 
1936
4278
  let task = URLSession.shared.dataTask(with: url) { data, _, error in
1937
4279
  if let error = error {
1938
- call.reject("Failed to lookup app: \(error.localizedDescription)")
4280
+ self.logger.error("App Store lookup failed: \(error.localizedDescription)")
4281
+ openFallbackAppStorePage()
1939
4282
  return
1940
4283
  }
1941
4284
 
@@ -1944,39 +4287,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1944
4287
  let results = json["results"] as? [[String: Any]],
1945
4288
  let appInfo = results.first,
1946
4289
  let trackId = appInfo["trackId"] as? Int else {
1947
- // If lookup fails, try opening the generic App Store app page using bundle ID
1948
- let fallbackUrlString = "https://apps.apple.com/app/\(bundleId)"
1949
- guard let fallbackUrl = URL(string: fallbackUrlString) else {
1950
- call.reject("Failed to find app in App Store and fallback URL is invalid")
1951
- return
1952
- }
1953
- DispatchQueue.main.async {
1954
- UIApplication.shared.open(fallbackUrl) { success in
1955
- if success {
1956
- call.resolve()
1957
- } else {
1958
- call.reject("Failed to open App Store")
1959
- }
1960
- }
1961
- }
1962
- return
1963
- }
1964
-
1965
- let appStoreUrl = "https://apps.apple.com/app/id\(trackId)"
1966
- guard let url = URL(string: appStoreUrl) else {
1967
- call.reject("Invalid App Store URL")
4290
+ openFallbackAppStorePage()
1968
4291
  return
1969
4292
  }
1970
4293
 
1971
- DispatchQueue.main.async {
1972
- UIApplication.shared.open(url) { success in
1973
- if success {
1974
- call.resolve()
1975
- } else {
1976
- call.reject("Failed to open App Store")
1977
- }
1978
- }
1979
- }
4294
+ openAppStorePage(urlString: "https://apps.apple.com/app/id\(trackId)")
1980
4295
  }
1981
4296
  task.resume()
1982
4297
  }