@capgo/capacitor-updater 7.45.10 → 7.50.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/Package.swift +1 -1
  2. package/README.md +510 -92
  3. package/android/src/main/java/ee/forgr/capacitor_updater/AndroidAppExitReporter.java +92 -0
  4. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +3192 -777
  5. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +798 -299
  6. package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +45 -30
  7. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +46 -28
  8. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +2 -0
  9. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +3 -3
  10. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +359 -25
  11. package/android/src/main/java/ee/forgr/capacitor_updater/ThreeFingerPinchDetector.java +323 -0
  12. package/dist/docs.json +1283 -169
  13. package/dist/esm/definitions.d.ts +621 -44
  14. package/dist/esm/definitions.js.map +1 -1
  15. package/dist/esm/web.d.ts +11 -1
  16. package/dist/esm/web.js +59 -1
  17. package/dist/esm/web.js.map +1 -1
  18. package/dist/plugin.cjs.js +59 -1
  19. package/dist/plugin.cjs.js.map +1 -1
  20. package/dist/plugin.js +59 -1
  21. package/dist/plugin.js.map +1 -1
  22. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +0 -1
  23. package/ios/Sources/CapacitorUpdaterPlugin/AppHealthTracker.swift +82 -0
  24. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +0 -16
  25. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +2 -2
  26. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +78 -2
  27. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +2116 -223
  28. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +997 -332
  29. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +0 -1
  30. package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +78 -1
  31. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +418 -36
  32. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +276 -0
  33. package/package.json +15 -3
@@ -37,6 +37,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
37
37
  CAPPluginMethod(name: "setStatsUrl", returnType: CAPPluginReturnPromise),
38
38
  CAPPluginMethod(name: "setChannelUrl", returnType: CAPPluginReturnPromise),
39
39
  CAPPluginMethod(name: "set", returnType: CAPPluginReturnPromise),
40
+ CAPPluginMethod(name: "startPreviewSession", returnType: CAPPluginReturnPromise),
41
+ CAPPluginMethod(name: "listPreviews", returnType: CAPPluginReturnPromise),
42
+ CAPPluginMethod(name: "setPreview", returnType: CAPPluginReturnPromise),
43
+ CAPPluginMethod(name: "resetPreview", returnType: CAPPluginReturnPromise),
44
+ CAPPluginMethod(name: "deletePreview", returnType: CAPPluginReturnPromise),
45
+ CAPPluginMethod(name: "checkPreviewUpdate", returnType: CAPPluginReturnPromise),
46
+ CAPPluginMethod(name: "updatePreview", returnType: CAPPluginReturnPromise),
40
47
  CAPPluginMethod(name: "list", returnType: CAPPluginReturnPromise),
41
48
  CAPPluginMethod(name: "delete", returnType: CAPPluginReturnPromise),
42
49
  CAPPluginMethod(name: "setBundleError", returnType: CAPPluginReturnPromise),
@@ -47,8 +54,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
47
54
  CAPPluginMethod(name: "setMultiDelay", returnType: CAPPluginReturnPromise),
48
55
  CAPPluginMethod(name: "cancelDelay", returnType: CAPPluginReturnPromise),
49
56
  CAPPluginMethod(name: "getLatest", returnType: CAPPluginReturnPromise),
57
+ CAPPluginMethod(name: "getMissingBundleFiles", returnType: CAPPluginReturnPromise),
58
+ CAPPluginMethod(name: "getBundleDownloadSize", returnType: CAPPluginReturnPromise),
59
+ CAPPluginMethod(name: "triggerUpdateCheck", returnType: CAPPluginReturnPromise),
50
60
  CAPPluginMethod(name: "setChannel", returnType: CAPPluginReturnPromise),
51
61
  CAPPluginMethod(name: "unsetChannel", returnType: CAPPluginReturnPromise),
62
+ CAPPluginMethod(name: "reportWebViewError", returnType: CAPPluginReturnPromise),
52
63
  CAPPluginMethod(name: "getChannel", returnType: CAPPluginReturnPromise),
53
64
  CAPPluginMethod(name: "listChannels", returnType: CAPPluginReturnPromise),
54
65
  CAPPluginMethod(name: "setCustomId", returnType: CAPPluginReturnPromise),
@@ -64,6 +75,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
64
75
  CAPPluginMethod(name: "isShakeMenuEnabled", returnType: CAPPluginReturnPromise),
65
76
  CAPPluginMethod(name: "setShakeChannelSelector", returnType: CAPPluginReturnPromise),
66
77
  CAPPluginMethod(name: "isShakeChannelSelectorEnabled", returnType: CAPPluginReturnPromise),
78
+ CAPPluginMethod(name: "getAppId", returnType: CAPPluginReturnPromise),
79
+ CAPPluginMethod(name: "setAppId", returnType: CAPPluginReturnPromise),
67
80
  // App Store update methods
68
81
  CAPPluginMethod(name: "getAppUpdateInfo", returnType: CAPPluginReturnPromise),
69
82
  CAPPluginMethod(name: "openAppStore", returnType: CAPPluginReturnPromise),
@@ -72,10 +85,19 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
72
85
  CAPPluginMethod(name: "completeFlexibleUpdate", returnType: CAPPluginReturnPromise)
73
86
  ]
74
87
  public var implementation = CapgoUpdater()
75
- private let pluginVersion: String = "7.45.10"
88
+ private let pluginVersion: String = "7.50.1"
76
89
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
77
90
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
78
91
  static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
92
+ static let autoUpdateModeOff = "off"
93
+ static let autoUpdateModeBackground = "atBackground"
94
+ static let autoUpdateModeInstall = "atInstall"
95
+ static let autoUpdateModeLaunch = "onLaunch"
96
+ static let autoUpdateModeAlways = "always"
97
+ static let autoUpdateModeOnlyDownload = "onlyDownload"
98
+ static let shakeMenuGestureShake = "shake"
99
+ static let shakeMenuGestureThreeFingerPinch = "threeFingerPinch"
100
+ private static let previewLoaderTimeoutMs = 60000
79
101
  private let keepUrlPathFlagKey = "__capgo_keep_url_path_after_reload"
80
102
  private let customIdDefaultsKey = "CapacitorUpdater.customId"
81
103
  private let updateUrlDefaultsKey = "CapacitorUpdater.updateUrl"
@@ -83,12 +105,36 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
83
105
  private let channelUrlDefaultsKey = "CapacitorUpdater.channelUrl"
84
106
  private let defaultChannelDefaultsKey = "CapacitorUpdater.defaultChannel"
85
107
  private let lastFailedBundleDefaultsKey = "CapacitorUpdater.lastFailedBundle"
108
+ private let lastVersionOsDefaultsKey = "CapacitorUpdater.lastVersionOs"
109
+ private let lastVersionBuildDefaultsKey = "CapacitorUpdater.lastVersionBuild"
110
+ private let lastVersionCodeDefaultsKey = "CapacitorUpdater.lastVersionCode"
111
+ private let osVersionChangedAction = "os_version_changed"
112
+ private let nativeAppVersionChangedAction = "native_app_version_changed"
113
+ private let previewSessionDefaultsKey = "CapacitorUpdater.previewSession"
114
+ private let previewPreviousShakeMenuDefaultsKey = "CapacitorUpdater.previewPreviousShakeMenu"
115
+ private let previewPreviousShakeChannelSelectorDefaultsKey = "CapacitorUpdater.previewPreviousShakeChannelSelector"
116
+ private let previewPreviousNextBundleDefaultsKey = "CapacitorUpdater.previewPreviousNextBundle"
117
+ private let previewPreviousAppIdDefaultsKey = "CapacitorUpdater.previewPreviousAppId"
118
+ private let previewPreviousDefaultChannelDefaultsKey = "CapacitorUpdater.previewPreviousDefaultChannel"
119
+ private let previewPreviousDefaultChannelWasSetDefaultsKey = "CapacitorUpdater.previewPreviousDefaultChannelWasSet"
120
+ private let previewAppIdDefaultsKey = "CapacitorUpdater.previewAppId"
121
+ private let previewPayloadUrlDefaultsKey = "CapacitorUpdater.previewPayloadUrl"
122
+ private let previewNameDefaultsKey = "CapacitorUpdater.previewName"
123
+ private let previewSourceDefaultsKey = "CapacitorUpdater.previewSource"
124
+ private let previewSessionsDefaultsKey = "CapacitorUpdater.previewSessions"
125
+ private let previewSessionAlertPendingDefaultsKey = "CapacitorUpdater.previewSessionAlertPending"
126
+ private let previewDeepLinkScheme = "capgo"
127
+ private let previewDeepLinkRootComponent = "preview"
128
+ private let previewDeepLinkChannelComponent = "channel"
129
+ private let previewDeepLinkBundleComponent = "bundle"
130
+ private let previewPathSeparator = Character(UnicodeScalar(UInt8(47)))
86
131
  // Note: DELAY_CONDITION_PREFERENCES is now defined in DelayUpdateUtils.DELAY_CONDITION_PREFERENCES
87
132
  private var updateUrl = ""
88
133
  private var backgroundTaskID: UIBackgroundTaskIdentifier = UIBackgroundTaskIdentifier.invalid
89
134
  private var currentVersionNative: Version = "0.0.0"
90
135
  private var currentBuildVersion: String = "0"
91
136
  private var autoUpdate = false
137
+ private var autoUpdateMode = CapacitorUpdaterPlugin.autoUpdateModeOff
92
138
  private var appReadyTimeout = 10000
93
139
  private var appReadyCheck: DispatchWorkItem?
94
140
  private var resetWhenUpdate = true
@@ -102,6 +148,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
102
148
  private var autoSplashscreenTimeoutWorkItem: DispatchWorkItem?
103
149
  private var splashscreenLoaderView: UIActivityIndicatorView?
104
150
  private var splashscreenLoaderContainer: UIView?
151
+ private var previewTransitionLoaderView: UIActivityIndicatorView?
152
+ private var previewTransitionLoaderContainer: UIView?
153
+ private var previewTransitionLoaderTimeoutWorkItem: DispatchWorkItem?
154
+ private var previewTransitionLoaderRequested = false
105
155
  private let splashscreenPluginName = "SplashScreen"
106
156
  private let splashscreenRetryDelayMilliseconds = 100
107
157
  private let splashscreenMaxRetries = 20
@@ -118,7 +168,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
118
168
  private let onLaunchDirectUpdateStateLock = NSLock()
119
169
  private var downloadInProgress = false
120
170
  private var downloadStartTime: Date?
121
- private let downloadTimeout: TimeInterval = 3600 // 1 hour timeout
171
+ private let downloadTimeout: TimeInterval = 600 // 10 minute timeout
122
172
 
123
173
  // Lock to ensure cleanup completes before downloads start
124
174
  private let cleanupLock = NSLock()
@@ -127,9 +177,19 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
127
177
  private var persistCustomId = false
128
178
  private var persistModifyUrl = false
129
179
  private var allowManualBundleError = false
180
+ private var allowPreview = false
130
181
  private var keepUrlPathFlagLastValue: Bool?
182
+ private var appHealthTracker: AppHealthTracker?
183
+ private var webViewStatsReporter: WebViewStatsReporter?
131
184
  public var shakeMenuEnabled = false
132
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?
133
193
  let semaphoreReady = DispatchSemaphore(value: 0)
134
194
 
135
195
  private var delayUpdateUtils: DelayUpdateUtils!
@@ -143,6 +203,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
143
203
  } else {
144
204
  logger.error("Failed to get webView for logging")
145
205
  }
206
+ let webViewStatsReporter = WebViewStatsReporter(implementation: implementation)
207
+ self.webViewStatsReporter = webViewStatsReporter
208
+ webViewStatsReporter.install(on: self.bridge?.webView)
146
209
  #if targetEnvironment(simulator)
147
210
  logger.info("::::: SIMULATOR :::::")
148
211
  logger.info("Application directory: \(NSHomeDirectory())")
@@ -162,6 +225,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
162
225
  }
163
226
  persistModifyUrl = getConfig().getBoolean("persistModifyUrl", false)
164
227
  allowManualBundleError = getConfig().getBoolean("allowManualBundleError", false)
228
+ allowPreview = getConfig().getBoolean("allowPreview", false)
165
229
  logger.info("init for device \(self.implementation.deviceID)")
166
230
  guard let versionName = getConfig().getString("version", Bundle.main.versionName) else {
167
231
  logger.error("Cannot get version name")
@@ -181,33 +245,6 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
181
245
  keepUrlPathAfterReload = getConfig().getBoolean("keepUrlPathAfterReload", false)
182
246
  syncKeepUrlPathFlag(enabled: keepUrlPathAfterReload)
183
247
 
184
- // Handle directUpdate configuration - support string values and backward compatibility
185
- if let directUpdateString = getConfig().getString("directUpdate") {
186
- // Handle backward compatibility for boolean true
187
- if directUpdateString == "true" {
188
- directUpdateMode = "always"
189
- directUpdate = true
190
- } else {
191
- directUpdateMode = directUpdateString
192
- directUpdate = directUpdateString == "always" || directUpdateString == "atInstall" || directUpdateString == "onLaunch"
193
- // Validate directUpdate value
194
- if directUpdateString != "false" && directUpdateString != "always" && directUpdateString != "atInstall" && directUpdateString != "onLaunch" {
195
- logger.error("Invalid directUpdate value: \"\(directUpdateString)\". Supported values are: \"false\", \"true\", \"always\", \"atInstall\", \"onLaunch\". Defaulting to \"false\".")
196
- directUpdateMode = "false"
197
- directUpdate = false
198
- }
199
- }
200
- } else {
201
- let directUpdateBool = getConfig().getBoolean("directUpdate", false)
202
- if directUpdateBool {
203
- directUpdateMode = "always" // backward compatibility: true = always
204
- directUpdate = true
205
- } else {
206
- directUpdateMode = "false"
207
- directUpdate = false
208
- }
209
- }
210
-
211
248
  autoSplashscreen = getConfig().getBoolean("autoSplashscreen", false)
212
249
  autoSplashscreenLoader = getConfig().getBoolean("autoSplashscreenLoader", false)
213
250
  let splashscreenTimeoutValue = getConfig().getInt("autoSplashscreenTimeout", 10000)
@@ -217,23 +254,39 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
217
254
  updateUrl = storedUpdateUrl
218
255
  logger.info("Loaded persisted updateUrl")
219
256
  }
220
- autoUpdate = getConfig().getBoolean("autoUpdate", true)
257
+ configureAutoUpdateModeFromConfig()
221
258
  appReadyTimeout = max(1000, getConfig().getInt("appReadyTimeout", 10000)) // Minimum 1 second
222
259
  implementation.timeout = Double(getConfig().getInt("responseTimeout", 20))
223
260
  resetWhenUpdate = getConfig().getBoolean("resetWhenUpdate", true)
224
261
  shakeMenuEnabled = getConfig().getBoolean("shakeMenu", false)
225
262
  shakeChannelSelectorEnabled = getConfig().getBoolean("allowShakeChannelSelector", false)
226
- let periodCheckDelayValue = getConfig().getInt("periodCheckDelay", 0)
227
- if periodCheckDelayValue >= 0 && periodCheckDelayValue > 600 {
228
- periodCheckDelay = 600
229
- } else {
230
- periodCheckDelay = periodCheckDelayValue
231
- }
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
273
+ }
274
+ syncShakeMenuGestureRecognizer()
275
+ periodCheckDelay = Self.normalizedPeriodCheckDelaySeconds(getConfig().getInt("periodCheckDelay", 0))
232
276
 
233
277
  implementation.setPublicKey(getConfig().getString("publicKey") ?? "")
234
278
  implementation.notifyDownloadRaw = notifyDownload
235
279
  implementation.notifyListeners = { [weak self] eventName, data in
236
- self?.notifyListeners(eventName, data: data)
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
+ }
237
290
  }
238
291
  implementation.pluginVersion = self.pluginVersion
239
292
 
@@ -256,6 +309,15 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
256
309
  // crash the app on purpose it should not happen
257
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")
258
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
+ }
259
321
  logger.info("appId \(implementation.appId)")
260
322
  implementation.statsUrl = getConfig().getString("statsUrl", CapacitorUpdaterPlugin.statsUrlDefault)!
261
323
  implementation.channelUrl = getConfig().getString("channelUrl", CapacitorUpdaterPlugin.channelUrlDefault)!
@@ -278,14 +340,24 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
278
340
  implementation.defaultChannel = getConfig().getString("defaultChannel", "")!
279
341
  }
280
342
  self.implementation.autoReset()
343
+ let appHealthTracker = AppHealthTracker(implementation: self.implementation)
344
+ self.appHealthTracker = appHealthTracker
345
+ appHealthTracker.reportPreviousUncleanForegroundExit()
346
+ appHealthTracker.startSession()
281
347
 
282
348
  // Check if app was recently installed/updated BEFORE cleanup updates the stored native build version.
283
349
  self.wasRecentlyInstalledOrUpdated = self.checkIfRecentlyInstalledOrUpdated()
350
+ let nativeBuildVersionChanged = self.hasNativeBuildVersionChanged()
351
+ if nativeBuildVersionChanged {
352
+ self.clearPreviewSessionForNativeBuildChange()
353
+ }
354
+ self.leavePreviewSessionForLaunchURLIfNeeded()
284
355
 
285
356
  if resetWhenUpdate {
286
357
  let didResetCurrentBundle = self.resetCurrentBundleForNativeBuildChangeIfNeeded()
287
358
  self.cleanupObsoleteVersions(didResetCurrentBundle: didResetCurrentBundle)
288
359
  }
360
+ self.reportNativeVersionStatsIfChanged()
289
361
 
290
362
  // Load the server
291
363
  // This is very much swift specific, android does not do that
@@ -298,9 +370,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
298
370
  logger.error("unable to force reload, the plugin might fallback to the builtin version")
299
371
  }
300
372
 
301
- let nc = NotificationCenter.default
302
- nc.addObserver(self, selector: #selector(appMovedToBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
303
- nc.addObserver(self, selector: #selector(appMovedToForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
373
+ self.registerNotificationObservers()
304
374
 
305
375
  // Check for 'kill' delay condition on app launch
306
376
  // This handles cases where the app was killed (willTerminateNotification is not reliable for system kills)
@@ -308,6 +378,47 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
308
378
 
309
379
  self.appMovedToForeground()
310
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
+ )
311
422
  }
312
423
 
313
424
  private func syncKeepUrlPathFlag(enabled: Bool) {
@@ -357,6 +468,22 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
357
468
  }
358
469
  }
359
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
+
360
487
  private func initialLoad() -> Bool {
361
488
  guard let bridge = self.bridge else { return false }
362
489
  if keepUrlPathAfterReload {
@@ -409,6 +536,86 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
409
536
  return previous != "0" && self.currentBuildVersion != previous
410
537
  }
411
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
+
412
619
  @discardableResult
413
620
  func resetCurrentBundleForNativeBuildChangeIfNeeded() -> Bool {
414
621
  let previous = self.storedNativeBuildVersion()
@@ -522,11 +729,88 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
522
729
  logger.info("Cleanup finished, proceeding with download")
523
730
  }
524
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
+
525
804
  @objc func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
526
805
  let bundleInfo = bundle ?? self.implementation.getBundleInfo(id: id)
527
- 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)
528
810
  if percent == 100 {
529
- self.notifyListeners("downloadComplete", data: ["bundle": bundleInfo.toJSON()])
811
+ var downloadCompletePayload: JSObject = [:]
812
+ downloadCompletePayload["bundle"] = bundlePayload(bundleInfo)
813
+ self.notifyListenersOnMain("downloadComplete", data: downloadCompletePayload)
530
814
  self.implementation.sendStats(action: "download_complete", versionName: bundleInfo.getVersionName())
531
815
  } else if percent.isMultiple(of: 10) || ignoreMultipleOfTen {
532
816
  self.implementation.sendStats(action: "download_\(percent)", versionName: bundleInfo.getVersionName())
@@ -606,6 +890,299 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
606
890
  call.resolve(["version": self.pluginVersion])
607
891
  }
608
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
+
609
1186
  @objc func download(_ call: CAPPluginCall) {
610
1187
  guard let urlString = call.getString("url") else {
611
1188
  logger.error("Download called without url")
@@ -619,64 +1196,30 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
619
1196
  }
620
1197
 
621
1198
  let sessionKey = call.getString("sessionKey", "")
622
- var checksum = call.getString("checksum", "")
1199
+ let checksum = call.getString("checksum", "")
623
1200
  let manifestArray = call.getArray("manifest")
624
- let url = URL(string: urlString)
625
- logger.info("Downloading \(String(describing: url))")
626
- DispatchQueue.global(qos: .background).async {
1201
+ logger.info("Downloading \(urlString)")
1202
+ self.saveCallForAsyncHandling(call)
1203
+ self.runBackgroundDownloadWork {
627
1204
  do {
628
- let next: BundleInfo
629
- if let manifestArray = manifestArray {
630
- // Convert JSArray to [ManifestEntry]
631
- var manifestEntries: [ManifestEntry] = []
632
- for item in manifestArray {
633
- if let manifestDict = item as? [String: Any] {
634
- let entry = ManifestEntry(
635
- file_name: manifestDict["file_name"] as? String,
636
- file_hash: manifestDict["file_hash"] as? String,
637
- download_url: manifestDict["download_url"] as? String
638
- )
639
- manifestEntries.append(entry)
640
- }
641
- }
642
- next = try self.implementation.downloadManifest(manifest: manifestEntries, version: version, sessionKey: sessionKey)
643
- } else {
644
- next = try self.implementation.download(url: url!, version: version, sessionKey: sessionKey)
645
- }
646
- // If public key is present but no checksum provided, refuse installation
647
- if self.implementation.publicKey != "" && checksum == "" {
648
- self.logger.error("Public key present but no checksum provided")
649
- self.implementation.sendStats(action: "checksum_required", versionName: next.getVersionName())
650
- let id = next.getId()
651
- let resDel = self.implementation.delete(id: id)
652
- if !resDel {
653
- self.logger.error("Delete failed, id \(id) doesn't exist")
654
- }
655
- throw ObjectSavableError.checksum
656
- }
657
-
658
- checksum = try CryptoCipher.decryptChecksum(checksum: checksum, publicKey: self.implementation.publicKey)
659
- CryptoCipher.logChecksumInfo(label: "Bundle checksum", hexChecksum: next.getChecksum())
660
- CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: checksum)
661
- if (checksum != "" || self.implementation.publicKey != "") && next.getChecksum() != checksum {
662
- self.logger.error("Error checksum \(next.getChecksum()) \(checksum)")
663
- self.implementation.sendStats(action: "checksum_fail", versionName: next.getVersionName())
664
- let id = next.getId()
665
- let resDel = self.implementation.delete(id: id)
666
- if !resDel {
667
- self.logger.error("Delete failed, id \(id) doesn't exist")
668
- }
669
- throw ObjectSavableError.checksum
670
- } else {
671
- self.logger.info("Good checksum \(next.getChecksum()) \(checksum)")
672
- }
673
- self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
674
- call.resolve(next.toJSON())
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())
675
1216
  } catch {
676
- self.logger.error("Failed to download from: \(String(describing: url)) \(error.localizedDescription)")
677
- self.notifyListeners("downloadFailed", data: ["version": version])
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)
678
1221
  self.implementation.sendStats(action: "download_fail")
679
- call.reject("Failed to download from: \(url!) - \(error.localizedDescription)")
1222
+ self.rejectCall(call, message: "Failed to download from: \(urlString) - \(error.localizedDescription)")
680
1223
  }
681
1224
  }
682
1225
  }
@@ -768,11 +1311,37 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
768
1311
  }
769
1312
  }
770
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
+
771
1337
  @objc func reload(_ call: CAPPluginCall) {
772
1338
  let current: BundleInfo = self.implementation.getCurrentBundle()
773
1339
  let next: BundleInfo? = self.implementation.getNextBundle()
774
1340
 
775
- if let next = next, !next.isErrorStatus(), next.getId() != current.getId() {
1341
+ if !self.isPreviewSessionStateActive(),
1342
+ let next = next,
1343
+ !next.isErrorStatus(),
1344
+ next.getId() != current.getId() {
776
1345
  let previousState = self.implementation.captureResetState()
777
1346
  let previousBundleName = self.implementation.getCurrentBundle().getVersionName()
778
1347
  logger.info("Applying pending bundle before reload: \(next.toString())")
@@ -791,6 +1360,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
791
1360
  }
792
1361
  self.notifyBundleSet(next)
793
1362
  _ = self.implementation.setNextBundle(next: Optional<String>.none)
1363
+ self.showPreviewSessionNoticeIfNeeded()
794
1364
  call.resolve()
795
1365
  return
796
1366
  }
@@ -802,6 +1372,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
802
1372
  }
803
1373
 
804
1374
  if self._reload() {
1375
+ self.showPreviewSessionNoticeIfNeeded()
805
1376
  call.resolve()
806
1377
  } else {
807
1378
  logger.error("Reload failed")
@@ -809,14 +1380,36 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
809
1380
  }
810
1381
  }
811
1382
 
812
- @objc func next(_ call: CAPPluginCall) {
813
- guard let id = call.getString("id") else {
814
- logger.error("Next called without id")
815
- call.reject("Next called without id")
816
- return
817
- }
818
- logger.info("Setting next active id \(id)")
819
- if !self.implementation.setNextBundle(next: id) {
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) {
820
1413
  logger.error("Set next version failed. id \(id) does not exist.")
821
1414
  call.reject("Set next version failed. id \(id) does not exist.")
822
1415
  } else {
@@ -835,11 +1428,851 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
835
1428
  if !res {
836
1429
  logger.info("Bundle successfully set to: \(id) ")
837
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()
838
1441
  } else if !self._reload() {
839
1442
  call.reject("Reload failed after setting bundle \(id)")
840
1443
  } else {
841
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 {
842
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
2067
+ }
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
2074
+ }
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)
2091
+ }
2092
+
2093
+ return url.path
2094
+ }
2095
+
2096
+ private func normalizedPreviewPath(_ components: [String]) -> String {
2097
+ let separator = String(self.previewPathSeparator)
2098
+ return separator + components.filter { !$0.isEmpty }.joined(separator: separator)
2099
+ }
2100
+
2101
+ private func previewDeepLinkPath(_ leafComponent: String) -> String {
2102
+ self.normalizedPreviewPath([self.previewDeepLinkRootComponent, leafComponent])
2103
+ }
2104
+
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)
2109
+ }
2110
+
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 {
2118
+ return
2119
+ }
2120
+
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")
2130
+ }
2131
+ }
2132
+ }
2133
+
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")
2152
+ }
2153
+
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)")
2162
+ }
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")
2175
+ }
2176
+ guard payload.url != nil || payload.manifest?.isEmpty == false else {
2177
+ throw makePreviewError("Preview payload is missing download information")
2178
+ }
2179
+
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()
2184
+ }
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
2206
+ }
2207
+ }
2208
+
2209
+ private func clearPreviewSessionForNativeBuildChange() {
2210
+ guard self.previewSessionEnabled || self.implementation.getPreviewFallbackBundle() != nil || !self.previewSessions().isEmpty else {
2211
+ return
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()
2229
+ }
2230
+
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)
2235
+ return
2236
+ }
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)
2240
+ }
2241
+ }
2242
+
2243
+ private func showPreviewSessionNoticeIfNeeded() {
2244
+ guard self.previewSessionEnabled && self.previewSessionAlertPending else {
2245
+ return
2246
+ }
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
+ }
843
2276
  }
844
2277
  }
845
2278
 
@@ -902,45 +2335,132 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
902
2335
 
903
2336
  @objc func getLatest(_ call: CAPPluginCall) {
904
2337
  let channel = call.getString("channel")
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)
905
2346
  runGetLatestWork {
906
- let res = self.implementation.getLatest(url: URL(string: self.updateUrl)!, channel: channel)
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
+ }
907
2355
  if let error = res.error, !error.isEmpty {
908
2356
  let responseKind = self.updateResponseKind(kind: res.kind)
909
2357
  res.kind = responseKind
2358
+ self.notifyBreakingEventsIfNeeded(response: res, version: res.version)
910
2359
  if responseKind == "failed" {
911
- call.reject(error)
2360
+ self.rejectCall(call, message: error)
912
2361
  } else {
913
2362
  if res.version.isEmpty {
914
2363
  res.version = self.implementation.getCurrentBundle().getVersionName()
915
2364
  }
916
- call.resolve(res.toDict())
2365
+ self.resolveCall(call, data: res.toDict())
917
2366
  }
918
2367
  } else if let kind = res.kind, !kind.isEmpty {
919
2368
  let responseKind = self.updateResponseKind(kind: kind)
920
2369
  res.kind = responseKind
2370
+ self.notifyBreakingEventsIfNeeded(response: res, version: res.version)
921
2371
  if responseKind != "failed" {
922
2372
  if res.version.isEmpty {
923
2373
  res.version = self.implementation.getCurrentBundle().getVersionName()
924
2374
  }
925
- call.resolve(res.toDict())
2375
+ self.resolveCall(call, data: res.toDict())
926
2376
  } else {
927
- call.reject(res.message ?? "server did not provide a message")
2377
+ self.rejectCall(call, message: res.message ?? "server did not provide a message")
928
2378
  }
929
2379
  } else if let message = res.message, !message.isEmpty {
930
- call.reject(message)
2380
+ self.notifyBreakingEventsIfNeeded(response: res, version: res.version)
2381
+ self.rejectCall(call, message: message)
931
2382
  } else {
932
- call.resolve(res.toDict())
2383
+ self.resolveCall(call, data: res.toDict())
933
2384
  }
934
2385
  }
935
2386
  }
936
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
+
937
2456
  @objc func unsetChannel(_ call: CAPPluginCall) {
938
2457
  let triggerAutoUpdate = call.getBool("triggerAutoUpdate", false)
939
- DispatchQueue.global(qos: .background).async {
2458
+ self.saveCallForAsyncHandling(call)
2459
+ DispatchQueue.global(qos: .utility).async {
940
2460
  let configDefaultChannel = self.getConfig().getString("defaultChannel", "")!
941
2461
  let res = self.implementation.unsetChannel(defaultChannelKey: self.defaultChannelDefaultsKey, configDefaultChannel: configDefaultChannel)
942
2462
  if res.error != "" {
943
- call.reject(res.error, "UNSETCHANNEL_FAILED", nil, [
2463
+ self.rejectCall(call, message: res.error, code: "UNSETCHANNEL_FAILED", data: [
944
2464
  "message": res.error,
945
2465
  "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
946
2466
  ])
@@ -954,7 +2474,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
954
2474
  self.logger.info("Download already in progress, skipping duplicate download request")
955
2475
  }
956
2476
  }
957
- call.resolve(res.toDict())
2477
+ self.resolveCall(call, data: res.toDict())
958
2478
  }
959
2479
  }
960
2480
  }
@@ -969,17 +2489,24 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
969
2489
  return
970
2490
  }
971
2491
  let triggerAutoUpdate = call.getBool("triggerAutoUpdate") ?? false
972
- DispatchQueue.global(qos: .background).async {
973
- 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
+ )
974
2501
  if res.error != "" {
975
2502
  // Fire channelPrivate event if channel doesn't allow self-assignment
976
2503
  if res.error.contains("cannot_update_via_private_channel") || res.error.contains("channel_self_set_not_allowed") {
977
- self.notifyListeners("channelPrivate", data: [
2504
+ self.notifyListenersOnMain("channelPrivate", data: [
978
2505
  "channel": channel,
979
2506
  "message": res.error
980
2507
  ])
981
2508
  }
982
- call.reject(res.error, "SETCHANNEL_FAILED", nil, [
2509
+ self.rejectCall(call, message: res.error, code: "SETCHANNEL_FAILED", data: [
983
2510
  "message": res.error,
984
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"
985
2512
  ])
@@ -993,35 +2520,39 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
993
2520
  self.logger.info("Download already in progress, skipping duplicate download request")
994
2521
  }
995
2522
  }
996
- call.resolve(res.toDict())
2523
+ self.resolveCall(call, data: res.toDict())
997
2524
  }
998
2525
  }
999
2526
  }
1000
2527
 
1001
2528
  @objc func getChannel(_ call: CAPPluginCall) {
1002
- DispatchQueue.global(qos: .background).async {
1003
- let res = self.implementation.getChannel()
2529
+ self.saveCallForAsyncHandling(call)
2530
+ DispatchQueue.global(qos: .utility).async {
2531
+ let res = self.implementation.getChannel(defaultChannelKey: self.defaultChannelDefaultsKey)
1004
2532
  if res.error != "" {
1005
- call.reject(res.error, "GETCHANNEL_FAILED", nil, [
2533
+ self.rejectCall(call, message: res.error, code: "GETCHANNEL_FAILED", data: [
1006
2534
  "message": res.error,
1007
2535
  "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
1008
2536
  ])
1009
2537
  } else {
1010
- call.resolve(res.toDict())
2538
+ self.resolveCall(call, data: res.toDict())
1011
2539
  }
1012
2540
  }
1013
2541
  }
1014
2542
 
1015
2543
  @objc func listChannels(_ call: CAPPluginCall) {
1016
- DispatchQueue.global(qos: .background).async {
2544
+ self.saveCallForAsyncHandling(call)
2545
+ DispatchQueue.global(qos: .utility).async {
1017
2546
  let res = self.implementation.listChannels()
1018
2547
  if res.error != "" {
1019
- call.reject(res.error, "LISTCHANNELS_FAILED", nil, [
2548
+ self.rejectCall(call, message: res.error, code: "LISTCHANNELS_FAILED", data: [
1020
2549
  "message": res.error,
1021
2550
  "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
1022
2551
  ])
1023
2552
  } else {
1024
- call.resolve(res.toDict())
2553
+ var payload: JSObject = [:]
2554
+ payload["channels"] = res.channels
2555
+ self.resolveCall(call, data: payload)
1025
2556
  }
1026
2557
  }
1027
2558
  }
@@ -1148,6 +2679,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1148
2679
  let bundle = self.implementation.getCurrentBundle()
1149
2680
  self.implementation.setSuccess(bundle: bundle, autoDeletePrevious: self.autoDeletePrevious)
1150
2681
  logger.info("Current bundle loaded successfully. [notifyAppReady was called] \(bundle.toString())")
2682
+ self.clearIncomingPreviewTransition()
2683
+ self.hidePreviewTransitionLoader(reason: "notify-app-ready")
2684
+
1151
2685
  call.resolve(["bundle": bundle.toJSON()])
1152
2686
  }
1153
2687
 
@@ -1197,6 +2731,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1197
2731
  // Note: _checkCancelDelay method has been moved to DelayUpdateUtils class
1198
2732
 
1199
2733
  private func _isAutoUpdateEnabled() -> Bool {
2734
+ if self.isPreviewSessionStateActive() {
2735
+ return false
2736
+ }
1200
2737
  let instanceDescriptor = (self.bridge?.viewController as? CAPBridgeViewController)?.instanceDescriptor()
1201
2738
  if instanceDescriptor?.serverURL != nil {
1202
2739
  logger.warn("AutoUpdate is automatic disabled when serverUrl is set.")
@@ -1234,10 +2771,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1234
2771
  logger.info("Built-in bundle is active. We skip the check for notifyAppReady.")
1235
2772
  return
1236
2773
  }
2774
+ if self.isPreviewSessionStateActive() {
2775
+ logger.info("Preview session is active. We skip the check for notifyAppReady.")
2776
+ return
2777
+ }
1237
2778
 
1238
2779
  logger.info("Current bundle is: \(current.toString())")
1239
2780
 
1240
- if BundleStatus.SUCCESS.localizedString != current.getStatus() {
2781
+ if BundleStatus.SUCCESS.storedValue != current.getStatus() {
1241
2782
  logger.error("notifyAppReady was not called, roll back current bundle: \(current.toString())")
1242
2783
  logger.error("Did you forget to call 'notifyAppReady()' in your Capacitor App code?")
1243
2784
  self.notifyListeners("updateFailed", data: [
@@ -1286,6 +2827,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1286
2827
  if self.autoSplashscreen {
1287
2828
  self.hideSplashscreen()
1288
2829
  }
2830
+ self.hidePreviewTransitionLoader(reason: "app-ready")
1289
2831
  }
1290
2832
  }
1291
2833
 
@@ -1453,6 +2995,51 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1453
2995
  }
1454
2996
  }
1455
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
3011
+ } else {
3012
+ indicatorStyle = .whiteLarge
3013
+ }
3014
+
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
+ ])
3041
+ }
3042
+
1456
3043
  private func addSplashscreenLoaderIfNeeded() {
1457
3044
  guard self.autoSplashscreenLoader else {
1458
3045
  return
@@ -1467,40 +3054,20 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1467
3054
  return
1468
3055
  }
1469
3056
 
1470
- let container = UIView()
1471
- container.translatesAutoresizingMaskIntoConstraints = false
1472
- container.backgroundColor = UIColor.clear
1473
- container.isUserInteractionEnabled = false
1474
-
1475
- let indicatorStyle: UIActivityIndicatorView.Style
3057
+ let indicatorColor: UIColor?
1476
3058
  if #available(iOS 13.0, *) {
1477
- indicatorStyle = .large
3059
+ indicatorColor = UIColor.label
1478
3060
  } else {
1479
- indicatorStyle = .whiteLarge
1480
- }
1481
-
1482
- let indicator = UIActivityIndicatorView(style: indicatorStyle)
1483
- indicator.translatesAutoresizingMaskIntoConstraints = false
1484
- indicator.hidesWhenStopped = false
1485
- if #available(iOS 13.0, *) {
1486
- indicator.color = UIColor.label
3061
+ indicatorColor = nil
1487
3062
  }
1488
- indicator.startAnimating()
1489
-
1490
- container.addSubview(indicator)
1491
- rootView.addSubview(container)
1492
-
1493
- NSLayoutConstraint.activate([
1494
- container.leadingAnchor.constraint(equalTo: rootView.leadingAnchor),
1495
- container.trailingAnchor.constraint(equalTo: rootView.trailingAnchor),
1496
- container.topAnchor.constraint(equalTo: rootView.topAnchor),
1497
- container.bottomAnchor.constraint(equalTo: rootView.bottomAnchor),
1498
- indicator.centerXAnchor.constraint(equalTo: container.centerXAnchor),
1499
- indicator.centerYAnchor.constraint(equalTo: container.centerYAnchor)
1500
- ])
1501
-
1502
- self.splashscreenLoaderContainer = container
1503
- 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
1504
3071
  }
1505
3072
 
1506
3073
  if Thread.isMainThread {
@@ -1529,6 +3096,97 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1529
3096
  }
1530
3097
  }
1531
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
+
1532
3190
  private func scheduleSplashscreenTimeout() {
1533
3191
  guard self.autoSplashscreenTimeout > 0 else {
1534
3192
  return
@@ -1588,6 +3246,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1588
3246
  }
1589
3247
 
1590
3248
  private func shouldUseDirectUpdate() -> Bool {
3249
+ if !self.autoUpdate || self.autoUpdateMode == Self.autoUpdateModeOnlyDownload {
3250
+ return false
3251
+ }
1591
3252
  if self.autoSplashscreenTimedOut {
1592
3253
  return false
1593
3254
  }
@@ -1614,10 +3275,132 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1614
3275
  }
1615
3276
  }
1616
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
+
1617
3393
  static func shouldConsumeOnLaunchDirectUpdate(directUpdateMode: String, plannedDirectUpdate: Bool) -> Bool {
1618
3394
  plannedDirectUpdate && directUpdateMode == "onLaunch"
1619
3395
  }
1620
3396
 
3397
+ static func normalizedPeriodCheckDelaySeconds(_ value: Int) -> Int {
3398
+ guard value > 0 else {
3399
+ return 0
3400
+ }
3401
+ return max(600, value)
3402
+ }
3403
+
1621
3404
  private func getOnLaunchDirectUpdateUsed() -> Bool {
1622
3405
  self.onLaunchDirectUpdateStateLock.lock()
1623
3406
  defer { self.onLaunchDirectUpdateStateLock.unlock() }
@@ -1640,6 +3423,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1640
3423
 
1641
3424
  func configureDirectUpdateModeForTesting(_ directUpdateMode: String, onLaunchDirectUpdateUsed: Bool = false) {
1642
3425
  self.directUpdateMode = directUpdateMode
3426
+ self.autoUpdateMode = Self.autoUpdateModeForLegacyDirectUpdateMode(directUpdateMode)
3427
+ self.autoUpdate = Self.isAutoUpdateModeEnabled(self.autoUpdateMode)
3428
+ self.directUpdate = Self.isDirectUpdateMode(self.directUpdateMode)
1643
3429
  self.setOnLaunchDirectUpdateUsed(onLaunchDirectUpdateUsed)
1644
3430
  }
1645
3431
 
@@ -1647,6 +3433,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1647
3433
  self.updateUrl = updateUrl
1648
3434
  }
1649
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
+
1650
3443
  func setCurrentBuildVersionForTesting(_ currentBuildVersion: String) {
1651
3444
  self.currentBuildVersion = currentBuildVersion
1652
3445
  }
@@ -1668,13 +3461,32 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1668
3461
  self.notifyListeners("majorAvailable", data: payload)
1669
3462
  }
1670
3463
 
1671
- private func updateResponseKind(kind: String?) -> String {
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 {
1672
3480
  if let kind, ["up_to_date", "blocked", "failed"].contains(kind) {
1673
3481
  return kind
1674
3482
  }
1675
3483
  return "failed"
1676
3484
  }
1677
3485
 
3486
+ private func updateResponseKind(kind: String?) -> String {
3487
+ Self.normalizedUpdateResponseKind(kind: kind)
3488
+ }
3489
+
1678
3490
  private func endBackgroundDownloadAfterLatestError(
1679
3491
  backendError: String,
1680
3492
  res: AppVersion,
@@ -1694,6 +3506,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1694
3506
  "version": latestVersionName,
1695
3507
  "bundle": current.toJSON()
1696
3508
  ])
3509
+ self.notifyBreakingEventsIfNeeded(response: res, version: res.version)
1697
3510
 
1698
3511
  if responseKind == "up_to_date" {
1699
3512
  self.logger.info("No new version available")
@@ -1722,7 +3535,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1722
3535
  plannedDirectUpdate: Bool = false,
1723
3536
  failureAction: String = "download_fail",
1724
3537
  failureEvent: String = "downloadFailed",
1725
- sendStats: Bool = true
3538
+ sendStats: Bool = true,
3539
+ notifyNoNeedUpdate: Bool = true
1726
3540
  ) {
1727
3541
  // Clear download in progress flag - this is called at the end of every download attempt
1728
3542
  // whether it succeeds, fails, or is skipped (e.g., already up to date)
@@ -1739,7 +3553,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1739
3553
  }
1740
3554
  self.notifyListeners(failureEvent, data: ["version": latestVersionName])
1741
3555
  }
1742
- self.notifyListeners("noNeedUpdate", data: ["bundle": current.toJSON()])
3556
+ if notifyNoNeedUpdate {
3557
+ self.notifyListeners("noNeedUpdate", data: ["bundle": current.toJSON()])
3558
+ }
1743
3559
  self.sendReadyToJs(current: current, msg: msg)
1744
3560
  logger.info("endBackGroundTaskWithNotif \(msg) current: \(current.getVersionName()) latestVersionName: \(latestVersionName)")
1745
3561
  self.endBackGroundTask()
@@ -1767,8 +3583,31 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1767
3583
  return true
1768
3584
  }
1769
3585
 
3586
+ private func clearDownloadInProgressState() {
3587
+ downloadLock.lock()
3588
+ defer { downloadLock.unlock() }
3589
+ downloadInProgress = false
3590
+ downloadStartTime = nil
3591
+ }
3592
+
1770
3593
  func runBackgroundDownloadWork(_ work: @escaping () -> Void) {
1771
- DispatchQueue.global(qos: .background).async(execute: work)
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
+ }
1772
3611
  }
1773
3612
 
1774
3613
  func runGetLatestWork(_ work: @escaping () -> Void) {
@@ -1776,6 +3615,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1776
3615
  }
1777
3616
 
1778
3617
  func backgroundDownload() {
3618
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3619
+ return
3620
+ }
1779
3621
  // Set download in progress flag (thread-safe)
1780
3622
  downloadLock.lock()
1781
3623
  downloadInProgress = true
@@ -1783,7 +3625,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1783
3625
  downloadLock.unlock()
1784
3626
 
1785
3627
  let plannedDirectUpdate = self.shouldUseDirectUpdate()
1786
- 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
+ }
1787
3636
  guard let url = URL(string: self.updateUrl) else {
1788
3637
  logger.error("Error no url or wrong format")
1789
3638
  // Clear the flag if we return early
@@ -1797,13 +3646,19 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1797
3646
  self.runBackgroundDownloadWork {
1798
3647
  // Wait for cleanup to complete before starting download
1799
3648
  self.waitForCleanupIfNeeded()
1800
- self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "Finish Download Tasks") {
1801
- // End the task if time expires.
1802
- self.endBackGroundTask()
3649
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3650
+ self.clearDownloadInProgressState()
3651
+ return
1803
3652
  }
3653
+ self.beginDownloadBackgroundTask()
1804
3654
  self.logger.info("Check for update via \(self.updateUrl)")
1805
3655
  let res = self.implementation.getLatest(url: url, channel: nil)
1806
3656
  let current = self.implementation.getCurrentBundle()
3657
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3658
+ self.clearDownloadInProgressState()
3659
+ self.endBackGroundTask()
3660
+ return
3661
+ }
1807
3662
 
1808
3663
  // Handle network errors and other failures first
1809
3664
  let backendError = res.error ?? ""
@@ -1830,7 +3685,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1830
3685
  error: false,
1831
3686
  plannedDirectUpdate: plannedDirectUpdate
1832
3687
  )
1833
- } else {
3688
+ } else if self.shouldAutoSetNextBundle() {
1834
3689
  if plannedDirectUpdate && !directUpdateAllowed {
1835
3690
  self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will apply later.")
1836
3691
  }
@@ -1843,21 +3698,37 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1843
3698
  error: false,
1844
3699
  plannedDirectUpdate: plannedDirectUpdate
1845
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
+ )
1846
3716
  }
1847
3717
  return
1848
3718
  }
1849
3719
  let sessionKey = res.sessionKey ?? ""
3720
+ let latestVersionName = res.version
1850
3721
  guard let downloadUrl = URL(string: res.url) else {
3722
+ self.notifyBreakingEventsIfNeeded(response: res, version: latestVersionName)
1851
3723
  self.logger.error("Error no url or wrong format")
1852
3724
  self.endBackGroundTaskWithNotif(
1853
3725
  msg: "Error no url or wrong format",
1854
- latestVersionName: res.version,
3726
+ latestVersionName: latestVersionName,
1855
3727
  current: current,
1856
3728
  plannedDirectUpdate: plannedDirectUpdate
1857
3729
  )
1858
3730
  return
1859
3731
  }
1860
- let latestVersionName = res.version
1861
3732
  if latestVersionName != "" && current.getVersionName() != latestVersionName {
1862
3733
  do {
1863
3734
  self.logger.info("New bundle: \(latestVersionName) found. Current is: \(current.getVersionName()). \(messageUpdate)")
@@ -1899,6 +3770,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1899
3770
  )
1900
3771
  return
1901
3772
  }
3773
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3774
+ self.clearDownloadInProgressState()
3775
+ self.endBackGroundTask()
3776
+ return
3777
+ }
1902
3778
  res.checksum = try CryptoCipher.decryptChecksum(checksum: res.checksum, publicKey: self.implementation.publicKey)
1903
3779
  CryptoCipher.logChecksumInfo(label: "Bundle checksum", hexChecksum: next.getChecksum())
1904
3780
  CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: res.checksum)
@@ -1918,6 +3794,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1918
3794
  )
1919
3795
  return
1920
3796
  }
3797
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3798
+ self.clearDownloadInProgressState()
3799
+ self.endBackGroundTask()
3800
+ return
3801
+ }
1921
3802
  let directUpdateAllowed = plannedDirectUpdate && !self.autoSplashscreenTimedOut
1922
3803
  if directUpdateAllowed {
1923
3804
  let delayUpdatePreferences = UserDefaults.standard.string(forKey: DelayUpdateUtils.DELAY_CONDITION_PREFERENCES) ?? "[]"
@@ -1937,7 +3818,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1937
3818
  )
1938
3819
  return
1939
3820
  }
1940
- if self.implementation.set(bundle: next) && self._reload() {
3821
+ if self.applyDownloadedBundleForDirectUpdate(next) {
1941
3822
  self.notifyBundleSet(next)
1942
3823
  self.endBackGroundTaskWithNotif(
1943
3824
  msg: "update installed",
@@ -1947,14 +3828,17 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1947
3828
  plannedDirectUpdate: plannedDirectUpdate
1948
3829
  )
1949
3830
  } else {
3831
+ _ = self.implementation.setNextBundle(next: next.getId())
3832
+ self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
1950
3833
  self.endBackGroundTaskWithNotif(
1951
- msg: "Update install failed",
3834
+ msg: "Direct update reload failed, update will install next background",
1952
3835
  latestVersionName: latestVersionName,
1953
- current: next,
3836
+ current: self.implementation.getCurrentBundle(),
3837
+ error: false,
1954
3838
  plannedDirectUpdate: plannedDirectUpdate
1955
3839
  )
1956
3840
  }
1957
- } else {
3841
+ } else if self.shouldAutoSetNextBundle() {
1958
3842
  if plannedDirectUpdate && !directUpdateAllowed {
1959
3843
  self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will install on next app background.")
1960
3844
  }
@@ -1967,6 +3851,17 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1967
3851
  error: false,
1968
3852
  plannedDirectUpdate: plannedDirectUpdate
1969
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
+ )
1970
3865
  }
1971
3866
  return
1972
3867
  } catch {
@@ -1995,6 +3890,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1995
3890
  }
1996
3891
 
1997
3892
  private func installNext() {
3893
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3894
+ return
3895
+ }
1998
3896
  let delayUpdatePreferences = UserDefaults.standard.string(forKey: DelayUpdateUtils.DELAY_CONDITION_PREFERENCES) ?? "[]"
1999
3897
  let delayConditionList: [DelayCondition] = fromJsonArr(json: delayUpdatePreferences).map { obj -> DelayCondition in
2000
3898
  let kind: String = obj.value(forKey: "kind") as! String
@@ -2039,6 +3937,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2039
3937
  }
2040
3938
 
2041
3939
  @objc func appMovedToForeground() {
3940
+ appHealthTracker?.markForeground(true)
2042
3941
  let current: BundleInfo = self.implementation.getCurrentBundle()
2043
3942
  self.implementation.sendStats(action: "app_moved_to_foreground", versionName: current.getVersionName())
2044
3943
  self.delayUpdateUtils.checkCancelDelay(source: .foreground)
@@ -2084,9 +3983,15 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2084
3983
  timer.invalidate()
2085
3984
  return
2086
3985
  }
2087
- DispatchQueue.global(qos: .background).async {
3986
+ DispatchQueue.global(qos: .utility).async {
3987
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3988
+ return
3989
+ }
2088
3990
  let res = self.implementation.getLatest(url: url, channel: nil)
2089
3991
  let current = self.implementation.getCurrentBundle()
3992
+ if self.shouldBlockAutoUpdateForPreviewSession() {
3993
+ return
3994
+ }
2090
3995
 
2091
3996
  if res.version != current.getVersionName() {
2092
3997
  self.logger.info("New version found: \(res.version)")
@@ -2105,6 +4010,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2105
4010
  @objc func appMovedToBackground() {
2106
4011
  // Reset timeout flag at start of each background cycle
2107
4012
  self.autoSplashscreenTimedOut = false
4013
+ appHealthTracker?.markForeground(false)
2108
4014
 
2109
4015
  let current: BundleInfo = self.implementation.getCurrentBundle()
2110
4016
  self.implementation.sendStats(action: "app_moved_to_background", versionName: current.getVersionName())
@@ -2171,13 +4077,15 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2171
4077
  }
2172
4078
 
2173
4079
  self.shakeMenuEnabled = enabled
2174
- logger.info("Shake menu \(enabled ? "enabled" : "disabled")")
4080
+ self.syncShakeMenuGestureRecognizer()
4081
+ logger.info("Shake menu \(enabled ? "enabled" : "disabled") with \(self.shakeMenuGesture) gesture")
2175
4082
  call.resolve()
2176
4083
  }
2177
4084
 
2178
4085
  @objc func isShakeMenuEnabled(_ call: CAPPluginCall) {
2179
4086
  call.resolve([
2180
- "enabled": self.shakeMenuEnabled
4087
+ "enabled": self.shakeMenuEnabled,
4088
+ "gesture": self.shakeMenuGesture
2181
4089
  ])
2182
4090
  }
2183
4091
 
@@ -2189,6 +4097,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2189
4097
  }
2190
4098
 
2191
4099
  self.shakeChannelSelectorEnabled = enabled
4100
+ self.syncShakeMenuGestureRecognizer()
2192
4101
  logger.info("Shake channel selector \(enabled ? "enabled" : "disabled")")
2193
4102
  call.resolve()
2194
4103
  }
@@ -2236,29 +4145,30 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2236
4145
 
2237
4146
  logger.info("Getting App Store update info for \(bundleId) in country \(country)")
2238
4147
 
4148
+ self.saveCallForAsyncHandling(call)
2239
4149
  DispatchQueue.global(qos: .background).async {
2240
4150
  let urlString = "https://itunes.apple.com/lookup?bundleId=\(bundleId)&country=\(country)"
2241
4151
  guard let url = URL(string: urlString) else {
2242
- call.reject("Invalid URL for App Store lookup")
4152
+ self.rejectCall(call, message: "Invalid URL for App Store lookup")
2243
4153
  return
2244
4154
  }
2245
4155
 
2246
4156
  let task = URLSession.shared.dataTask(with: url) { data, _, error in
2247
4157
  if let error = error {
2248
4158
  self.logger.error("App Store lookup failed: \(error.localizedDescription)")
2249
- call.reject("App Store lookup failed: \(error.localizedDescription)")
4159
+ self.rejectCall(call, message: "App Store lookup failed: \(error.localizedDescription)")
2250
4160
  return
2251
4161
  }
2252
4162
 
2253
4163
  guard let data = data else {
2254
- call.reject("No data received from App Store")
4164
+ self.rejectCall(call, message: "No data received from App Store")
2255
4165
  return
2256
4166
  }
2257
4167
 
2258
4168
  do {
2259
4169
  guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
2260
4170
  let resultCount = json["resultCount"] as? Int else {
2261
- call.reject("Invalid response from App Store")
4171
+ self.rejectCall(call, message: "Invalid response from App Store")
2262
4172
  return
2263
4173
  }
2264
4174
 
@@ -2315,10 +4225,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2315
4225
  self.logger.info("App not found in App Store for bundleId: \(bundleId)")
2316
4226
  }
2317
4227
 
2318
- call.resolve(result)
4228
+ self.resolveCall(call, data: result)
2319
4229
  } catch {
2320
4230
  self.logger.error("Failed to parse App Store response: \(error.localizedDescription)")
2321
- call.reject("Failed to parse App Store response: \(error.localizedDescription)")
4231
+ self.rejectCall(call, message: "Failed to parse App Store response: \(error.localizedDescription)")
2322
4232
  }
2323
4233
  }
2324
4234
  task.resume()
@@ -2327,37 +4237,48 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2327
4237
 
2328
4238
  @objc func openAppStore(_ call: CAPPluginCall) {
2329
4239
  let appId = call.getString("appId")
4240
+ let bundleId = implementation.appId
4241
+ self.saveCallForAsyncHandling(call)
2330
4242
 
2331
- if let appId = appId {
2332
- // Open App Store with provided app ID
2333
- 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") {
2334
4244
  guard let url = URL(string: urlString) else {
2335
- call.reject("Invalid App Store URL")
4245
+ self.rejectCall(call, message: invalidMessage)
2336
4246
  return
2337
4247
  }
2338
4248
  DispatchQueue.main.async {
2339
4249
  UIApplication.shared.open(url) { success in
2340
4250
  if success {
2341
- call.resolve()
4251
+ self.resolveCall(call)
2342
4252
  } else {
2343
- call.reject("Failed to open App Store")
4253
+ self.rejectCall(call, message: failureMessage)
2344
4254
  }
2345
4255
  }
2346
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)")
2347
4269
  } else {
2348
- // Look up app ID using bundle identifier
2349
- let bundleId = implementation.appId
2350
4270
  let lookupUrl = "https://itunes.apple.com/lookup?bundleId=\(bundleId)"
2351
4271
 
2352
4272
  DispatchQueue.global(qos: .background).async {
2353
4273
  guard let url = URL(string: lookupUrl) else {
2354
- call.reject("Invalid lookup URL")
4274
+ openFallbackAppStorePage()
2355
4275
  return
2356
4276
  }
2357
4277
 
2358
4278
  let task = URLSession.shared.dataTask(with: url) { data, _, error in
2359
4279
  if let error = error {
2360
- call.reject("Failed to lookup app: \(error.localizedDescription)")
4280
+ self.logger.error("App Store lookup failed: \(error.localizedDescription)")
4281
+ openFallbackAppStorePage()
2361
4282
  return
2362
4283
  }
2363
4284
 
@@ -2366,39 +4287,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2366
4287
  let results = json["results"] as? [[String: Any]],
2367
4288
  let appInfo = results.first,
2368
4289
  let trackId = appInfo["trackId"] as? Int else {
2369
- // If lookup fails, try opening the generic App Store app page using bundle ID
2370
- let fallbackUrlString = "https://apps.apple.com/app/\(bundleId)"
2371
- guard let fallbackUrl = URL(string: fallbackUrlString) else {
2372
- call.reject("Failed to find app in App Store and fallback URL is invalid")
2373
- return
2374
- }
2375
- DispatchQueue.main.async {
2376
- UIApplication.shared.open(fallbackUrl) { success in
2377
- if success {
2378
- call.resolve()
2379
- } else {
2380
- call.reject("Failed to open App Store")
2381
- }
2382
- }
2383
- }
2384
- return
2385
- }
2386
-
2387
- let appStoreUrl = "https://apps.apple.com/app/id\(trackId)"
2388
- guard let url = URL(string: appStoreUrl) else {
2389
- call.reject("Invalid App Store URL")
4290
+ openFallbackAppStorePage()
2390
4291
  return
2391
4292
  }
2392
4293
 
2393
- DispatchQueue.main.async {
2394
- UIApplication.shared.open(url) { success in
2395
- if success {
2396
- call.resolve()
2397
- } else {
2398
- call.reject("Failed to open App Store")
2399
- }
2400
- }
2401
- }
4294
+ openAppStorePage(urlString: "https://apps.apple.com/app/id\(trackId)")
2402
4295
  }
2403
4296
  task.resume()
2404
4297
  }