@capgo/cordova-updater 8.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +82 -0
  3. package/dist/esm/definitions.d.ts +2684 -0
  4. package/dist/esm/definitions.js +109 -0
  5. package/dist/esm/definitions.js.map +1 -0
  6. package/dist/esm/exec.d.ts +4 -0
  7. package/dist/esm/exec.js +25 -0
  8. package/dist/esm/exec.js.map +1 -0
  9. package/dist/esm/history.d.ts +1 -0
  10. package/dist/esm/history.js +283 -0
  11. package/dist/esm/history.js.map +1 -0
  12. package/dist/esm/index.d.ts +57 -0
  13. package/dist/esm/index.js +68 -0
  14. package/dist/esm/index.js.map +1 -0
  15. package/dist/esm/plugin.d.ts +57 -0
  16. package/dist/esm/plugin.js +15 -0
  17. package/dist/esm/plugin.js.map +1 -0
  18. package/dist/plugin.cjs.js +217 -0
  19. package/dist/plugin.cjs.js.map +1 -0
  20. package/dist/plugin.js +222 -0
  21. package/dist/plugin.js.map +1 -0
  22. package/package.json +96 -0
  23. package/plugin.xml +207 -0
  24. package/src/android/CordovaUpdaterPlugin.java +5662 -0
  25. package/src/android/UpdaterPathHandler.java +99 -0
  26. package/src/android/app/capgo/cordova/updater/AndroidAppExitReporter.java +92 -0
  27. package/src/android/app/capgo/cordova/updater/AppLifecycleObserver.java +115 -0
  28. package/src/android/app/capgo/cordova/updater/BundleInfo.java +246 -0
  29. package/src/android/app/capgo/cordova/updater/BundleStatus.java +45 -0
  30. package/src/android/app/capgo/cordova/updater/Callback.java +13 -0
  31. package/src/android/app/capgo/cordova/updater/CapgoUpdater.java +3404 -0
  32. package/src/android/app/capgo/cordova/updater/CordovaUpdaterConfig.java +56 -0
  33. package/src/android/app/capgo/cordova/updater/CryptoCipher.java +462 -0
  34. package/src/android/app/capgo/cordova/updater/DataManager.java +44 -0
  35. package/src/android/app/capgo/cordova/updater/DelayCondition.java +56 -0
  36. package/src/android/app/capgo/cordova/updater/DelayUntilNext.java +14 -0
  37. package/src/android/app/capgo/cordova/updater/DelayUpdateUtils.java +307 -0
  38. package/src/android/app/capgo/cordova/updater/DeviceIdHelper.java +230 -0
  39. package/src/android/app/capgo/cordova/updater/DownloadService.java +1219 -0
  40. package/src/android/app/capgo/cordova/updater/DownloadWorkerManager.java +260 -0
  41. package/src/android/app/capgo/cordova/updater/InternalUtils.java +54 -0
  42. package/src/android/app/capgo/cordova/updater/Logger.java +345 -0
  43. package/src/android/app/capgo/cordova/updater/ShakeDetector.java +72 -0
  44. package/src/android/app/capgo/cordova/updater/ShakeMenu.java +39 -0
  45. package/src/android/app/capgo/cordova/updater/ThreeFingerPinchDetector.java +323 -0
  46. package/src/android/app/capgo/cordova/updater/compat/JSArray.java +9 -0
  47. package/src/android/app/capgo/cordova/updater/compat/JSObject.java +83 -0
  48. package/src/android/app/capgo/cordova/updater/compat/PluginCall.java +165 -0
  49. package/src/android/capgo-cordova-updater.gradle +19 -0
  50. package/src/android/capgo-updater.xml +3 -0
  51. package/src/android/test/app/capgo/cordova/updater/CapacitorUpdaterUnitTest.java.skip +3039 -0
  52. package/src/android/test/app/capgo/cordova/updater/DelayUpdateUtilsTest.java +177 -0
  53. package/src/android/test/app/capgo/cordova/updater/NativeContractTest.java +105 -0
  54. package/src/ios/AES.swift +216 -0
  55. package/src/ios/AppHealthTracker.swift +82 -0
  56. package/src/ios/BundleInfo.swift +185 -0
  57. package/src/ios/BundleStatus.swift +127 -0
  58. package/src/ios/CapgoRawRsa.swift +36 -0
  59. package/src/ios/CapgoUpdater.swift +3544 -0
  60. package/src/ios/CordovaCompat.swift +11 -0
  61. package/src/ios/CordovaPluginCall.swift +190 -0
  62. package/src/ios/CordovaUpdaterCommands.swift +270 -0
  63. package/src/ios/CordovaUpdaterPlugin.swift +4706 -0
  64. package/src/ios/CryptoCipher.swift +326 -0
  65. package/src/ios/DelayCondition.swift +74 -0
  66. package/src/ios/DelayUntilNext.swift +30 -0
  67. package/src/ios/DelayUpdateUtils.swift +257 -0
  68. package/src/ios/DeviceIdHelper.swift +120 -0
  69. package/src/ios/Info.plist +28 -0
  70. package/src/ios/InternalUtils.swift +471 -0
  71. package/src/ios/Logger.swift +283 -0
  72. package/src/ios/RSA.swift +96 -0
  73. package/src/ios/ShakeMenu.swift +52 -0
  74. package/src/ios/UpdaterSchemeHandler.swift +81 -0
  75. package/src/ios/UserDefaultsExtension.swift +46 -0
  76. package/src/ios/WebViewStatsReporter.swift +303 -0
@@ -0,0 +1,4706 @@
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
6
+
7
+ import Foundation
8
+ #if SWIFT_PACKAGE
9
+ import Cordova
10
+ #endif
11
+ import UIKit
12
+ import WebKit
13
+ import Version
14
+
15
+ /**
16
+ * Please read the Capacitor iOS Plugin Development Guide
17
+ * here: https://capacitorjs.com/docs/plugins/ios
18
+ */
19
+ @objc(CapgoCordovaUpdaterPlugin)
20
+ public class CordovaUpdaterPlugin: CDVPlugin, CDVPluginSchemeHandler {
21
+ enum DefaultChannelPreviewSnapshot: Equatable {
22
+ case missing
23
+ case invalidated
24
+ case snapshot(String?)
25
+ case unreadable
26
+ }
27
+
28
+ private var schemeHandler: UpdaterSchemeHandler?
29
+ private struct ListenerRegistration {
30
+ let eventName: String
31
+ let callbackId: String
32
+ }
33
+
34
+ private var cordovaListeners: [String: ListenerRegistration] = [:]
35
+ private var savedCalls: [String: CordovaPluginCall] = [:]
36
+ private let listenerStateQueue = DispatchQueue(label: "ee.forgr.capgo.cordovaUpdater.listenerState")
37
+ private var savedAppUpdateCall: CordovaPluginCall?
38
+
39
+ public override func pluginInitialize() {
40
+ super.pluginInitialize()
41
+ schemeHandler = UpdaterSchemeHandler()
42
+ loadPlugin()
43
+ }
44
+
45
+ public func overrideSchemeTask(_ task: WKURLSchemeTask) -> Bool {
46
+ return schemeHandler?.handle(task: task) ?? false
47
+ }
48
+
49
+ public func stop(_ task: WKURLSchemeTask) {
50
+ schemeHandler?.stop(task: task)
51
+ }
52
+
53
+ private func plistConfigKey(_ key: String) -> String {
54
+ guard let first = key.first else {
55
+ return "CapgoUpdater" + key
56
+ }
57
+ return "CapgoUpdater" + first.uppercased() + key.dropFirst()
58
+ }
59
+
60
+ private func readConfigString(_ key: String, _ defaultValue: String? = nil) -> String? {
61
+ guard let value = Bundle.main.object(forInfoDictionaryKey: plistConfigKey(key)) as? String, !value.trimmingCharacters(in: .whitespaces).isEmpty else {
62
+ return defaultValue
63
+ }
64
+ return value
65
+ }
66
+
67
+ private func readConfigBool(_ key: String, _ defaultValue: Bool = false) -> Bool {
68
+ guard let raw = readConfigString(key, nil) else { return defaultValue }
69
+ return (raw as NSString).boolValue
70
+ }
71
+
72
+ private func readConfigInt(_ key: String, _ defaultValue: Int = 0) -> Int {
73
+ guard let raw = readConfigString(key, nil) else { return defaultValue }
74
+ return Int(raw) ?? defaultValue
75
+ }
76
+
77
+ lazy var logger: Logger = {
78
+ // Default to true for OS logging. In test environments without a bridge,
79
+ // this will default to true. In production, it reads from config.
80
+ let osLogging: Bool
81
+ if true {
82
+ osLogging = readConfigBool("osLogging", true)
83
+ } else {
84
+ osLogging = true
85
+ }
86
+ let options = Logger.Options(useSyslog: osLogging)
87
+ return Logger(withTag: "✨ CapgoUpdater", options: options)
88
+ }()
89
+
90
+ public var implementation = CapgoUpdater()
91
+ private let pluginVersion: String = "8.1.1"
92
+ static let updateUrlDefault = "https://plugin.capgo.app/updates"
93
+ static let statsUrlDefault = "https://plugin.capgo.app/stats"
94
+ static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
95
+ static let autoUpdateModeOff = "off"
96
+ static let autoUpdateModeBackground = "atBackground"
97
+ static let autoUpdateModeInstall = "atInstall"
98
+ static let autoUpdateModeLaunch = "onLaunch"
99
+ static let autoUpdateModeAlways = "always"
100
+ static let autoUpdateModeOnlyDownload = "onlyDownload"
101
+ static let shakeMenuGestureShake = "shake"
102
+ static let shakeMenuGestureThreeFingerPinch = "threeFingerPinch"
103
+ private static let previewLoaderTimeoutMs = 60000
104
+ private let keepUrlPathFlagKey = "__capgo_keep_url_path_after_reload"
105
+ private let customIdDefaultsKey = "CapacitorUpdater.customId"
106
+ private let updateUrlDefaultsKey = "CapacitorUpdater.updateUrl"
107
+ private let statsUrlDefaultsKey = "CapacitorUpdater.statsUrl"
108
+ private let channelUrlDefaultsKey = "CapacitorUpdater.channelUrl"
109
+ private let defaultChannelDefaultsKey = "CapacitorUpdater.defaultChannel"
110
+ private let lastFailedBundleDefaultsKey = "CapacitorUpdater.lastFailedBundle"
111
+ private let lastVersionOsDefaultsKey = "CapacitorUpdater.lastVersionOs"
112
+ private let lastVersionBuildDefaultsKey = "CapacitorUpdater.lastVersionBuild"
113
+ private let lastVersionCodeDefaultsKey = "CapacitorUpdater.lastVersionCode"
114
+ private let osVersionChangedAction = "os_version_changed"
115
+ private let nativeAppVersionChangedAction = "native_app_version_changed"
116
+ private let previewSessionDefaultsKey = "CapacitorUpdater.previewSession"
117
+ private let previewPreviousShakeMenuDefaultsKey = "CapacitorUpdater.previewPreviousShakeMenu"
118
+ private let previewPreviousShakeChannelSelectorDefaultsKey = "CapacitorUpdater.previewPreviousShakeChannelSelector"
119
+ private let previewPreviousNextBundleDefaultsKey = "CapacitorUpdater.previewPreviousNextBundle"
120
+ private let previewPreviousAppIdDefaultsKey = "CapacitorUpdater.previewPreviousAppId"
121
+ private let previewPreviousDefaultChannelDefaultsKey = "CapacitorUpdater.previewPreviousDefaultChannel"
122
+ private let previewPreviousDefaultChannelWasSetDefaultsKey = "CapacitorUpdater.previewPreviousDefaultChannelWasSet"
123
+ private let previewAppIdDefaultsKey = "CapacitorUpdater.previewAppId"
124
+ private let previewPayloadUrlDefaultsKey = "CapacitorUpdater.previewPayloadUrl"
125
+ private let previewNameDefaultsKey = "CapacitorUpdater.previewName"
126
+ private let previewSourceDefaultsKey = "CapacitorUpdater.previewSource"
127
+ private let previewSessionsDefaultsKey = "CapacitorUpdater.previewSessions"
128
+ private let previewSessionAlertPendingDefaultsKey = "CapacitorUpdater.previewSessionAlertPending"
129
+ private let defaultChannelInstallMarkerDefaultsKey = "CapacitorUpdater.defaultChannelInstallMarkerCreated"
130
+ private let defaultChannelInstallMarkerFilename = "CapacitorUpdater.defaultChannelInstallMarker"
131
+ private let defaultChannelStateFilename = "CapacitorUpdater.defaultChannelState"
132
+ private let defaultChannelPreviewSnapshotFilename = "CapacitorUpdater.defaultChannelPreviewSnapshot"
133
+
134
+ private let previewDeepLinkScheme = "capgo"
135
+ private let previewDeepLinkRootComponent = "preview"
136
+ private let previewDeepLinkChannelComponent = "channel"
137
+ private let previewDeepLinkBundleComponent = "bundle"
138
+ private let previewPathSeparator = Character(UnicodeScalar(UInt8(47)))
139
+ // Note: DELAY_CONDITION_PREFERENCES is now defined in DelayUpdateUtils.DELAY_CONDITION_PREFERENCES
140
+ private var updateUrl = ""
141
+ private var backgroundTaskID: UIBackgroundTaskIdentifier = UIBackgroundTaskIdentifier.invalid
142
+ private var currentVersionNative: Version = "0.0.0"
143
+ private var currentBuildVersion: String = "0"
144
+ private var autoUpdate = false
145
+ private var autoUpdateMode = CordovaUpdaterPlugin.autoUpdateModeOff
146
+ private var appReadyTimeout = 10000
147
+ private var appReadyCheck: DispatchWorkItem?
148
+ private var resetWhenUpdate = true
149
+ private var directUpdate = false
150
+ private var directUpdateMode: String = "false"
151
+ private var wasRecentlyInstalledOrUpdated = false
152
+ private var onLaunchDirectUpdateUsed = false
153
+ private var autoSplashscreen = false
154
+ private var autoSplashscreenLoader = false
155
+ private var autoSplashscreenTimeout = 10000
156
+ private var autoSplashscreenTimeoutWorkItem: DispatchWorkItem?
157
+ private var splashscreenLoaderView: UIActivityIndicatorView?
158
+ private var splashscreenLoaderContainer: UIView?
159
+ private var previewTransitionLoaderView: UIActivityIndicatorView?
160
+ private var previewTransitionLoaderContainer: UIView?
161
+ private var previewTransitionLoaderTimeoutWorkItem: DispatchWorkItem?
162
+ private var previewTransitionLoaderRequested = false
163
+ private let splashscreenPluginName = "SplashScreen"
164
+ private let splashscreenRetryDelayMilliseconds = 100
165
+ private let splashscreenMaxRetries = 20
166
+ private var autoSplashscreenTimedOut = false
167
+ private var splashscreenInvocationToken = 0
168
+ private var autoDeleteFailed = false
169
+ private var autoDeletePrevious = false
170
+ var allowSetDefaultChannel = true
171
+ var persistDefaultChannelOnReinstall = true
172
+ private var keepUrlPathAfterReload = false
173
+ private var backgroundWork: DispatchWorkItem?
174
+ private var taskRunning = false
175
+ private var periodCheckDelay = 0
176
+ private let downloadLock = NSLock()
177
+ private let onLaunchDirectUpdateStateLock = NSLock()
178
+ private var downloadInProgress = false
179
+ private var downloadStartTime: Date?
180
+ private let downloadTimeout: TimeInterval = 600 // 10 minute timeout
181
+
182
+ // Lock to ensure cleanup completes before downloads start
183
+ private let cleanupLock = NSLock()
184
+ private let defaultChannelStateLock = NSLock()
185
+ private var cleanupComplete = false
186
+ private var cleanupThread: Thread?
187
+ private var defaultChannelCleanupMustRetry = false
188
+ private var persistCustomId = false
189
+ private var persistModifyUrl = false
190
+ private var allowManualBundleError = false
191
+ private var allowPreview = false
192
+ private var keepUrlPathFlagLastValue: Bool?
193
+ private var appHealthTracker: AppHealthTracker?
194
+ private var webViewStatsReporter: WebViewStatsReporter?
195
+ public var shakeMenuEnabled = false
196
+ public var shakeChannelSelectorEnabled = false
197
+ public var shakeMenuGesture = CordovaUpdaterPlugin.shakeMenuGestureShake
198
+ public var previewSessionEnabled = false
199
+ private var previewSessionAlertPending = false
200
+ private var isLeavingPreviewForIncomingLink = false
201
+ private var previewTransitionClearWorkItem: DispatchWorkItem?
202
+ let semaphoreReady = DispatchSemaphore(value: 0)
203
+
204
+ private var delayUpdateUtils: DelayUpdateUtils!
205
+
206
+ func loadPlugin() {
207
+ let disableJSLogging = readConfigBool("disableJSLogging", false)
208
+ // Set webView for logging to JavaScript console
209
+ if let webView = self.updaterWebView, !disableJSLogging {
210
+ logger.setWebView(webView: webView)
211
+ logger.info("WebView set successfully for logging")
212
+ } else {
213
+ logger.error("Failed to get webView for logging")
214
+ }
215
+ let webViewStatsReporter = WebViewStatsReporter(implementation: implementation)
216
+ self.webViewStatsReporter = webViewStatsReporter
217
+ webViewStatsReporter.install(on: self.updaterWebView)
218
+ #if targetEnvironment(simulator)
219
+ logger.info("::::: SIMULATOR :::::")
220
+ logger.info("Application directory: \(NSHomeDirectory())")
221
+ #endif
222
+
223
+ self.semaphoreUp()
224
+ // Use DeviceIdHelper to get or create device ID that persists across reinstalls
225
+ self.implementation.deviceID = DeviceIdHelper.getOrCreateDeviceId()
226
+ persistCustomId = readConfigBool("persistCustomId", false)
227
+ allowSetDefaultChannel = readConfigBool("allowSetDefaultChannel", true)
228
+ persistDefaultChannelOnReinstall = readConfigBool("persistDefaultChannelOnReinstall", true)
229
+ if persistCustomId {
230
+ let storedCustomId = UserDefaults.standard.string(forKey: customIdDefaultsKey) ?? ""
231
+ if !storedCustomId.isEmpty {
232
+ implementation.customId = storedCustomId
233
+ logger.info("Loaded persisted customId")
234
+ }
235
+ }
236
+ persistModifyUrl = readConfigBool("persistModifyUrl", false)
237
+ allowManualBundleError = readConfigBool("allowManualBundleError", false)
238
+ allowPreview = readConfigBool("allowPreview", false)
239
+ logger.info("init for device \(self.implementation.deviceID)")
240
+ guard let versionName = readConfigString("version", Bundle.main.versionName) else {
241
+ logger.error("Cannot get version name")
242
+ // crash the app on purpose
243
+ fatalError("Cannot get version name")
244
+ }
245
+ do {
246
+ currentVersionNative = try Version(versionName)
247
+ } catch {
248
+ logger.error("Cannot parse versionName \(versionName)")
249
+ }
250
+ currentBuildVersion = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0"
251
+ logger.info("version native \(self.currentVersionNative.description)")
252
+ implementation.versionBuild = readConfigString("version", Bundle.main.versionName)!
253
+ autoDeleteFailed = readConfigBool("autoDeleteFailed", true)
254
+ autoDeletePrevious = readConfigBool("autoDeletePrevious", true)
255
+ keepUrlPathAfterReload = readConfigBool("keepUrlPathAfterReload", false)
256
+ syncKeepUrlPathFlag(enabled: keepUrlPathAfterReload)
257
+
258
+ autoSplashscreen = readConfigBool("autoSplashscreen", false)
259
+ autoSplashscreenLoader = readConfigBool("autoSplashscreenLoader", false)
260
+ let splashscreenTimeoutValue = readConfigInt("autoSplashscreenTimeout", 10000)
261
+ autoSplashscreenTimeout = max(0, splashscreenTimeoutValue)
262
+ updateUrl = readConfigString("updateUrl", CordovaUpdaterPlugin.updateUrlDefault)!
263
+ if persistModifyUrl, let storedUpdateUrl = UserDefaults.standard.object(forKey: updateUrlDefaultsKey) as? String {
264
+ updateUrl = storedUpdateUrl
265
+ logger.info("Loaded persisted updateUrl")
266
+ }
267
+ configureAutoUpdateModeFromConfig()
268
+ appReadyTimeout = max(1000, readConfigInt("appReadyTimeout", 10000)) // Minimum 1 second
269
+ implementation.timeout = Double(readConfigInt("responseTimeout", 20))
270
+ resetWhenUpdate = readConfigBool("resetWhenUpdate", true)
271
+ shakeMenuEnabled = readConfigBool("shakeMenu", false)
272
+ shakeChannelSelectorEnabled = readConfigBool("allowShakeChannelSelector", false)
273
+ shakeMenuGesture = Self.normalizedShakeMenuGesture(readConfigString("shakeMenuGesture", Self.shakeMenuGestureShake))
274
+ let storedPreviewSessionEnabled = UserDefaults.standard.bool(forKey: previewSessionDefaultsKey)
275
+ let shouldClearPreviewSessionBecauseDisabled = !allowPreview && storedPreviewSessionEnabled
276
+ previewSessionEnabled = allowPreview && storedPreviewSessionEnabled
277
+ implementation.previewSession = previewSessionEnabled
278
+ if previewSessionEnabled {
279
+ previewSessionAlertPending = UserDefaults.standard.object(forKey: previewSessionAlertPendingDefaultsKey) as? Bool ?? true
280
+ shakeMenuEnabled = true
281
+ shakeChannelSelectorEnabled = UserDefaults.standard.object(forKey: previewPreviousShakeChannelSelectorDefaultsKey) as? Bool
282
+ ?? shakeChannelSelectorEnabled
283
+ }
284
+ syncShakeMenuGestureRecognizer()
285
+ periodCheckDelay = Self.normalizedPeriodCheckDelaySeconds(readConfigInt("periodCheckDelay", 0))
286
+
287
+ implementation.setPublicKey(readConfigString("publicKey") ?? "")
288
+ implementation.notifyDownloadRaw = notifyDownload
289
+ implementation.notifyListeners = { [weak self] eventName, data in
290
+ let emit = {
291
+ self?.notifyJSListeners(eventName, data: data)
292
+ }
293
+ if Thread.isMainThread {
294
+ emit()
295
+ } else {
296
+ DispatchQueue.main.async {
297
+ emit()
298
+ }
299
+ }
300
+ }
301
+ implementation.pluginVersion = self.pluginVersion
302
+
303
+ // Set logger for shared classes
304
+ implementation.setLogger(logger)
305
+ CryptoCipher.setLogger(logger)
306
+
307
+ // Log public key prefix if encryption is enabled
308
+ if let keyId = implementation.getKeyId(), !keyId.isEmpty {
309
+ logger.info("Public key prefix: \(keyId)")
310
+ }
311
+
312
+ // Initialize DelayUpdateUtils
313
+ self.delayUpdateUtils = DelayUpdateUtils(currentVersionNative: currentVersionNative, logger: logger)
314
+ implementation.appId = Bundle.main.infoDictionary?["CFBundleIdentifier"] as? String ?? ""
315
+ implementation.appId = readConfigString("appId", implementation.appId)!
316
+ if implementation.appId == "" {
317
+ // crash the app on purpose it should not happen
318
+ fatalError("appId is missing in plugin config (CapgoUpdaterAppId), and cannot be retrieved from the native app")
319
+ }
320
+ if shouldClearPreviewSessionBecauseDisabled {
321
+ clearPreviewSessionBecauseDisabled()
322
+ }
323
+ if previewSessionEnabled,
324
+ let previewAppId = UserDefaults.standard.string(forKey: previewAppIdDefaultsKey),
325
+ !previewAppId.isEmpty {
326
+ implementation.appId = previewAppId
327
+ logger.info("Using preview appId \(previewAppId)")
328
+ }
329
+ logger.info("appId \(implementation.appId)")
330
+ implementation.statsUrl = readConfigString("statsUrl", CordovaUpdaterPlugin.statsUrlDefault)!
331
+ implementation.channelUrl = readConfigString("channelUrl", CordovaUpdaterPlugin.channelUrlDefault)!
332
+ if persistModifyUrl {
333
+ if let storedStatsUrl = UserDefaults.standard.object(forKey: statsUrlDefaultsKey) as? String {
334
+ implementation.statsUrl = storedStatsUrl
335
+ logger.info("Loaded persisted statsUrl")
336
+ }
337
+ if let storedChannelUrl = UserDefaults.standard.object(forKey: channelUrlDefaultsKey) as? String {
338
+ implementation.channelUrl = storedChannelUrl
339
+ logger.info("Loaded persisted channelUrl")
340
+ }
341
+ }
342
+
343
+ let nativeBuildVersionChanged = self.hasNativeBuildVersionChanged()
344
+ let defaultChannelPersistenceDisabled = !persistDefaultChannelOnReinstall
345
+ let restoredReinstall = defaultChannelPersistenceDisabled && self.isRestoredReinstall()
346
+ var installMarkerCanBePrepared = true
347
+ if shouldClearPersistedDefaultChannel(
348
+ nativeBuildVersionChanged: nativeBuildVersionChanged,
349
+ resetWhenUpdate: resetWhenUpdate,
350
+ restoredReinstall: restoredReinstall
351
+ ) {
352
+ installMarkerCanBePrepared = clearPersistedDefaultChannel()
353
+ if installMarkerCanBePrepared {
354
+ logger.info("Cleared persisted defaultChannel because reinstall persistence is disabled")
355
+ } else {
356
+ logger.warn("Cannot durably clear persisted defaultChannel")
357
+ self.defaultChannelCleanupMustRetry = true
358
+ self.invalidateDefaultChannelInstallMarker()
359
+ }
360
+ }
361
+ if defaultChannelPersistenceDisabled && installMarkerCanBePrepared {
362
+ self.prepareDefaultChannelInstallMarker()
363
+ }
364
+ if !previewSessionEnabled,
365
+ !defaultChannelCleanupMustRetry,
366
+ self.hasPendingDefaultChannelPreviewSnapshot(),
367
+ !self.restorePendingDefaultChannelPreviewSnapshot() {
368
+ logger.warn("Default channel preview restore remains pending")
369
+ }
370
+
371
+ let configDefaultChannel = readConfigString("defaultChannel", "")!
372
+ let storedDefaultChannel = UserDefaults.standard.string(forKey: defaultChannelDefaultsKey)
373
+ let stateFile = self.defaultChannelStateFile()
374
+ let state = stateFile.map { self.defaultChannelState(file: $0) }
375
+ if defaultChannelCleanupMustRetry {
376
+ implementation.defaultChannel = configDefaultChannel
377
+ logger.warn("Using configured defaultChannel until persisted cleanup can retry")
378
+ } else if let state = state, state.exists, state.isReadable {
379
+ self.reconcileDefaultChannelDefaults(with: state.channel)
380
+ implementation.defaultChannel = state.channel ?? configDefaultChannel
381
+ logger.info("Loaded persisted defaultChannel from local state")
382
+ } else if defaultChannelPersistenceDisabled, state?.exists == true {
383
+ implementation.defaultChannel = configDefaultChannel
384
+ logger.warn("Ignoring unreadable persisted defaultChannel while reinstall persistence is disabled")
385
+ } else if let storedDefaultChannel = storedDefaultChannel {
386
+ implementation.defaultChannel = storedDefaultChannel
387
+ logger.info("Loaded persisted defaultChannel from setChannel()")
388
+ } else {
389
+ implementation.defaultChannel = configDefaultChannel
390
+ }
391
+ if !defaultChannelCleanupMustRetry,
392
+ state?.exists != true || (!defaultChannelPersistenceDisabled && state?.isReadable != true) {
393
+ _ = self.persistDefaultChannelStateFromDefaults()
394
+ }
395
+ self.implementation.autoReset()
396
+ let appHealthTracker = AppHealthTracker(implementation: self.implementation)
397
+ self.appHealthTracker = appHealthTracker
398
+ appHealthTracker.reportPreviousUncleanForegroundExit()
399
+ appHealthTracker.startSession()
400
+
401
+ // Check if app was recently installed/updated BEFORE cleanup updates the stored native build version.
402
+ self.wasRecentlyInstalledOrUpdated = self.checkIfRecentlyInstalledOrUpdated()
403
+ if nativeBuildVersionChanged {
404
+ self.clearPreviewSessionForNativeBuildChange()
405
+ }
406
+ self.leavePreviewSessionForLaunchURLIfNeeded()
407
+
408
+ if resetWhenUpdate {
409
+ let didResetCurrentBundle = self.resetCurrentBundleForNativeBuildChangeIfNeeded()
410
+ self.cleanupObsoleteVersions(didResetCurrentBundle: didResetCurrentBundle)
411
+ }
412
+ self.reportNativeVersionStatsIfChanged()
413
+
414
+ // Load the server
415
+ // This is very much swift specific, android does not do that
416
+ // In android we depend on the serverBasePath capacitor property
417
+ // In IOS we do not. Instead during the plugin initialization we try to call setServerBasePath
418
+ // The idea is to prevent having to store the bundle in 2 locations for hot reload and persistent storage
419
+ // According to martin it is not possible to use serverBasePath on ios in a way that allows us to store the bundle once
420
+
421
+ if !self.initialLoad() {
422
+ logger.error("unable to force reload, the plugin might fallback to the builtin version")
423
+ }
424
+
425
+ self.registerNotificationObservers()
426
+
427
+ // Check for 'kill' delay condition on app launch
428
+ // This handles cases where the app was killed (willTerminateNotification is not reliable for system kills)
429
+ self.delayUpdateUtils.checkCancelDelay(source: .killed)
430
+
431
+ self.appMovedToForeground()
432
+ self.checkForUpdateAfterDelay()
433
+ self.showPreviewSessionNoticeIfNeeded()
434
+ }
435
+
436
+ private func registerNotificationObservers() {
437
+ let notificationCenter = NotificationCenter.default
438
+ notificationCenter.addObserver(
439
+ self,
440
+ selector: #selector(appMovedToBackground),
441
+ name: UIApplication.didEnterBackgroundNotification,
442
+ object: nil
443
+ )
444
+ notificationCenter.addObserver(
445
+ self,
446
+ selector: #selector(appMovedToForeground),
447
+ name: UIApplication.willEnterForegroundNotification,
448
+ object: nil
449
+ )
450
+ notificationCenter.addObserver(
451
+ self,
452
+ selector: #selector(appWillTerminate),
453
+ name: UIApplication.willTerminateNotification,
454
+ object: nil
455
+ )
456
+ notificationCenter.addObserver(
457
+ self,
458
+ selector: #selector(appDidReceiveMemoryWarning),
459
+ name: UIApplication.didReceiveMemoryWarningNotification,
460
+ object: nil
461
+ )
462
+ notificationCenter.addObserver(
463
+ self,
464
+ selector: #selector(handleOpenURLForPreviewSession(notification:)),
465
+ name: Notification.Name("CDVPluginHandleOpenURLNotification"),
466
+ object: nil
467
+ )
468
+ notificationCenter.addObserver(
469
+ self,
470
+ selector: #selector(handleOpenURLForPreviewSession(notification:)),
471
+ name: Notification.Name("CDVPluginHandleOpenURLWithAppSourceAndAnnotationNotification"),
472
+ object: nil
473
+ )
474
+ }
475
+
476
+ private func syncKeepUrlPathFlag(enabled: Bool) {
477
+ let script: String
478
+ if enabled {
479
+ script = "(function(){ try { localStorage.setItem('\(keepUrlPathFlagKey)', '1'); } catch (err) {} window.__capgoKeepUrlPathAfterReload = true; var evt; try { evt = new CustomEvent('CapacitorUpdaterKeepUrlPathAfterReload', { detail: { enabled: true } }); } catch (e) { evt = document.createEvent('CustomEvent'); evt.initCustomEvent('CapacitorUpdaterKeepUrlPathAfterReload', false, false, { enabled: true }); } window.dispatchEvent(evt); })();"
480
+ } else {
481
+ script = "(function(){ try { localStorage.removeItem('\(keepUrlPathFlagKey)'); } catch (err) {} delete window.__capgoKeepUrlPathAfterReload; var evt; try { evt = new CustomEvent('CapacitorUpdaterKeepUrlPathAfterReload', { detail: { enabled: false } }); } catch (e) { evt = document.createEvent('CustomEvent'); evt.initCustomEvent('CapacitorUpdaterKeepUrlPathAfterReload', false, false, { enabled: false }); } window.dispatchEvent(evt); })();"
482
+ }
483
+ DispatchQueue.main.async { [weak self] in
484
+ guard let self = self, let webView = self.updaterWebView else {
485
+ return
486
+ }
487
+ if self.keepUrlPathFlagLastValue != enabled {
488
+ let userScript = WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true)
489
+ webView.configuration.userContentController.addUserScript(userScript)
490
+ self.keepUrlPathFlagLastValue = enabled
491
+ }
492
+ webView.evaluateJavaScript(script, completionHandler: nil)
493
+ }
494
+ }
495
+
496
+ private func persistLastFailedBundle(_ bundle: BundleInfo?) {
497
+ if let bundle = bundle {
498
+ do {
499
+ try UserDefaults.standard.setObj(bundle, forKey: lastFailedBundleDefaultsKey)
500
+ } catch {
501
+ logger.error("Failed to persist failed bundle info \(error.localizedDescription)")
502
+ }
503
+ } else {
504
+ UserDefaults.standard.removeObject(forKey: lastFailedBundleDefaultsKey)
505
+ }
506
+ UserDefaults.standard.synchronize()
507
+ }
508
+
509
+ private func readLastFailedBundle() -> BundleInfo? {
510
+ do {
511
+ let bundle: BundleInfo = try UserDefaults.standard.getObj(forKey: lastFailedBundleDefaultsKey, castTo: BundleInfo.self)
512
+ return bundle
513
+ } catch ObjectSavableError.noValue {
514
+ return nil
515
+ } catch {
516
+ logger.error("Failed to read failed bundle info \(error.localizedDescription)")
517
+ UserDefaults.standard.removeObject(forKey: lastFailedBundleDefaultsKey)
518
+ UserDefaults.standard.synchronize()
519
+ return nil
520
+ }
521
+ }
522
+
523
+ @objc private func appWillTerminate() {
524
+ appHealthTracker?.markForeground(false)
525
+ }
526
+
527
+ @objc private func appDidReceiveMemoryWarning() {
528
+ appHealthTracker?.reportMemoryWarning()
529
+ }
530
+
531
+ func reportWebViewError(_ call: CAPPluginCall) {
532
+ guard let webViewStatsReporter = webViewStatsReporter else {
533
+ call.resolve()
534
+ return
535
+ }
536
+ webViewStatsReporter.reportError(call)
537
+ }
538
+
539
+ private func initialLoad() -> Bool {
540
+ if keepUrlPathAfterReload {
541
+ syncKeepUrlPathFlag(enabled: true)
542
+ }
543
+
544
+ let id = self.implementation.getCurrentBundleId()
545
+ var dest: URL
546
+ if BundleInfo.ID_BUILTIN == id {
547
+ dest = Bundle.main.resourceURL!.appendingPathComponent("public")
548
+ } else {
549
+ dest = self.implementation.getBundleDirectory(id: id)
550
+ }
551
+
552
+ if !FileManager.default.fileExists(atPath: dest.path) {
553
+ logger.error("Initial load fail - file at path \(dest.path) doesn't exist. Defaulting to buildin!! \(id)")
554
+ dest = Bundle.main.resourceURL!.appendingPathComponent("public")
555
+ }
556
+
557
+ logger.info("Initial load \(id)")
558
+ // We don't use the viewcontroller here as it does not work during the initial load state
559
+ schemeHandler?.activeBundleDir = URL(fileURLWithPath: dest.path)
560
+ return true
561
+ }
562
+
563
+ private func semaphoreWait(waitTime: Int) {
564
+ // print("\\(CapgoUpdater.TAG) semaphoreWait \\(waitTime)")
565
+ let result = semaphoreReady.wait(timeout: .now() + .milliseconds(waitTime))
566
+ if result == .timedOut {
567
+ logger.error("Semaphore wait timed out after \(waitTime)ms")
568
+ }
569
+ }
570
+
571
+ private func semaphoreUp() {
572
+ DispatchQueue.global().async {
573
+ self.semaphoreWait(waitTime: 0)
574
+ }
575
+ }
576
+
577
+ private func semaphoreDown() {
578
+ semaphoreReady.signal()
579
+ }
580
+
581
+ func shouldClearPersistedDefaultChannel(
582
+ nativeBuildVersionChanged: Bool,
583
+ resetWhenUpdate: Bool,
584
+ restoredReinstall: Bool
585
+ ) -> Bool {
586
+ !persistDefaultChannelOnReinstall && (restoredReinstall || (resetWhenUpdate && nativeBuildVersionChanged))
587
+ }
588
+
589
+ func clearPersistedDefaultChannel() -> Bool {
590
+ guard let stateFile = self.defaultChannelStateFile(),
591
+ let previewSnapshotFile = self.defaultChannelPreviewSnapshotFile() else {
592
+ return false
593
+ }
594
+ return self.clearPersistedDefaultChannel(
595
+ stateFile: stateFile,
596
+ previewSnapshotFile: previewSnapshotFile
597
+ )
598
+ }
599
+
600
+ func clearPersistedDefaultChannel(stateFile: URL, previewSnapshotFile: URL) -> Bool {
601
+ defaultChannelStateLock.lock()
602
+ defer { defaultChannelStateLock.unlock() }
603
+ do {
604
+ try self.persistDefaultChannelStateWithoutLock(channel: nil, file: stateFile)
605
+ try self.persistDefaultChannelPreviewSnapshotWithoutLock(
606
+ channel: nil,
607
+ isValid: false,
608
+ file: previewSnapshotFile
609
+ )
610
+ UserDefaults.standard.removeObject(forKey: defaultChannelDefaultsKey)
611
+ UserDefaults.standard.removeObject(forKey: previewPreviousDefaultChannelDefaultsKey)
612
+ UserDefaults.standard.removeObject(forKey: previewPreviousDefaultChannelWasSetDefaultsKey)
613
+ return true
614
+ } catch {
615
+ logger.warn("Cannot persist cleared default channel state: \(error.localizedDescription)")
616
+ return false
617
+ }
618
+ }
619
+
620
+ private func defaultChannelStateFile() -> URL? {
621
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
622
+ .appendingPathComponent(defaultChannelStateFilename)
623
+ }
624
+
625
+ private func defaultChannelPreviewSnapshotFile() -> URL? {
626
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
627
+ .appendingPathComponent(defaultChannelPreviewSnapshotFilename)
628
+ }
629
+
630
+ func defaultChannelState(file: URL) -> (exists: Bool, channel: String?, isReadable: Bool) {
631
+ defaultChannelStateLock.lock()
632
+ defer { defaultChannelStateLock.unlock() }
633
+ return self.defaultChannelStateWithoutLock(file: file)
634
+ }
635
+
636
+ private func defaultChannelStateWithoutLock(file: URL) -> (exists: Bool, channel: String?, isReadable: Bool) {
637
+ let fileManager = FileManager.default
638
+ guard fileManager.fileExists(atPath: file.path) else {
639
+ return (false, nil, true)
640
+ }
641
+ do {
642
+ let data = try Data(contentsOf: file)
643
+ guard !data.isEmpty else {
644
+ return (true, nil, true)
645
+ }
646
+ guard let channel = String(data: data, encoding: .utf8) else {
647
+ logger.warn("Cannot decode persisted default channel state")
648
+ return (true, nil, false)
649
+ }
650
+ return (true, channel, true)
651
+ } catch {
652
+ logger.warn("Cannot read persisted default channel state: \(error.localizedDescription)")
653
+ return (true, nil, false)
654
+ }
655
+ }
656
+
657
+ func persistDefaultChannelState(channel: String?, file: URL) throws {
658
+ defaultChannelStateLock.lock()
659
+ defer { defaultChannelStateLock.unlock() }
660
+ try self.persistDefaultChannelStateWithoutLock(channel: channel, file: file)
661
+ }
662
+
663
+ private func persistDefaultChannelStateWithoutLock(channel: String?, file: URL) throws {
664
+ let data = channel.map { Data($0.utf8) } ?? Data()
665
+ try self.persistBackupExcludedFile(file: file, data: data, overwrite: true)
666
+ }
667
+
668
+ func defaultChannelPreviewSnapshot(file: URL) -> DefaultChannelPreviewSnapshot {
669
+ defaultChannelStateLock.lock()
670
+ defer { defaultChannelStateLock.unlock() }
671
+ return self.defaultChannelPreviewSnapshotWithoutLock(file: file)
672
+ }
673
+
674
+ private func defaultChannelPreviewSnapshotWithoutLock(file: URL) -> DefaultChannelPreviewSnapshot {
675
+ guard FileManager.default.fileExists(atPath: file.path) else {
676
+ return .missing
677
+ }
678
+ do {
679
+ let data = try Data(contentsOf: file)
680
+ guard let kind = data.first else {
681
+ return .unreadable
682
+ }
683
+ switch kind {
684
+ case 0 where data.count == 1:
685
+ return .invalidated
686
+ case 1 where data.count == 1:
687
+ return .snapshot(nil)
688
+ case 2:
689
+ guard let channel = String(data: Data(data.dropFirst()), encoding: .utf8) else {
690
+ return .unreadable
691
+ }
692
+ return .snapshot(channel)
693
+ default:
694
+ return .unreadable
695
+ }
696
+ } catch {
697
+ logger.warn("Cannot read default channel preview snapshot: \(error.localizedDescription)")
698
+ return .unreadable
699
+ }
700
+ }
701
+
702
+ func persistDefaultChannelPreviewSnapshot(channel: String?, file: URL) throws {
703
+ defaultChannelStateLock.lock()
704
+ defer { defaultChannelStateLock.unlock() }
705
+ try self.persistDefaultChannelPreviewSnapshotWithoutLock(
706
+ channel: channel,
707
+ isValid: true,
708
+ file: file
709
+ )
710
+ }
711
+
712
+ func invalidateDefaultChannelPreviewSnapshot(file: URL) throws {
713
+ defaultChannelStateLock.lock()
714
+ defer { defaultChannelStateLock.unlock() }
715
+ try self.persistDefaultChannelPreviewSnapshotWithoutLock(
716
+ channel: nil,
717
+ isValid: false,
718
+ file: file
719
+ )
720
+ }
721
+
722
+ private func persistDefaultChannelPreviewSnapshotWithoutLock(
723
+ channel: String?,
724
+ isValid: Bool,
725
+ file: URL
726
+ ) throws {
727
+ var data = Data()
728
+ if !isValid {
729
+ data.append(0)
730
+ } else if let channel = channel {
731
+ data.append(2)
732
+ data.append(contentsOf: channel.utf8)
733
+ } else {
734
+ data.append(1)
735
+ }
736
+ try self.persistBackupExcludedFile(file: file, data: data, overwrite: true)
737
+ }
738
+
739
+ private func persistDefaultChannelPreviewSnapshot(channel: String?) -> Bool {
740
+ guard let file = self.defaultChannelPreviewSnapshotFile() else {
741
+ return false
742
+ }
743
+ do {
744
+ try self.persistDefaultChannelPreviewSnapshot(channel: channel, file: file)
745
+ return true
746
+ } catch {
747
+ logger.warn("Cannot persist default channel preview snapshot: \(error.localizedDescription)")
748
+ return false
749
+ }
750
+ }
751
+
752
+ private func hasPendingDefaultChannelPreviewSnapshot() -> Bool {
753
+ guard let file = self.defaultChannelPreviewSnapshotFile() else {
754
+ return false
755
+ }
756
+ switch self.defaultChannelPreviewSnapshot(file: file) {
757
+ case .snapshot, .unreadable:
758
+ return true
759
+ case .missing, .invalidated:
760
+ return false
761
+ }
762
+ }
763
+
764
+ @discardableResult
765
+ private func restorePendingDefaultChannelPreviewSnapshot() -> Bool {
766
+ guard let snapshotFile = self.defaultChannelPreviewSnapshotFile() else {
767
+ return false
768
+ }
769
+ switch self.defaultChannelPreviewSnapshot(file: snapshotFile) {
770
+ case .missing, .invalidated:
771
+ return true
772
+ case .unreadable:
773
+ return false
774
+ case .snapshot(let channel):
775
+ self.reconcileDefaultChannelDefaults(with: channel)
776
+ guard let stateFile = self.defaultChannelStateFile() else {
777
+ return false
778
+ }
779
+ do {
780
+ try self.persistDefaultChannelState(channel: channel, file: stateFile)
781
+ try self.invalidateDefaultChannelPreviewSnapshot(file: snapshotFile)
782
+ logger.info("Restored defaultChannel from preview snapshot")
783
+ return true
784
+ } catch {
785
+ logger.warn("Cannot persist restored default channel from preview snapshot: \(error.localizedDescription)")
786
+ return false
787
+ }
788
+ }
789
+ }
790
+
791
+ @discardableResult
792
+ func persistDefaultChannelStateFromDefaults() -> Bool {
793
+ guard let file = self.defaultChannelStateFile() else {
794
+ logger.warn("Cannot locate default channel state file")
795
+ return false
796
+ }
797
+ return self.persistDefaultChannelStateFromDefaults(stateFile: file)
798
+ }
799
+
800
+ @discardableResult
801
+ func persistDefaultChannelStateFromDefaults(stateFile: URL) -> Bool {
802
+ defaultChannelStateLock.lock()
803
+ defer { defaultChannelStateLock.unlock() }
804
+ do {
805
+ try self.persistDefaultChannelStateWithoutLock(
806
+ channel: UserDefaults.standard.string(forKey: defaultChannelDefaultsKey),
807
+ file: stateFile
808
+ )
809
+ return true
810
+ } catch {
811
+ do {
812
+ if FileManager.default.fileExists(atPath: stateFile.path) {
813
+ try FileManager.default.removeItem(at: stateFile)
814
+ }
815
+ logger.warn("Cannot persist default channel state; falling back to UserDefaults: \(error.localizedDescription)")
816
+ return true
817
+ } catch let invalidationError {
818
+ logger.warn("Cannot persist or invalidate default channel state: \(invalidationError.localizedDescription)")
819
+ return false
820
+ }
821
+ }
822
+ }
823
+
824
+ private func persistedDefaultChannel() -> String? {
825
+ guard let file = self.defaultChannelStateFile() else {
826
+ return UserDefaults.standard.string(forKey: defaultChannelDefaultsKey)
827
+ }
828
+ return self.persistedDefaultChannel(stateFile: file)
829
+ }
830
+
831
+ func persistedDefaultChannel(stateFile: URL) -> String? {
832
+ let state = self.defaultChannelState(file: stateFile)
833
+ if state.exists {
834
+ if state.isReadable {
835
+ return state.channel
836
+ }
837
+ if !persistDefaultChannelOnReinstall {
838
+ return nil
839
+ }
840
+ }
841
+ return UserDefaults.standard.string(forKey: defaultChannelDefaultsKey)
842
+ }
843
+
844
+ private func reconcileDefaultChannelDefaults(with channel: String?) {
845
+ if let channel = channel {
846
+ UserDefaults.standard.set(channel, forKey: defaultChannelDefaultsKey)
847
+ } else {
848
+ UserDefaults.standard.removeObject(forKey: defaultChannelDefaultsKey)
849
+ }
850
+ }
851
+
852
+ private func defaultChannelInstallMarker() -> URL? {
853
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
854
+ .appendingPathComponent(defaultChannelInstallMarkerFilename)
855
+ }
856
+
857
+ private func invalidateDefaultChannelInstallMarker() {
858
+ guard let marker = self.defaultChannelInstallMarker() else {
859
+ return
860
+ }
861
+ do {
862
+ try self.invalidateDefaultChannelInstallMarker(marker: marker)
863
+ } catch {
864
+ logger.warn("Cannot invalidate default channel install marker for cleanup retry: \(error.localizedDescription)")
865
+ }
866
+ }
867
+
868
+ func invalidateDefaultChannelInstallMarker(marker: URL) throws {
869
+ let fileManager = FileManager.default
870
+ if fileManager.fileExists(atPath: marker.path) {
871
+ try fileManager.removeItem(at: marker)
872
+ }
873
+ }
874
+
875
+ func isRestoredReinstall() -> Bool {
876
+ guard let marker = self.defaultChannelInstallMarker() else {
877
+ return false
878
+ }
879
+ return self.isRestoredReinstall(
880
+ marker: marker,
881
+ markerWasCreated: UserDefaults.standard.bool(forKey: defaultChannelInstallMarkerDefaultsKey)
882
+ )
883
+ }
884
+
885
+ func isRestoredReinstall(marker: URL, markerWasCreated: Bool) -> Bool {
886
+ markerWasCreated && !FileManager.default.fileExists(atPath: marker.path)
887
+ }
888
+
889
+ private func prepareDefaultChannelInstallMarker() {
890
+ guard let marker = self.defaultChannelInstallMarker() else {
891
+ return
892
+ }
893
+ self.prepareDefaultChannelInstallMarker(
894
+ marker: marker,
895
+ markerWasCreated: UserDefaults.standard.bool(forKey: defaultChannelInstallMarkerDefaultsKey)
896
+ )
897
+ }
898
+
899
+ func prepareDefaultChannelInstallMarker(marker: URL, markerWasCreated: Bool) {
900
+ do {
901
+ try self.persistBackupExcludedFile(file: marker, data: Data())
902
+ if !markerWasCreated {
903
+ UserDefaults.standard.set(true, forKey: defaultChannelInstallMarkerDefaultsKey)
904
+ UserDefaults.standard.synchronize()
905
+ }
906
+ } catch {
907
+ try? FileManager.default.removeItem(at: marker)
908
+ UserDefaults.standard.set(false, forKey: defaultChannelInstallMarkerDefaultsKey)
909
+ UserDefaults.standard.synchronize()
910
+ logger.warn("Cannot prepare default channel install marker: \(error.localizedDescription)")
911
+ }
912
+ }
913
+
914
+ private func persistBackupExcludedFile(file: URL, data: Data, overwrite: Bool = false) throws {
915
+ let fileManager = FileManager.default
916
+ if overwrite || !fileManager.fileExists(atPath: file.path) {
917
+ try fileManager.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
918
+ try data.write(to: file, options: .atomic)
919
+ }
920
+ let currentResourceValues = try file.resourceValues(forKeys: [.isExcludedFromBackupKey])
921
+ if currentResourceValues.isExcludedFromBackup != true {
922
+ var resourceValues = URLResourceValues()
923
+ resourceValues.isExcludedFromBackup = true
924
+ var mutableFile = file
925
+ try mutableFile.setResourceValues(resourceValues)
926
+ }
927
+ }
928
+
929
+ func storedNativeBuildVersion() -> String {
930
+ UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
931
+ }
932
+
933
+ func hasNativeBuildVersionChanged() -> Bool {
934
+ let previous = self.storedNativeBuildVersion()
935
+ return previous != "0" && self.currentBuildVersion != previous
936
+ }
937
+
938
+ func reportNativeVersionStatsIfChanged() {
939
+ self.reportNativeVersionStatsIfChanged(
940
+ currentVersionBuild: self.implementation.versionBuild,
941
+ currentVersionCode: self.currentBuildVersion,
942
+ currentVersionOs: UIDevice.current.systemVersion
943
+ )
944
+ }
945
+
946
+ func reportNativeVersionStatsIfChanged(currentVersionBuild: String?, currentVersionCode: String?, currentVersionOs: String?) {
947
+ let normalizedVersionBuild = self.normalizedStatsValue(currentVersionBuild)
948
+ let normalizedVersionCode = self.normalizedStatsValue(currentVersionCode)
949
+ let normalizedVersionOs = self.normalizedStatsValue(currentVersionOs)
950
+ let userDefaults = UserDefaults.standard
951
+ let previousVersionOs = userDefaults.string(forKey: self.lastVersionOsDefaultsKey) ?? ""
952
+ let previousVersionBuild = userDefaults.string(forKey: self.lastVersionBuildDefaultsKey) ?? ""
953
+ let previousVersionCode = userDefaults.string(forKey: self.lastVersionCodeDefaultsKey) ?? ""
954
+ let osVersionChanged = !normalizedVersionOs.isEmpty && !previousVersionOs.isEmpty && previousVersionOs != normalizedVersionOs
955
+
956
+ if osVersionChanged {
957
+ self.implementation.sendStats(
958
+ action: self.osVersionChangedAction,
959
+ versionName: self.implementation.getCurrentBundle().getVersionName(),
960
+ oldVersionName: "",
961
+ metadata: [
962
+ "previous_version_os": previousVersionOs,
963
+ "current_version_os": normalizedVersionOs
964
+ ],
965
+ onSent: { [weak self] in
966
+ self?.persistLastVersionOs(normalizedVersionOs)
967
+ }
968
+ )
969
+ }
970
+
971
+ let hasPreviousNativeVersion = !previousVersionBuild.isEmpty || !previousVersionCode.isEmpty
972
+ let nativeVersionChanged = hasPreviousNativeVersion &&
973
+ (previousVersionBuild != normalizedVersionBuild || previousVersionCode != normalizedVersionCode)
974
+ if nativeVersionChanged {
975
+ self.implementation.sendStats(
976
+ action: self.nativeAppVersionChangedAction,
977
+ versionName: self.implementation.getCurrentBundle().getVersionName(),
978
+ oldVersionName: "",
979
+ metadata: [
980
+ "previous_version_build": previousVersionBuild,
981
+ "current_version_build": normalizedVersionBuild,
982
+ "previous_version_code": previousVersionCode,
983
+ "current_version_code": normalizedVersionCode
984
+ ],
985
+ onSent: { [weak self] in
986
+ self?.persistLastNativeAppVersion(build: normalizedVersionBuild, code: normalizedVersionCode)
987
+ }
988
+ )
989
+ }
990
+
991
+ if !osVersionChanged || !nativeVersionChanged {
992
+ if !osVersionChanged {
993
+ userDefaults.set(normalizedVersionOs, forKey: self.lastVersionOsDefaultsKey)
994
+ }
995
+ if !nativeVersionChanged {
996
+ userDefaults.set(normalizedVersionBuild, forKey: self.lastVersionBuildDefaultsKey)
997
+ userDefaults.set(normalizedVersionCode, forKey: self.lastVersionCodeDefaultsKey)
998
+ }
999
+ userDefaults.synchronize()
1000
+ }
1001
+ }
1002
+
1003
+ private func persistLastVersionOs(_ versionOs: String) {
1004
+ UserDefaults.standard.set(versionOs, forKey: self.lastVersionOsDefaultsKey)
1005
+ UserDefaults.standard.synchronize()
1006
+ }
1007
+
1008
+ private func persistLastNativeAppVersion(build: String, code: String) {
1009
+ UserDefaults.standard.set(build, forKey: self.lastVersionBuildDefaultsKey)
1010
+ UserDefaults.standard.set(code, forKey: self.lastVersionCodeDefaultsKey)
1011
+ UserDefaults.standard.synchronize()
1012
+ }
1013
+
1014
+ private func normalizedStatsValue(_ value: String?) -> String {
1015
+ value ?? ""
1016
+ }
1017
+
1018
+ @discardableResult
1019
+ func resetCurrentBundleForNativeBuildChangeIfNeeded() -> Bool {
1020
+ let previous = self.storedNativeBuildVersion()
1021
+ guard previous != "0" && self.currentBuildVersion != previous else {
1022
+ return false
1023
+ }
1024
+
1025
+ // Reset startup state synchronously so initialLoad() boots from the builtin bundle.
1026
+ self.logger.info("Native build version changed from \(previous) to \(self.currentBuildVersion). Resetting startup bundle to builtin.")
1027
+ self.implementation.reset(isInternal: true)
1028
+ return true
1029
+ }
1030
+
1031
+ private func cleanupObsoleteVersions(didResetCurrentBundle: Bool = false) {
1032
+ cleanupThread = Thread {
1033
+ self.cleanupLock.lock()
1034
+ defer {
1035
+ self.cleanupComplete = true
1036
+ self.cleanupLock.unlock()
1037
+ self.logger.info("Cleanup complete")
1038
+ }
1039
+
1040
+ // Michael (WcaleNieWolny) at 04.01.2026
1041
+ // The following line of code contains a bug. After having evaluated it, I have decided not to fix it.
1042
+ // The initial report: https://discord.com/channels/912707985829163099/1456985639345061969
1043
+ // The bug happens in a very specific scenario. Here is the reproduction steps, followed by the lackof busniess impact
1044
+ // Reproduction steps:
1045
+ // 1. Install iOS app via app store. Version: 10.13.0. Version v10 of the app uses Capacitor 6 (6.3.13) - a version where the key was still "LatestVersionNative"
1046
+ // 2. The plugin writes "10.13.0" to the key "LatestVersionNative"
1047
+ // 3. Update the app to version 10.17.0 via Capgo.
1048
+ // 4. Update the app via testflight to version 11.0.0. This version uses Capacitor 8 (8.41.3) - a version where the key was changed to "LatestNativeBuildVersion"
1049
+ // 5. During the initial load of then new native version, the plugin will read "LatestNativeBuildVersion", not find it, read "LatestVersionNative", find it and revert to builtin version sucessfully.
1050
+ // 6. The plugin writes "11.0.0" to the key "LatestNativeBuildVersion"
1051
+ // 7. The app is now in a state where it is using the builtin version, but the key "LatestNativeBuildVersion" is still set to "11.0.0" and "LatestVersionNative" is still set to "10.13.0".
1052
+ // 8. The user downgrades using app store back to version 10.13.0.
1053
+ // 9. The old plugin reads "LatestVersionNative", finds "10.13.0," so it doesn't revert to builtin version. // <--- THIS IS THE FIRST PART OF THE BUG
1054
+ // 10. "LatestVersionNative" is written to "10.13.0" but "LatestNativeBuildVersion" is not touched, and stays at "11.0.0"
1055
+ // 11. A capgo update happesn to version 10.17.0.
1056
+ // 12. The user updates again to version 11.0.0 via Testflight.
1057
+ // 13. The plugin reads "LatestNativeBuildVersion", finds "11.0.0", so it doesn't revert to builtin version. It is unaware of the native update that happended.
1058
+ // 14. Capgo loads the 10.13.0 version, while it should have loaded the builtin 11.0.0 version. // <--- THIS IS THE SECOND PART OF THE BUG
1059
+ // The business impact:
1060
+ // None - no one will ever be affected by this bug as reverting via app store should in practice never happen. You are not SUPPOSE to go from Capacitor v8 to v6.
1061
+ // Downgrading isn't supported.
1062
+ // Possible fixes:
1063
+ // 1. Write "LatestVersionNative" - this fixes the part 1 of this bug
1064
+ // 2. Compare both keys. If any is not equal to "currentBuildVersion", then revert to builtin version. This fixes the part 2 of this bug
1065
+
1066
+ let previous = self.storedNativeBuildVersion()
1067
+ if previous != "0" && self.currentBuildVersion != previous {
1068
+ if !didResetCurrentBundle {
1069
+ self.logger.info("Native build version changed from \(previous) to \(self.currentBuildVersion). Resetting current bundle to builtin.")
1070
+ self.implementation.reset(isInternal: true)
1071
+ }
1072
+ let res = self.implementation.list()
1073
+ for version in res {
1074
+ // Check if thread was cancelled
1075
+ if Thread.current.isCancelled {
1076
+ self.logger.warn("Cleanup was cancelled, stopping")
1077
+ return
1078
+ }
1079
+ self.logger.info("Deleting obsolete bundle: \(version.getId())")
1080
+ let res = self.implementation.delete(id: version.getId())
1081
+ if !res {
1082
+ self.logger.error("Delete failed, id \(version.getId()) doesn't exist")
1083
+ }
1084
+ }
1085
+
1086
+ let storedBundles = self.implementation.list(raw: true)
1087
+ let allowedIds = Set(storedBundles.compactMap { info -> String? in
1088
+ let id = info.getId()
1089
+ return id.isEmpty ? nil : id
1090
+ })
1091
+ self.implementation.cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: Thread.current)
1092
+ self.implementation.cleanupOrphanedTempFolders(threadToCheck: Thread.current)
1093
+
1094
+ // Check again before the expensive delta cache cleanup
1095
+ if Thread.current.isCancelled {
1096
+ self.logger.warn("Cleanup was cancelled before delta cache cleanup")
1097
+ return
1098
+ }
1099
+ self.implementation.cleanupDeltaCache(threadToCheck: Thread.current)
1100
+ }
1101
+ UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
1102
+ UserDefaults.standard.synchronize()
1103
+ }
1104
+ cleanupThread?.start()
1105
+
1106
+ // Start a timeout watchdog thread to cancel cleanup if it takes too long
1107
+ let timeout = Double(self.appReadyTimeout / 2) / 1000.0
1108
+ Thread.detachNewThread {
1109
+ Thread.sleep(forTimeInterval: timeout)
1110
+ if let thread = self.cleanupThread, !thread.isFinished && !self.cleanupComplete {
1111
+ self.logger.warn("Cleanup timeout exceeded (\(timeout)s), cancelling cleanup thread")
1112
+ thread.cancel()
1113
+ }
1114
+ }
1115
+ }
1116
+
1117
+ private func waitForCleanupIfNeeded() {
1118
+ if cleanupComplete {
1119
+ return // Already done, no need to wait
1120
+ }
1121
+
1122
+ logger.info("Waiting for cleanup to complete before starting download...")
1123
+
1124
+ // Wait for cleanup to complete - blocks until lock is released
1125
+ cleanupLock.lock()
1126
+ cleanupLock.unlock()
1127
+
1128
+ logger.info("Cleanup finished, proceeding with download")
1129
+ }
1130
+
1131
+ private func resolveCall(_ call: CAPPluginCall, data: PluginCallResultData? = nil) {
1132
+ let resolve = {
1133
+ let targetCall = self.savedCalls.removeValue(forKey: call.callbackId) ?? call
1134
+ if let data {
1135
+ targetCall.resolve(data)
1136
+ } else {
1137
+ targetCall.resolve()
1138
+ }
1139
+ }
1140
+
1141
+ if Thread.isMainThread {
1142
+ resolve()
1143
+ } else {
1144
+ DispatchQueue.main.async {
1145
+ resolve()
1146
+ }
1147
+ }
1148
+ }
1149
+
1150
+ private func rejectCall(_ call: CAPPluginCall, message: String, code: String? = nil, error: Error? = nil, data: PluginCallResultData? = nil) {
1151
+ let reject = {
1152
+ let targetCall = self.savedCalls.removeValue(forKey: call.callbackId) ?? call
1153
+ targetCall.reject(message, code, error: error, data: data)
1154
+ }
1155
+
1156
+ if Thread.isMainThread {
1157
+ reject()
1158
+ } else {
1159
+ DispatchQueue.main.async {
1160
+ reject()
1161
+ }
1162
+ }
1163
+ }
1164
+
1165
+ private func saveCallForAsyncHandling(_ call: CAPPluginCall) {
1166
+ listenerStateQueue.sync {
1167
+ savedCalls[call.callbackId] = call
1168
+ }
1169
+ }
1170
+
1171
+ private func notifyListenersOnMain(_ eventName: String, data: JSObject) {
1172
+ let notify = {
1173
+ self.notifyJSListeners(eventName, data: data)
1174
+ }
1175
+
1176
+ if Thread.isMainThread {
1177
+ notify()
1178
+ } else {
1179
+ DispatchQueue.main.async {
1180
+ notify()
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ func notifyJSListeners(_ eventName: String, data: JSObject) {
1186
+ let targetCalls: [CordovaPluginCall] = listenerStateQueue.sync {
1187
+ cordovaListeners.values
1188
+ .filter { $0.eventName == eventName }
1189
+ .compactMap { savedCalls[$0.callbackId] }
1190
+ }
1191
+ for call in targetCalls {
1192
+ call.sendKeepAliveResult(data)
1193
+ }
1194
+ }
1195
+
1196
+ func addListener(_ call: CAPPluginCall) {
1197
+ guard let eventName = call.getString("eventName"), !eventName.isEmpty else {
1198
+ call.reject("eventName must be provided.")
1199
+ return
1200
+ }
1201
+ guard let listenerId = call.getString("listenerId"), !listenerId.isEmpty else {
1202
+ call.reject("listenerId must be provided.")
1203
+ return
1204
+ }
1205
+ listenerStateQueue.sync {
1206
+ savedCalls[call.callbackId] = call
1207
+ cordovaListeners[listenerId] = ListenerRegistration(eventName: eventName, callbackId: call.callbackId)
1208
+ }
1209
+ call.sendNoResultKeepAlive()
1210
+ }
1211
+
1212
+ func removeListener(_ call: CAPPluginCall) {
1213
+ guard let listenerId = call.getString("listenerId"), !listenerId.isEmpty else {
1214
+ call.reject("listenerId must be provided.")
1215
+ return
1216
+ }
1217
+ listenerStateQueue.sync {
1218
+ if let registration = cordovaListeners.removeValue(forKey: listenerId) {
1219
+ savedCalls.removeValue(forKey: registration.callbackId)
1220
+ }
1221
+ }
1222
+ call.resolve()
1223
+ }
1224
+
1225
+ func removeAllListeners(_ call: CAPPluginCall) {
1226
+ listenerStateQueue.sync {
1227
+ let callbackIds = Set(cordovaListeners.values.map { $0.callbackId })
1228
+ for callbackId in callbackIds {
1229
+ savedCalls.removeValue(forKey: callbackId)
1230
+ }
1231
+ cordovaListeners.removeAll()
1232
+ }
1233
+ call.resolve()
1234
+ }
1235
+
1236
+ var updaterWebView: WKWebView? {
1237
+ #if SWIFT_PACKAGE
1238
+ return (self.webView as? WKWebView) ?? (self.webViewEngine as? WKWebView)
1239
+ #else
1240
+ return (self.webView as? WKWebView) ?? (self.webViewEngine?.engineWebView as? WKWebView)
1241
+ #endif
1242
+ }
1243
+
1244
+ private func bundlePayload(_ bundleInfo: BundleInfo) -> JSObject {
1245
+ var payload: JSObject = [:]
1246
+ for (key, value) in bundleInfo.toJSON() {
1247
+ payload[key] = value
1248
+ }
1249
+ return payload
1250
+ }
1251
+
1252
+ @objc func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
1253
+ let bundleInfo = bundle ?? self.implementation.getBundleInfo(id: id)
1254
+ var downloadPayload: JSObject = [:]
1255
+ downloadPayload["percent"] = percent
1256
+ downloadPayload["bundle"] = bundlePayload(bundleInfo)
1257
+ self.notifyListenersOnMain("download", data: downloadPayload)
1258
+ if percent == 100 {
1259
+ var downloadCompletePayload: JSObject = [:]
1260
+ downloadCompletePayload["bundle"] = bundlePayload(bundleInfo)
1261
+ self.notifyListenersOnMain("downloadComplete", data: downloadCompletePayload)
1262
+ self.implementation.sendStats(action: "download_complete", versionName: bundleInfo.getVersionName())
1263
+ } else if percent.isMultiple(of: 10) || ignoreMultipleOfTen {
1264
+ self.implementation.sendStats(action: "download_\(percent)", versionName: bundleInfo.getVersionName())
1265
+ }
1266
+ }
1267
+
1268
+ func setUpdateUrl(_ call: CAPPluginCall) {
1269
+ if !readConfigBool("allowModifyUrl", false) {
1270
+ logger.error("setUpdateUrl called without allowModifyUrl")
1271
+ call.reject("setUpdateUrl called without allowModifyUrl set allowModifyUrl in your config to true to allow it")
1272
+ return
1273
+ }
1274
+ guard let url = call.getString("url") else {
1275
+ logger.error("setUpdateUrl called without url")
1276
+ call.reject("setUpdateUrl called without url")
1277
+ return
1278
+ }
1279
+ self.updateUrl = url
1280
+ if persistModifyUrl {
1281
+ UserDefaults.standard.set(url, forKey: updateUrlDefaultsKey)
1282
+ UserDefaults.standard.synchronize()
1283
+ }
1284
+ call.resolve()
1285
+ }
1286
+
1287
+ func getUpdateUrl() -> String {
1288
+ return updateUrl
1289
+ }
1290
+
1291
+ func setStatsUrl(_ call: CAPPluginCall) {
1292
+ if !readConfigBool("allowModifyUrl", false) {
1293
+ logger.error("setStatsUrl called without allowModifyUrl")
1294
+ call.reject("setStatsUrl called without allowModifyUrl set allowModifyUrl in your config to true to allow it")
1295
+ return
1296
+ }
1297
+ guard let url = call.getString("url") else {
1298
+ logger.error("setStatsUrl called without url")
1299
+ call.reject("setStatsUrl called without url")
1300
+ return
1301
+ }
1302
+ self.implementation.statsUrl = url
1303
+ if persistModifyUrl {
1304
+ UserDefaults.standard.set(url, forKey: statsUrlDefaultsKey)
1305
+ UserDefaults.standard.synchronize()
1306
+ }
1307
+ call.resolve()
1308
+ }
1309
+
1310
+ func setChannelUrl(_ call: CAPPluginCall) {
1311
+ if !readConfigBool("allowModifyUrl", false) {
1312
+ logger.error("setChannelUrl called without allowModifyUrl")
1313
+ call.reject("setChannelUrl called without allowModifyUrl set allowModifyUrl in your config to true to allow it")
1314
+ return
1315
+ }
1316
+ guard let url = call.getString("url") else {
1317
+ logger.error("setChannelUrl called without url")
1318
+ call.reject("setChannelUrl called without url")
1319
+ return
1320
+ }
1321
+ self.implementation.channelUrl = url
1322
+ if persistModifyUrl {
1323
+ UserDefaults.standard.set(url, forKey: channelUrlDefaultsKey)
1324
+ UserDefaults.standard.synchronize()
1325
+ }
1326
+ call.resolve()
1327
+ }
1328
+
1329
+ func getBuiltinVersion(_ call: CAPPluginCall) {
1330
+ call.resolve(["version": implementation.versionBuild])
1331
+ }
1332
+
1333
+ func getDeviceId(_ call: CAPPluginCall) {
1334
+ call.resolve(["deviceId": implementation.deviceID])
1335
+ }
1336
+
1337
+ func getPluginVersion(_ call: CAPPluginCall) {
1338
+ call.resolve(["version": self.pluginVersion])
1339
+ }
1340
+
1341
+ private func manifestEntries(from manifestArray: [Any]?) -> [ManifestEntry]? {
1342
+ guard let manifestArray = manifestArray else {
1343
+ return nil
1344
+ }
1345
+ var manifestEntries: [ManifestEntry] = []
1346
+ for item in manifestArray {
1347
+ if let manifestDict = item as? [String: Any] {
1348
+ manifestEntries.append(ManifestEntry(
1349
+ file_name: manifestDict["file_name"] as? String,
1350
+ file_hash: manifestDict["file_hash"] as? String,
1351
+ download_url: manifestDict["download_url"] as? String
1352
+ ))
1353
+ }
1354
+ }
1355
+ return manifestEntries
1356
+ }
1357
+
1358
+ private struct PreviewPayload: Decodable {
1359
+ let version: String?
1360
+ let url: String?
1361
+ let checksum: String?
1362
+ let sessionKey: String?
1363
+ let manifest: [ManifestEntry]?
1364
+ let message: String?
1365
+ let error: String?
1366
+ }
1367
+
1368
+ private func normalizedPreviewMetadataValue(_ rawValue: String?) -> String? {
1369
+ guard let rawValue else {
1370
+ return nil
1371
+ }
1372
+
1373
+ let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
1374
+ guard !value.isEmpty else {
1375
+ return nil
1376
+ }
1377
+
1378
+ let lowercased = value.lowercased()
1379
+ guard lowercased != "undefined", lowercased != "null" else {
1380
+ return nil
1381
+ }
1382
+
1383
+ return value
1384
+ }
1385
+
1386
+ private func previewSessions() -> [String: [String: Any]] {
1387
+ guard let rawSessions = UserDefaults.standard.dictionary(forKey: self.previewSessionsDefaultsKey) else {
1388
+ return [:]
1389
+ }
1390
+
1391
+ var sessions: [String: [String: Any]] = [:]
1392
+ for (id, rawValue) in rawSessions {
1393
+ if let session = rawValue as? [String: Any] {
1394
+ sessions[id] = session
1395
+ }
1396
+ }
1397
+ return sessions
1398
+ }
1399
+
1400
+ private func savePreviewSessions(_ sessions: [String: [String: Any]]) {
1401
+ UserDefaults.standard.set(sessions, forKey: self.previewSessionsDefaultsKey)
1402
+ UserDefaults.standard.synchronize()
1403
+ }
1404
+
1405
+ private func metadataString(_ metadata: [String: Any], _ key: String) -> String? {
1406
+ self.normalizedPreviewMetadataValue(metadata[key] as? String)
1407
+ }
1408
+
1409
+ private func currentPreviewMetadataValue(forKey key: String) -> String? {
1410
+ self.normalizedPreviewMetadataValue(UserDefaults.standard.string(forKey: key))
1411
+ }
1412
+
1413
+ private func previewInfo(
1414
+ id: String,
1415
+ metadata: [String: Any],
1416
+ availableBundleIds: Set<String>,
1417
+ currentBundleId: String
1418
+ ) -> [String: Any]? {
1419
+ let bundle = self.implementation.getBundleInfo(id: id)
1420
+ if !bundle.isBuiltin() && !availableBundleIds.contains(id) {
1421
+ return nil
1422
+ }
1423
+ if bundle.isDeleted() || bundle.isErrorStatus() {
1424
+ return nil
1425
+ }
1426
+
1427
+ var info: [String: Any] = [
1428
+ "id": id,
1429
+ "bundle": bundle.toJSON(),
1430
+ "createdAt": self.metadataString(metadata, "createdAt") ?? Date().iso8601withFractionalSeconds,
1431
+ "updatedAt": self.metadataString(metadata, "updatedAt") ?? Date().iso8601withFractionalSeconds,
1432
+ "lastUsedAt": self.metadataString(metadata, "lastUsedAt") ?? Date().iso8601withFractionalSeconds,
1433
+ "isActive": self.previewSessionEnabled && currentBundleId == id
1434
+ ]
1435
+
1436
+ for key in ["name", "source", "appId", "payloadUrl"] {
1437
+ if let value = self.metadataString(metadata, key) {
1438
+ info[key] = value
1439
+ }
1440
+ }
1441
+
1442
+ return info
1443
+ }
1444
+
1445
+ private func listPreviewInfos(cleanup: Bool = true) -> [[String: Any]] {
1446
+ let availableBundleIds = Set(self.implementation.list().map { $0.getId() })
1447
+ let currentBundleId = self.implementation.getCurrentBundleId()
1448
+ var sessions = self.previewSessions()
1449
+ var previews: [[String: Any]] = []
1450
+ var staleIds: [String] = []
1451
+
1452
+ for (id, metadata) in sessions {
1453
+ if let info = self.previewInfo(
1454
+ id: id,
1455
+ metadata: metadata,
1456
+ availableBundleIds: availableBundleIds,
1457
+ currentBundleId: currentBundleId
1458
+ ) {
1459
+ previews.append(info)
1460
+ } else {
1461
+ staleIds.append(id)
1462
+ }
1463
+ }
1464
+
1465
+ if cleanup && !staleIds.isEmpty {
1466
+ for id in staleIds {
1467
+ sessions.removeValue(forKey: id)
1468
+ }
1469
+ self.savePreviewSessions(sessions)
1470
+ }
1471
+
1472
+ return previews.sorted { first, second in
1473
+ let firstUsed = first["lastUsedAt"] as? String ?? ""
1474
+ let secondUsed = second["lastUsedAt"] as? String ?? ""
1475
+ return firstUsed > secondUsed
1476
+ }
1477
+ }
1478
+
1479
+ private func storedPreviewInfo(id: String) -> [String: Any]? {
1480
+ let sessions = self.previewSessions()
1481
+ guard let metadata = sessions[id] else {
1482
+ return nil
1483
+ }
1484
+ let availableBundleIds = Set(self.implementation.list().map { $0.getId() })
1485
+ return self.previewInfo(
1486
+ id: id,
1487
+ metadata: metadata,
1488
+ availableBundleIds: availableBundleIds,
1489
+ currentBundleId: self.implementation.getCurrentBundleId()
1490
+ )
1491
+ }
1492
+
1493
+ @discardableResult
1494
+ private func recordPreviewBundle(_ bundle: BundleInfo, replacing oldId: String? = nil) -> [String: Any] {
1495
+ let now = Date().iso8601withFractionalSeconds
1496
+ var sessions = self.previewSessions()
1497
+ let id = bundle.getId()
1498
+ let replacingPreview = oldId.map { $0 != id } ?? false
1499
+ var metadata = sessions[id] ?? (replacingPreview ? sessions[oldId ?? ""] ?? [:] : [:])
1500
+
1501
+ if metadata["createdAt"] == nil {
1502
+ metadata["createdAt"] = now
1503
+ }
1504
+ metadata["updatedAt"] = now
1505
+ if metadata["lastUsedAt"] == nil || self.implementation.getCurrentBundleId() == id {
1506
+ metadata["lastUsedAt"] = now
1507
+ }
1508
+ metadata["version"] = bundle.getVersionName()
1509
+
1510
+ if !replacingPreview {
1511
+ if let appId = self.currentPreviewMetadataValue(forKey: self.previewAppIdDefaultsKey) {
1512
+ metadata["appId"] = appId
1513
+ } else {
1514
+ metadata.removeValue(forKey: "appId")
1515
+ }
1516
+
1517
+ if let payloadUrl = self.currentPreviewMetadataValue(forKey: self.previewPayloadUrlDefaultsKey) {
1518
+ metadata["payloadUrl"] = payloadUrl
1519
+ } else {
1520
+ metadata.removeValue(forKey: "payloadUrl")
1521
+ }
1522
+ }
1523
+
1524
+ if !replacingPreview {
1525
+ if let name = self.currentPreviewMetadataValue(forKey: self.previewNameDefaultsKey) {
1526
+ metadata["name"] = name
1527
+ } else {
1528
+ metadata.removeValue(forKey: "name")
1529
+ }
1530
+ }
1531
+ if self.metadataString(metadata, "name") == nil {
1532
+ metadata["name"] = bundle.getVersionName()
1533
+ }
1534
+
1535
+ if !replacingPreview {
1536
+ if let source = self.currentPreviewMetadataValue(forKey: self.previewSourceDefaultsKey) {
1537
+ metadata["source"] = source
1538
+ } else {
1539
+ metadata.removeValue(forKey: "source")
1540
+ }
1541
+ }
1542
+
1543
+ if let oldId, oldId != id {
1544
+ sessions.removeValue(forKey: oldId)
1545
+ }
1546
+ sessions[id] = metadata
1547
+ self.savePreviewSessions(sessions)
1548
+
1549
+ return self.storedPreviewInfo(id: id) ?? [
1550
+ "id": id,
1551
+ "bundle": bundle.toJSON(),
1552
+ "createdAt": now,
1553
+ "updatedAt": now,
1554
+ "lastUsedAt": now,
1555
+ "isActive": self.previewSessionEnabled && self.implementation.getCurrentBundleId() == id
1556
+ ]
1557
+ }
1558
+
1559
+ private func updateCurrentPreviewSessionMetadata(from preview: [String: Any]) {
1560
+ if let appId = self.metadataString(preview, "appId") {
1561
+ self.implementation.appId = appId
1562
+ UserDefaults.standard.set(appId, forKey: self.previewAppIdDefaultsKey)
1563
+ } else {
1564
+ self.restorePreviewPreviousAppId()
1565
+ UserDefaults.standard.removeObject(forKey: self.previewAppIdDefaultsKey)
1566
+ }
1567
+
1568
+ if let payloadUrl = self.metadataString(preview, "payloadUrl") {
1569
+ UserDefaults.standard.set(payloadUrl, forKey: self.previewPayloadUrlDefaultsKey)
1570
+ } else {
1571
+ UserDefaults.standard.removeObject(forKey: self.previewPayloadUrlDefaultsKey)
1572
+ }
1573
+
1574
+ if let name = self.metadataString(preview, "name") {
1575
+ UserDefaults.standard.set(name, forKey: self.previewNameDefaultsKey)
1576
+ } else {
1577
+ UserDefaults.standard.removeObject(forKey: self.previewNameDefaultsKey)
1578
+ }
1579
+
1580
+ if let source = self.metadataString(preview, "source") {
1581
+ UserDefaults.standard.set(source, forKey: self.previewSourceDefaultsKey)
1582
+ } else {
1583
+ UserDefaults.standard.removeObject(forKey: self.previewSourceDefaultsKey)
1584
+ }
1585
+ UserDefaults.standard.synchronize()
1586
+ }
1587
+
1588
+ private func makePreviewError(_ message: String) -> NSError {
1589
+ NSError(domain: "CapacitorUpdaterPreview", code: 0, userInfo: [NSLocalizedDescriptionKey: message])
1590
+ }
1591
+
1592
+ private func downloadBundle(urlString: String, version: String, sessionKey: String, checksum rawChecksum: String, manifestEntries: [ManifestEntry]?) throws -> BundleInfo {
1593
+ guard let url = URL(string: urlString) else {
1594
+ throw makePreviewError("Invalid download URL")
1595
+ }
1596
+
1597
+ var checksum = rawChecksum
1598
+ let next: BundleInfo
1599
+ if let manifestEntries = manifestEntries {
1600
+ next = try self.implementation.downloadManifest(manifest: manifestEntries, version: version, sessionKey: sessionKey)
1601
+ } else {
1602
+ next = try self.implementation.download(url: url, version: version, sessionKey: sessionKey)
1603
+ }
1604
+
1605
+ if self.implementation.publicKey != "" && checksum == "" {
1606
+ self.logger.error("Public key present but no checksum provided")
1607
+ self.implementation.sendStats(action: "checksum_required", versionName: next.getVersionName())
1608
+ let id = next.getId()
1609
+ let resDel = self.implementation.delete(id: id)
1610
+ if !resDel {
1611
+ self.logger.error("Delete failed, id \(id) doesn't exist")
1612
+ }
1613
+ throw ObjectSavableError.checksum
1614
+ }
1615
+
1616
+ checksum = try CryptoCipher.decryptChecksum(checksum: checksum, publicKey: self.implementation.publicKey)
1617
+ CryptoCipher.logChecksumInfo(label: "Bundle checksum", hexChecksum: next.getChecksum())
1618
+ CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: checksum)
1619
+ if (checksum != "" || self.implementation.publicKey != "") && next.getChecksum() != checksum {
1620
+ self.logger.error("Error checksum \(next.getChecksum()) \(checksum)")
1621
+ self.implementation.sendStats(action: "checksum_fail", versionName: next.getVersionName())
1622
+ let id = next.getId()
1623
+ let resDel = self.implementation.delete(id: id)
1624
+ if !resDel {
1625
+ self.logger.error("Delete failed, id \(id) doesn't exist")
1626
+ }
1627
+ throw ObjectSavableError.checksum
1628
+ }
1629
+
1630
+ self.logger.info("Good checksum \(next.getChecksum()) \(checksum)")
1631
+ return next
1632
+ }
1633
+
1634
+ func download(_ call: CAPPluginCall) {
1635
+ guard let urlString = call.getString("url") else {
1636
+ logger.error("Download called without url")
1637
+ call.reject("Download called without url")
1638
+ return
1639
+ }
1640
+ guard let version = call.getString("version") else {
1641
+ logger.error("Download called without version")
1642
+ call.reject("Download called without version")
1643
+ return
1644
+ }
1645
+
1646
+ let sessionKey = call.getString("sessionKey", "")
1647
+ let checksum = call.getString("checksum", "")
1648
+ let manifestArray = call.getArray("manifest")
1649
+ logger.info("Downloading \(urlString)")
1650
+ self.saveCallForAsyncHandling(call)
1651
+ self.runBackgroundDownloadWork {
1652
+ do {
1653
+ let next = try self.downloadBundle(
1654
+ urlString: urlString,
1655
+ version: version,
1656
+ sessionKey: sessionKey,
1657
+ checksum: checksum,
1658
+ manifestEntries: self.manifestEntries(from: manifestArray)
1659
+ )
1660
+ var updateAvailablePayload: JSObject = [:]
1661
+ updateAvailablePayload["bundle"] = self.bundlePayload(next)
1662
+ self.notifyListenersOnMain("updateAvailable", data: updateAvailablePayload)
1663
+ self.resolveCall(call, data: next.toJSON())
1664
+ } catch {
1665
+ self.logger.error("Failed to download from: \(urlString) \(error.localizedDescription)")
1666
+ var downloadFailedPayload: JSObject = [:]
1667
+ downloadFailedPayload["version"] = version
1668
+ self.notifyListenersOnMain("downloadFailed", data: downloadFailedPayload)
1669
+ self.implementation.sendStats(action: "download_fail")
1670
+ self.rejectCall(call, message: "Failed to download from: \(urlString) - \(error.localizedDescription)")
1671
+ }
1672
+ }
1673
+ }
1674
+
1675
+ private func currentReloadDestination() -> URL {
1676
+ let id = self.implementation.getCurrentBundleId()
1677
+ if BundleInfo.ID_BUILTIN == id {
1678
+ return Bundle.main.resourceURL!.appendingPathComponent("public")
1679
+ } else {
1680
+ return self.implementation.getBundleDirectory(id: id)
1681
+ }
1682
+ }
1683
+
1684
+ private func applyCurrentBundleToSchemeHandler() -> Bool {
1685
+ let id = self.implementation.getCurrentBundleId()
1686
+ let dest = self.currentReloadDestination()
1687
+ logger.info("Reloading \(id)")
1688
+
1689
+ if self.keepUrlPathAfterReload {
1690
+ self.syncKeepUrlPathFlag(enabled: true)
1691
+ }
1692
+
1693
+ self.schemeHandler?.activeBundleDir = dest
1694
+
1695
+ let reloadWebView = {
1696
+ guard let webView = self.updaterWebView else {
1697
+ self.logger.error("Cannot get webView for reload")
1698
+ return
1699
+ }
1700
+ if self.keepUrlPathAfterReload {
1701
+ webView.reload()
1702
+ } else {
1703
+ let fallback = URL(string: "https://localhost/index.html")!
1704
+ let target = webView.url ?? fallback
1705
+ webView.load(URLRequest(url: target))
1706
+ }
1707
+ }
1708
+
1709
+ if Thread.isMainThread {
1710
+ reloadWebView()
1711
+ } else {
1712
+ DispatchQueue.main.async {
1713
+ reloadWebView()
1714
+ }
1715
+ }
1716
+ return true
1717
+ }
1718
+
1719
+ func restoreLiveBundleStateAfterFailedReload() {
1720
+ let restoreLiveState = {
1721
+ _ = self.applyCurrentBundleToSchemeHandler()
1722
+ }
1723
+
1724
+ if Thread.isMainThread {
1725
+ restoreLiveState()
1726
+ } else {
1727
+ DispatchQueue.main.sync {
1728
+ restoreLiveState()
1729
+ }
1730
+ }
1731
+ }
1732
+
1733
+ public func _reload() -> Bool {
1734
+ self.semaphoreUp()
1735
+
1736
+ let performReload: () -> Bool = {
1737
+ guard self.applyCurrentBundleToSchemeHandler() else {
1738
+ return false
1739
+ }
1740
+ self.checkAppReady()
1741
+ self.notifyJSListeners("appReloaded", data: [:])
1742
+ return true
1743
+ }
1744
+
1745
+ if Thread.isMainThread {
1746
+ return performReload()
1747
+ } else {
1748
+ var result = false
1749
+ DispatchQueue.main.sync {
1750
+ result = performReload()
1751
+ }
1752
+ return result
1753
+ }
1754
+ }
1755
+
1756
+ func reloadWithoutWaitingForAppReady() -> Bool {
1757
+ let performReload: () -> Bool = {
1758
+ guard self.applyCurrentBundleToSchemeHandler() else {
1759
+ return false
1760
+ }
1761
+ self.checkAppReady()
1762
+ self.notifyJSListeners("appReloaded", data: [:])
1763
+ return true
1764
+ }
1765
+
1766
+ if Thread.isMainThread {
1767
+ return performReload()
1768
+ } else {
1769
+ var result = false
1770
+ DispatchQueue.main.sync {
1771
+ result = performReload()
1772
+ }
1773
+ return result
1774
+ }
1775
+ }
1776
+
1777
+ func reload(_ call: CAPPluginCall) {
1778
+ let current: BundleInfo = self.implementation.getCurrentBundle()
1779
+ let next: BundleInfo? = self.implementation.getNextBundle()
1780
+
1781
+ if !self.isPreviewSessionStateActive(),
1782
+ let next = next,
1783
+ !next.isErrorStatus(),
1784
+ next.getId() != current.getId() {
1785
+ let previousState = self.implementation.captureResetState()
1786
+ let previousBundleName = self.implementation.getCurrentBundle().getVersionName()
1787
+ logger.info("Applying pending bundle before reload: \(next.toString())")
1788
+ let didApplyPendingBundle: Bool
1789
+ if next.isBuiltin() {
1790
+ self.implementation.prepareResetStateForTransition()
1791
+ didApplyPendingBundle = true
1792
+ } else {
1793
+ didApplyPendingBundle = self.implementation.stagePendingReload(bundle: next)
1794
+ }
1795
+ if didApplyPendingBundle && self._reload() {
1796
+ if next.isBuiltin() {
1797
+ self.implementation.finalizeResetTransition(previousBundleName: previousBundleName, isInternal: false)
1798
+ } else {
1799
+ self.implementation.finalizePendingReload(bundle: next, previousBundleName: previousBundleName)
1800
+ }
1801
+ self.notifyBundleSet(next)
1802
+ _ = self.implementation.setNextBundle(next: Optional<String>.none)
1803
+ self.showPreviewSessionNoticeIfNeeded()
1804
+ call.resolve()
1805
+ return
1806
+ }
1807
+ self.implementation.restoreResetState(previousState)
1808
+ self.restoreLiveBundleStateAfterFailedReload()
1809
+ logger.error("Reload failed after applying pending bundle: \(next.toString())")
1810
+ call.reject("Reload failed after applying pending bundle")
1811
+ return
1812
+ }
1813
+
1814
+ if self._reload() {
1815
+ self.showPreviewSessionNoticeIfNeeded()
1816
+ call.resolve()
1817
+ } else {
1818
+ logger.error("Reload failed")
1819
+ call.reject("Reload failed")
1820
+ }
1821
+ }
1822
+
1823
+ private func applyDownloadedBundleForDirectUpdate(_ next: BundleInfo) -> Bool {
1824
+ let previousState = self.implementation.captureResetState()
1825
+ let previousBundleName = self.implementation.getCurrentBundle().getVersionName()
1826
+
1827
+ guard self.implementation.stagePendingReload(bundle: next) else {
1828
+ self.implementation.restoreResetState(previousState)
1829
+ logger.error("Direct update failed to stage downloaded bundle: \(next.toString())")
1830
+ return false
1831
+ }
1832
+
1833
+ if self._reload() {
1834
+ self.implementation.finalizePendingReload(bundle: next, previousBundleName: previousBundleName)
1835
+ _ = self.implementation.setNextBundle(next: Optional<String>.none)
1836
+ return true
1837
+ }
1838
+
1839
+ self.implementation.restoreResetState(previousState)
1840
+ self.restoreLiveBundleStateAfterFailedReload()
1841
+ logger.error("Direct update reload failed after staging bundle: \(next.toString())")
1842
+ return false
1843
+ }
1844
+
1845
+ func next(_ call: CAPPluginCall) {
1846
+ guard let id = call.getString("id") else {
1847
+ logger.error("Next called without id")
1848
+ call.reject("Next called without id")
1849
+ return
1850
+ }
1851
+ logger.info("Setting next active id \(id)")
1852
+ if !self.implementation.setNextBundle(next: id) {
1853
+ logger.error("Set next version failed. id \(id) does not exist.")
1854
+ call.reject("Set next version failed. id \(id) does not exist.")
1855
+ } else {
1856
+ call.resolve(self.implementation.getBundleInfo(id: id).toJSON())
1857
+ }
1858
+ }
1859
+
1860
+ func set(_ call: CAPPluginCall) {
1861
+ guard let id = call.getString("id") else {
1862
+ logger.error("Set called without id")
1863
+ call.reject("Set called without id")
1864
+ return
1865
+ }
1866
+ let res = implementation.set(id: id)
1867
+ logger.info("Set active bundle: \(id)")
1868
+ if !res {
1869
+ logger.info("Bundle successfully set to: \(id) ")
1870
+ call.reject("Update failed, id \(id) doesn't exist")
1871
+ } else if self.previewSessionEnabled {
1872
+ let bundle = self.implementation.getBundleInfo(id: id)
1873
+ _ = self.recordPreviewBundle(bundle)
1874
+ if !self.reloadWithoutWaitingForAppReady() {
1875
+ call.reject("Reload failed after setting preview bundle \(id)")
1876
+ return
1877
+ }
1878
+ self.notifyBundleSet(bundle)
1879
+ self.showPreviewSessionNoticeIfNeeded()
1880
+ call.resolve()
1881
+ } else if !self._reload() {
1882
+ call.reject("Reload failed after setting bundle \(id)")
1883
+ } else {
1884
+ self.notifyBundleSet(self.implementation.getBundleInfo(id: id))
1885
+ self.showPreviewSessionNoticeIfNeeded()
1886
+ call.resolve()
1887
+ }
1888
+ }
1889
+
1890
+ private func isPreviewSessionStateActive() -> Bool {
1891
+ self.previewSessionEnabled || self.isLeavingPreviewForIncomingLink || self.implementation.previewSession
1892
+ }
1893
+
1894
+ private func shouldBlockAutoUpdateForPreviewSession() -> Bool {
1895
+ guard self.isPreviewSessionStateActive() else {
1896
+ return false
1897
+ }
1898
+
1899
+ logger.info("Preview session is active. Skipping normal auto-update work.")
1900
+ return true
1901
+ }
1902
+
1903
+ private func clearIncomingPreviewTransition() {
1904
+ self.previewTransitionClearWorkItem?.cancel()
1905
+ self.previewTransitionClearWorkItem = nil
1906
+ self.isLeavingPreviewForIncomingLink = false
1907
+ if !self.previewSessionEnabled {
1908
+ self.implementation.previewSession = false
1909
+ }
1910
+ }
1911
+
1912
+ private func scheduleIncomingPreviewTransitionFallbackClear() {
1913
+ self.previewTransitionClearWorkItem?.cancel()
1914
+ let workItem = DispatchWorkItem { [weak self] in
1915
+ self?.clearIncomingPreviewTransition()
1916
+ }
1917
+ self.previewTransitionClearWorkItem = workItem
1918
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.appReadyTimeout), execute: workItem)
1919
+ }
1920
+
1921
+ private func preparePreviewFallbackIfNeeded() -> Bool {
1922
+ if self.previewSessionEnabled {
1923
+ return true
1924
+ }
1925
+
1926
+ let current = self.implementation.getCurrentBundle()
1927
+ guard self.implementation.setPreviewFallbackBundle(fallback: current.getId()) else {
1928
+ logger.error("Could not save current bundle as preview fallback")
1929
+ return false
1930
+ }
1931
+
1932
+ if let previousNext = self.implementation.getNextBundle(),
1933
+ !previousNext.isDeleted(),
1934
+ !previousNext.isErrorStatus() {
1935
+ UserDefaults.standard.set(previousNext.getId(), forKey: self.previewPreviousNextBundleDefaultsKey)
1936
+ } else {
1937
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousNextBundleDefaultsKey)
1938
+ }
1939
+
1940
+ UserDefaults.standard.set(self.implementation.appId, forKey: self.previewPreviousAppIdDefaultsKey)
1941
+ let previousDefaultChannel = self.persistedDefaultChannel()
1942
+ guard self.persistDefaultChannelPreviewSnapshot(channel: previousDefaultChannel) else {
1943
+ logger.error("Could not durably save the default channel preview snapshot")
1944
+ return false
1945
+ }
1946
+ if let previousDefaultChannel = previousDefaultChannel {
1947
+ UserDefaults.standard.set(previousDefaultChannel, forKey: self.previewPreviousDefaultChannelDefaultsKey)
1948
+ UserDefaults.standard.set(true, forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
1949
+ } else {
1950
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelDefaultsKey)
1951
+ UserDefaults.standard.set(false, forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
1952
+ }
1953
+ UserDefaults.standard.set(self.shakeMenuEnabled, forKey: self.previewPreviousShakeMenuDefaultsKey)
1954
+ UserDefaults.standard.set(self.shakeChannelSelectorEnabled, forKey: self.previewPreviousShakeChannelSelectorDefaultsKey)
1955
+ logger.info("Preview session started with fallback bundle: \(current.toString())")
1956
+ return true
1957
+ }
1958
+
1959
+ private func activatePreviewSessionState() {
1960
+ self.clearIncomingPreviewTransition()
1961
+ self.hidePreviewTransitionLoader(reason: "preview-session-started")
1962
+ self.previewSessionEnabled = true
1963
+ self.previewSessionAlertPending = true
1964
+ self.implementation.previewSession = true
1965
+ self.shakeMenuEnabled = true
1966
+ self.syncShakeMenuGestureRecognizer()
1967
+ UserDefaults.standard.set(true, forKey: self.previewSessionDefaultsKey)
1968
+ UserDefaults.standard.set(true, forKey: self.previewSessionAlertPendingDefaultsKey)
1969
+ UserDefaults.standard.synchronize()
1970
+ }
1971
+
1972
+ func startPreviewSession(_ call: CAPPluginCall) {
1973
+ guard self.allowPreview else {
1974
+ self.hidePreviewTransitionLoader(reason: "preview-session-not-allowed")
1975
+ logger.error("startPreviewSession called without allowPreview")
1976
+ call.reject("startPreviewSession not allowed. Set allowPreview to true in your config to enable it.")
1977
+ return
1978
+ }
1979
+ let previewAppId = self.normalizedPreviewAppId(call.getString("appId"))
1980
+ let rawPayloadUrl = call.getString("payloadUrl")
1981
+ let previewPayloadUrl = self.normalizedPreviewPayloadUrl(rawPayloadUrl)
1982
+ if let rawPayloadUrl = rawPayloadUrl, !rawPayloadUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, previewPayloadUrl == nil {
1983
+ self.hidePreviewTransitionLoader(reason: "preview-session-invalid-payload")
1984
+ logger.error("startPreviewSession called with invalid payloadUrl")
1985
+ call.reject("Invalid preview payloadUrl")
1986
+ return
1987
+ }
1988
+
1989
+ guard self.preparePreviewFallbackIfNeeded() else {
1990
+ self.hidePreviewTransitionLoader(reason: "preview-session-fallback-failed")
1991
+ call.reject("Could not save current bundle as preview fallback")
1992
+ return
1993
+ }
1994
+
1995
+ if let previewAppId = previewAppId, !previewAppId.isEmpty {
1996
+ self.implementation.appId = previewAppId
1997
+ UserDefaults.standard.set(previewAppId, forKey: self.previewAppIdDefaultsKey)
1998
+ logger.info("Preview session using appId: \(previewAppId)")
1999
+ }
2000
+
2001
+ if let previewPayloadUrl = previewPayloadUrl {
2002
+ UserDefaults.standard.set(previewPayloadUrl.absoluteString, forKey: self.previewPayloadUrlDefaultsKey)
2003
+ logger.info("Preview session using payload URL")
2004
+ } else {
2005
+ UserDefaults.standard.removeObject(forKey: self.previewPayloadUrlDefaultsKey)
2006
+ }
2007
+
2008
+ if let previewName = self.normalizedPreviewMetadataValue(call.getString("name")) {
2009
+ UserDefaults.standard.set(previewName, forKey: self.previewNameDefaultsKey)
2010
+ } else {
2011
+ UserDefaults.standard.removeObject(forKey: self.previewNameDefaultsKey)
2012
+ }
2013
+
2014
+ if let previewSource = self.normalizedPreviewMetadataValue(call.getString("source")) {
2015
+ UserDefaults.standard.set(previewSource, forKey: self.previewSourceDefaultsKey)
2016
+ } else {
2017
+ UserDefaults.standard.removeObject(forKey: self.previewSourceDefaultsKey)
2018
+ }
2019
+
2020
+ self.activatePreviewSessionState()
2021
+ call.resolve()
2022
+ }
2023
+
2024
+ func listPreviews(_ call: CAPPluginCall) {
2025
+ guard self.allowPreview else {
2026
+ call.reject("listPreviews not allowed. Set allowPreview to true in your config to enable it.")
2027
+ return
2028
+ }
2029
+
2030
+ let previews = self.listPreviewInfos()
2031
+ var result: [String: Any] = [
2032
+ "previews": previews,
2033
+ "currentBundle": self.implementation.getCurrentBundle().toJSON()
2034
+ ]
2035
+ if let currentPreview = previews.first(where: { ($0["isActive"] as? Bool) == true }) {
2036
+ result["current"] = currentPreview
2037
+ }
2038
+ if let liveBundle = self.implementation.getPreviewFallbackBundle() {
2039
+ result["liveBundle"] = liveBundle.toJSON()
2040
+ }
2041
+ call.resolve(result)
2042
+ }
2043
+
2044
+ func setPreview(_ call: CAPPluginCall) {
2045
+ guard self.allowPreview else {
2046
+ call.reject("setPreview not allowed. Set allowPreview to true in your config to enable it.")
2047
+ return
2048
+ }
2049
+ guard let id = call.getString("id"), !id.isEmpty else {
2050
+ call.reject("setPreview called without id")
2051
+ return
2052
+ }
2053
+ guard let preview = self.storedPreviewInfo(id: id) else {
2054
+ call.reject("Preview \(id) is not available locally")
2055
+ return
2056
+ }
2057
+
2058
+ self.showPreviewTransitionLoader(reason: "set-preview")
2059
+ DispatchQueue.global(qos: .userInitiated).async {
2060
+ guard self.preparePreviewFallbackIfNeeded() else {
2061
+ self.hidePreviewTransitionLoader(reason: "set-preview-fallback-failed")
2062
+ call.reject("Could not save current bundle as preview fallback")
2063
+ return
2064
+ }
2065
+
2066
+ guard self.implementation.set(id: id) else {
2067
+ self.hidePreviewTransitionLoader(reason: "set-preview-failed")
2068
+ call.reject("Preview \(id) cannot be applied")
2069
+ return
2070
+ }
2071
+
2072
+ let bundle = self.implementation.getBundleInfo(id: id)
2073
+ self.updateCurrentPreviewSessionMetadata(from: preview)
2074
+ self.activatePreviewSessionState()
2075
+ _ = self.recordPreviewBundle(bundle)
2076
+ guard self.reloadWithoutWaitingForAppReady() else {
2077
+ self.hidePreviewTransitionLoader(reason: "set-preview-reload-failed")
2078
+ call.reject("Reload failed after setting preview \(id)")
2079
+ return
2080
+ }
2081
+
2082
+ self.notifyBundleSet(bundle)
2083
+ self.showPreviewSessionNoticeIfNeeded()
2084
+ call.resolve()
2085
+ }
2086
+ }
2087
+
2088
+ func previewMenuPreviews() -> [[String: Any]] {
2089
+ self.listPreviewInfos()
2090
+ }
2091
+
2092
+ func setPreviewFromShakeMenu(id: String) -> Bool {
2093
+ guard self.allowPreview, let preview = self.storedPreviewInfo(id: id) else {
2094
+ return false
2095
+ }
2096
+
2097
+ self.showPreviewTransitionLoader(reason: "set-preview-menu")
2098
+ guard self.preparePreviewFallbackIfNeeded() else {
2099
+ self.hidePreviewTransitionLoader(reason: "set-preview-menu-fallback-failed")
2100
+ return false
2101
+ }
2102
+
2103
+ guard self.implementation.set(id: id) else {
2104
+ self.hidePreviewTransitionLoader(reason: "set-preview-menu-failed")
2105
+ return false
2106
+ }
2107
+
2108
+ let bundle = self.implementation.getBundleInfo(id: id)
2109
+ self.updateCurrentPreviewSessionMetadata(from: preview)
2110
+ self.activatePreviewSessionState()
2111
+ _ = self.recordPreviewBundle(bundle)
2112
+ guard self.reloadWithoutWaitingForAppReady() else {
2113
+ self.hidePreviewTransitionLoader(reason: "set-preview-menu-reload-failed")
2114
+ return false
2115
+ }
2116
+
2117
+ self.notifyBundleSet(bundle)
2118
+ self.showPreviewSessionNoticeIfNeeded()
2119
+ return true
2120
+ }
2121
+
2122
+ func resetPreview(_ call: CAPPluginCall) {
2123
+ guard self.previewSessionEnabled else {
2124
+ call.resolve()
2125
+ return
2126
+ }
2127
+ DispatchQueue.global(qos: .userInitiated).async {
2128
+ if self.leavePreviewSessionFromShakeMenu() {
2129
+ call.resolve()
2130
+ } else {
2131
+ call.reject("Could not leave preview session")
2132
+ }
2133
+ }
2134
+ }
2135
+
2136
+ func deletePreview(_ call: CAPPluginCall) {
2137
+ guard self.allowPreview else {
2138
+ call.reject("deletePreview not allowed. Set allowPreview to true in your config to enable it.")
2139
+ return
2140
+ }
2141
+ guard let id = call.getString("id"), !id.isEmpty else {
2142
+ call.reject("deletePreview called without id")
2143
+ return
2144
+ }
2145
+ if self.previewSessionEnabled && self.implementation.getCurrentBundleId() == id {
2146
+ call.reject("Cannot delete the active preview")
2147
+ return
2148
+ }
2149
+
2150
+ var sessions = self.previewSessions()
2151
+ let removed = sessions.removeValue(forKey: id) != nil
2152
+ self.savePreviewSessions(sessions)
2153
+
2154
+ var deleted = false
2155
+ let fallbackId = self.implementation.getPreviewFallbackBundle()?.getId()
2156
+ let nextId = self.implementation.getNextBundle()?.getId()
2157
+ if removed, id != fallbackId, id != nextId, id != BundleInfo.ID_BUILTIN {
2158
+ deleted = self.implementation.delete(id: id, removeInfo: false)
2159
+ }
2160
+
2161
+ call.resolve(["removed": removed, "deleted": deleted])
2162
+ }
2163
+
2164
+ func checkPreviewUpdate(_ call: CAPPluginCall) {
2165
+ self.handlePreviewUpdate(call, shouldDownload: false)
2166
+ }
2167
+
2168
+ func updatePreview(_ call: CAPPluginCall) {
2169
+ self.handlePreviewUpdate(call, shouldDownload: true)
2170
+ }
2171
+
2172
+ private func handlePreviewUpdate(_ call: CAPPluginCall, shouldDownload: Bool) {
2173
+ guard self.allowPreview else {
2174
+ call.reject("Preview updates not allowed. Set allowPreview to true in your config to enable it.")
2175
+ return
2176
+ }
2177
+ guard let id = call.getString("id"), !id.isEmpty else {
2178
+ call.reject("Preview update called without id")
2179
+ return
2180
+ }
2181
+ guard let preview = self.storedPreviewInfo(id: id),
2182
+ let payloadUrlString = preview["payloadUrl"] as? String,
2183
+ let payloadUrl = self.normalizedPreviewPayloadUrl(payloadUrlString) else {
2184
+ call.reject("Preview \(id) has no payloadUrl to update from")
2185
+ return
2186
+ }
2187
+
2188
+ DispatchQueue.global(qos: .userInitiated).async {
2189
+ do {
2190
+ let payload = try self.fetchPreviewPayload(payloadUrl)
2191
+ guard let version = payload.version, !version.isEmpty else {
2192
+ throw self.makePreviewError("Preview payload is missing a version")
2193
+ }
2194
+
2195
+ let currentPreviewBundle = self.implementation.getBundleInfo(id: id)
2196
+ let upToDate = currentPreviewBundle.getVersionName() == version
2197
+ if upToDate || !shouldDownload {
2198
+ call.resolve([
2199
+ "preview": preview,
2200
+ "latestVersion": version,
2201
+ "upToDate": upToDate,
2202
+ "updated": false,
2203
+ "bundle": currentPreviewBundle.toJSON()
2204
+ ])
2205
+ return
2206
+ }
2207
+
2208
+ guard payload.url != nil || payload.manifest?.isEmpty == false else {
2209
+ throw self.makePreviewError("Preview payload is missing download information")
2210
+ }
2211
+
2212
+ let next = try self.downloadBundle(
2213
+ // Fallback URL is only provided when payload.url is missing; when manifestEntries is present,
2214
+ // downloadBundle routes through downloadManifest and ignores urlString.
2215
+ urlString: payload.url ?? "https://404.capgo.app/no.zip",
2216
+ version: version,
2217
+ sessionKey: payload.sessionKey ?? "",
2218
+ checksum: payload.checksum ?? "",
2219
+ manifestEntries: payload.manifest
2220
+ )
2221
+
2222
+ let wasActive = self.previewSessionEnabled && self.implementation.getCurrentBundleId() == id
2223
+ if wasActive {
2224
+ guard self.implementation.set(id: next.getId()) else {
2225
+ throw self.makePreviewError("Downloaded preview bundle cannot be applied")
2226
+ }
2227
+ }
2228
+
2229
+ let savedPreview = self.recordPreviewBundle(next, replacing: id)
2230
+ if wasActive {
2231
+ guard self.reloadWithoutWaitingForAppReady() else {
2232
+ throw self.makePreviewError("Reload failed after updating preview")
2233
+ }
2234
+ self.notifyBundleSet(next)
2235
+ self.showPreviewSessionNoticeIfNeeded()
2236
+ }
2237
+
2238
+ call.resolve([
2239
+ "preview": savedPreview,
2240
+ "latestVersion": version,
2241
+ "upToDate": false,
2242
+ "updated": true,
2243
+ "bundle": next.toJSON()
2244
+ ])
2245
+ } catch {
2246
+ self.logger.error("Could not update preview: \(error.localizedDescription)")
2247
+ call.reject("Could not update preview: \(error.localizedDescription)")
2248
+ }
2249
+ }
2250
+ }
2251
+
2252
+ func leavePreviewSessionFromShakeMenu() -> Bool {
2253
+ self.showPreviewTransitionLoader(reason: "leave-preview-session")
2254
+ let didReset = self.resetToPreviewFallbackBundle()
2255
+ guard didReset else {
2256
+ self.hidePreviewTransitionLoader(reason: "leave-preview-session-failed")
2257
+ return false
2258
+ }
2259
+
2260
+ self.endPreviewSession(keepPreviewGuard: true)
2261
+ return true
2262
+ }
2263
+
2264
+ private func leavePreviewSessionForLaunchURLIfNeeded() {
2265
+ // Cordova cold-start launch URLs are delivered via CDVPluginHandleOpenURLNotification after plugin init.
2266
+ }
2267
+
2268
+ private func leavePreviewSessionWithoutReload(keepPreviewGuard: Bool = false) -> Bool {
2269
+ guard let previewFallbackBundle = self.resolvePreviewFallbackBundle(reason: "preview deeplink launch") else {
2270
+ return false
2271
+ }
2272
+ guard self.implementation.stagePreviewFallbackReload(bundle: previewFallbackBundle) else {
2273
+ logger.error("Could not stage preview fallback bundle")
2274
+ return false
2275
+ }
2276
+
2277
+ self.endPreviewSession(keepPreviewGuard: keepPreviewGuard)
2278
+ return true
2279
+ }
2280
+
2281
+ private func leavePreviewSessionForIncomingPreviewLink() -> Bool {
2282
+ self.showPreviewTransitionLoader(reason: "incoming-preview-deeplink")
2283
+ guard let previewFallbackBundle = self.resolvePreviewFallbackBundle(reason: "incoming preview deeplink") else {
2284
+ self.clearIncomingPreviewTransition()
2285
+ self.hidePreviewTransitionLoader(reason: "incoming-preview-deeplink-failed")
2286
+ return false
2287
+ }
2288
+
2289
+ let previousState = self.implementation.captureResetState()
2290
+ guard self.implementation.stagePreviewFallbackReload(bundle: previewFallbackBundle) else {
2291
+ logger.error("Could not stage preview fallback bundle")
2292
+ self.clearIncomingPreviewTransition()
2293
+ self.hidePreviewTransitionLoader(reason: "incoming-preview-deeplink-failed")
2294
+ return false
2295
+ }
2296
+
2297
+ let didReload = self.reloadWithoutWaitingForAppReady()
2298
+ if didReload {
2299
+ self.endPreviewSession(keepPreviewGuard: true)
2300
+ self.scheduleIncomingPreviewTransitionFallbackClear()
2301
+ } else {
2302
+ self.implementation.restoreResetState(previousState)
2303
+ self.restoreLiveBundleStateAfterFailedReload()
2304
+ self.clearIncomingPreviewTransition()
2305
+ self.hidePreviewTransitionLoader(reason: "incoming-preview-deeplink-reload-failed")
2306
+ }
2307
+ return didReload
2308
+ }
2309
+
2310
+ func reloadPreviewSessionFromShakeMenu() -> Bool {
2311
+ self.showPreviewTransitionLoader(reason: "reload-preview-session")
2312
+ let didReload: Bool
2313
+ if let payloadUrl = self.storedPreviewPayloadUrl() {
2314
+ didReload = self.refreshPreviewSessionFromPayloadUrl(payloadUrl)
2315
+ } else {
2316
+ didReload = self.reloadWithoutWaitingForAppReady()
2317
+ }
2318
+
2319
+ if !didReload {
2320
+ self.hidePreviewTransitionLoader(reason: "reload-preview-session-failed")
2321
+ }
2322
+ return didReload
2323
+ }
2324
+
2325
+ func hasActivePreviewSession() -> Bool {
2326
+ self.previewSessionEnabled
2327
+ }
2328
+
2329
+ func resetToPreviewFallbackBundle() -> Bool {
2330
+ guard self.canPerformResetTransition() else { return false }
2331
+ guard let fallback = self.resolvePreviewFallbackBundle(reason: "leave preview") else {
2332
+ return false
2333
+ }
2334
+
2335
+ let previousState = self.implementation.captureResetState()
2336
+ let previousBundleName = self.implementation.getCurrentBundle().getVersionName()
2337
+ logger.info("Resetting to preview fallback bundle: \(fallback.toString())")
2338
+ if self.implementation.stagePreviewFallbackReload(bundle: fallback) && self.reloadWithoutWaitingForAppReady() {
2339
+ self.implementation.finalizeResetTransition(previousBundleName: previousBundleName, isInternal: false)
2340
+ self.notifyBundleSet(fallback)
2341
+ return true
2342
+ }
2343
+ self.implementation.restoreResetState(previousState)
2344
+ self.restoreLiveBundleStateAfterFailedReload()
2345
+ return false
2346
+ }
2347
+
2348
+ private func resolvePreviewFallbackBundle(reason: String) -> BundleInfo? {
2349
+ let fallback = self.implementation.getPreviewFallbackBundle()
2350
+ if let fallback, !fallback.isErrorStatus(), self.implementation.canSet(bundle: fallback) {
2351
+ return fallback
2352
+ }
2353
+
2354
+ if let fallback {
2355
+ if fallback.isErrorStatus() {
2356
+ logger.warn("Preview fallback bundle is in error state for \(reason). Falling back to builtin bundle.")
2357
+ } else {
2358
+ logger.warn("Preview fallback bundle is not installable for \(reason). Falling back to builtin bundle.")
2359
+ }
2360
+ } else {
2361
+ logger.warn("No preview fallback bundle available for \(reason). Falling back to builtin bundle.")
2362
+ }
2363
+
2364
+ let builtin = self.implementation.getBundleInfo(id: BundleInfo.ID_BUILTIN)
2365
+ if !builtin.isErrorStatus(), self.implementation.canSet(bundle: builtin) {
2366
+ return builtin
2367
+ }
2368
+
2369
+ logger.error("Builtin bundle is not available to leave preview for \(reason)")
2370
+ return nil
2371
+ }
2372
+
2373
+ private func endPreviewSession(keepPreviewGuard: Bool = false) {
2374
+ let previousShakeMenuEnabled = UserDefaults.standard.object(forKey: self.previewPreviousShakeMenuDefaultsKey) as? Bool
2375
+ ?? self.getBooleanConfig("shakeMenu", defaultValue: false)
2376
+ let previousShakeChannelSelectorEnabled = UserDefaults.standard.object(forKey: self.previewPreviousShakeChannelSelectorDefaultsKey) as? Bool
2377
+ ?? self.getBooleanConfig("allowShakeChannelSelector", defaultValue: false)
2378
+ self.restorePreviewPreviousNextBundle()
2379
+ self.restorePreviewPreviousAppId()
2380
+ self.restorePreviewPreviousDefaultChannel()
2381
+
2382
+ self.previewSessionEnabled = false
2383
+ self.previewSessionAlertPending = false
2384
+ if keepPreviewGuard {
2385
+ self.implementation.previewSession = true
2386
+ } else {
2387
+ self.clearIncomingPreviewTransition()
2388
+ }
2389
+ self.shakeMenuEnabled = previousShakeMenuEnabled
2390
+ self.shakeChannelSelectorEnabled = previousShakeChannelSelectorEnabled
2391
+ self.syncShakeMenuGestureRecognizer()
2392
+ _ = self.implementation.setPreviewFallbackBundle(fallback: nil)
2393
+ self.clearPreviewSessionPreferences()
2394
+ logger.info("Preview session ended")
2395
+ }
2396
+
2397
+ private func clearPreviewSessionBecauseDisabled() {
2398
+ logger.info("Preview session disabled by config; restoring preview fallback")
2399
+ if let bundleToRestore = self.resolvePreviewFallbackBundle(reason: "preview disabled") {
2400
+ _ = self.implementation.stagePreviewFallbackReload(bundle: bundleToRestore)
2401
+ } else {
2402
+ logger.warn("Could not restore preview fallback while disabling preview")
2403
+ }
2404
+
2405
+ self.restorePreviewPreviousNextBundle()
2406
+ self.restorePreviewPreviousAppId()
2407
+ self.restorePreviewPreviousDefaultChannel()
2408
+ self.previewSessionEnabled = false
2409
+ self.previewSessionAlertPending = false
2410
+ self.isLeavingPreviewForIncomingLink = false
2411
+ self.implementation.previewSession = false
2412
+ self.hidePreviewTransitionLoader(reason: "preview-session-disabled")
2413
+ self.shakeMenuEnabled = self.getBooleanConfig("shakeMenu", defaultValue: false)
2414
+ self.shakeChannelSelectorEnabled = self.getBooleanConfig("allowShakeChannelSelector", defaultValue: false)
2415
+ self.shakeMenuGesture = Self.normalizedShakeMenuGesture(self.getStringConfig("shakeMenuGesture", defaultValue: Self.shakeMenuGestureShake))
2416
+ self.syncShakeMenuGestureRecognizer()
2417
+ self.clearPreviewSessionPreferences()
2418
+ }
2419
+
2420
+ private func getBooleanConfig(_ key: String, defaultValue: Bool) -> Bool {
2421
+ guard true else {
2422
+ return defaultValue
2423
+ }
2424
+ return readConfigBool(key, defaultValue)
2425
+ }
2426
+
2427
+ private func getStringConfig(_ key: String, defaultValue: String) -> String {
2428
+ guard true else {
2429
+ return defaultValue
2430
+ }
2431
+ return readConfigString(key, defaultValue) ?? defaultValue
2432
+ }
2433
+
2434
+ private func clearPreviewSessionPreferences() {
2435
+ _ = self.implementation.setPreviewFallbackBundle(fallback: nil)
2436
+ UserDefaults.standard.removeObject(forKey: self.previewSessionDefaultsKey)
2437
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousShakeMenuDefaultsKey)
2438
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousShakeChannelSelectorDefaultsKey)
2439
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousNextBundleDefaultsKey)
2440
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousAppIdDefaultsKey)
2441
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelDefaultsKey)
2442
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
2443
+ UserDefaults.standard.removeObject(forKey: self.previewAppIdDefaultsKey)
2444
+ UserDefaults.standard.removeObject(forKey: self.previewPayloadUrlDefaultsKey)
2445
+ UserDefaults.standard.removeObject(forKey: self.previewNameDefaultsKey)
2446
+ UserDefaults.standard.removeObject(forKey: self.previewSourceDefaultsKey)
2447
+ UserDefaults.standard.removeObject(forKey: self.previewSessionAlertPendingDefaultsKey)
2448
+ UserDefaults.standard.synchronize()
2449
+ }
2450
+
2451
+ private func restorePreviewPreviousAppId() {
2452
+ guard let previousAppId = UserDefaults.standard.string(forKey: self.previewPreviousAppIdDefaultsKey),
2453
+ !previousAppId.isEmpty else {
2454
+ return
2455
+ }
2456
+ self.implementation.appId = previousAppId
2457
+ logger.info("Restored appId after preview: \(previousAppId)")
2458
+ }
2459
+
2460
+ private func restorePreviewPreviousDefaultChannel() {
2461
+ let configDefaultChannel = self.getStringConfig("defaultChannel", defaultValue: "")
2462
+ let hadPreviousDefaultChannel = UserDefaults.standard.object(forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey) as? Bool ?? false
2463
+
2464
+ guard hadPreviousDefaultChannel,
2465
+ let previousDefaultChannel = UserDefaults.standard.string(forKey: self.previewPreviousDefaultChannelDefaultsKey) else {
2466
+ UserDefaults.standard.removeObject(forKey: self.defaultChannelDefaultsKey)
2467
+ self.implementation.defaultChannel = configDefaultChannel
2468
+ logger.info("Restored defaultChannel after preview to config value")
2469
+ return
2470
+ }
2471
+
2472
+ UserDefaults.standard.set(previousDefaultChannel, forKey: self.defaultChannelDefaultsKey)
2473
+ self.implementation.defaultChannel = previousDefaultChannel
2474
+ logger.info("Restored defaultChannel after preview")
2475
+ }
2476
+
2477
+ private func normalizedPreviewAppId(_ rawAppId: String?) -> String? {
2478
+ guard let rawAppId else {
2479
+ return nil
2480
+ }
2481
+
2482
+ let appId = rawAppId.trimmingCharacters(in: .whitespacesAndNewlines)
2483
+ guard !appId.isEmpty else {
2484
+ return nil
2485
+ }
2486
+
2487
+ let lowercasedAppId = appId.lowercased()
2488
+ if lowercasedAppId == "undefined" || lowercasedAppId == "null" {
2489
+ return nil
2490
+ }
2491
+
2492
+ return appId
2493
+ }
2494
+
2495
+ private func normalizedPreviewPayloadUrl(_ rawPayloadUrl: String?) -> URL? {
2496
+ guard let rawPayloadUrl else {
2497
+ return nil
2498
+ }
2499
+
2500
+ let payloadUrl = rawPayloadUrl.trimmingCharacters(in: .whitespacesAndNewlines)
2501
+ guard !payloadUrl.isEmpty,
2502
+ let url = URL(string: payloadUrl),
2503
+ url.scheme == "https" || url.scheme == "http" else {
2504
+ return nil
2505
+ }
2506
+
2507
+ return url
2508
+ }
2509
+
2510
+ private func storedPreviewPayloadUrl() -> URL? {
2511
+ normalizedPreviewPayloadUrl(UserDefaults.standard.string(forKey: self.previewPayloadUrlDefaultsKey))
2512
+ }
2513
+
2514
+ private func previewPath(from url: URL) -> String {
2515
+ if url.scheme == self.previewDeepLinkScheme {
2516
+ var components: [String] = []
2517
+ if let host = url.host, !host.isEmpty {
2518
+ components.append(host)
2519
+ }
2520
+ components.append(contentsOf: url.path.split(separator: self.previewPathSeparator).map(String.init))
2521
+ return self.normalizedPreviewPath(components)
2522
+ }
2523
+
2524
+ return url.path
2525
+ }
2526
+
2527
+ private func normalizedPreviewPath(_ components: [String]) -> String {
2528
+ let separator = String(self.previewPathSeparator)
2529
+ return separator + components.filter { !$0.isEmpty }.joined(separator: separator)
2530
+ }
2531
+
2532
+ private func previewDeepLinkPath(_ leafComponent: String) -> String {
2533
+ self.normalizedPreviewPath([self.previewDeepLinkRootComponent, leafComponent])
2534
+ }
2535
+
2536
+ private func isPreviewDeepLink(_ url: URL) -> Bool {
2537
+ let path = self.previewPath(from: url)
2538
+ return path == self.previewDeepLinkPath(self.previewDeepLinkChannelComponent) ||
2539
+ path == self.previewDeepLinkPath(self.previewDeepLinkBundleComponent)
2540
+ }
2541
+
2542
+ @objc private func handleOpenURLForPreviewSession(notification: NSNotification) {
2543
+ let rawUrl = (notification.object as? [String: Any])?["url"]
2544
+ let url = rawUrl as? URL ?? (rawUrl as? NSURL).map { $0 as URL }
2545
+ guard self.previewSessionEnabled,
2546
+ !self.isLeavingPreviewForIncomingLink,
2547
+ let url,
2548
+ self.isPreviewDeepLink(url) else {
2549
+ return
2550
+ }
2551
+
2552
+ self.isLeavingPreviewForIncomingLink = true
2553
+ self.showPreviewTransitionLoader(reason: "incoming-preview-deeplink")
2554
+ logger.info("Preview deeplink received while preview session is active; restoring fallback before routing")
2555
+ DispatchQueue.global(qos: .userInitiated).async {
2556
+ let didLeave = self.leavePreviewSessionForIncomingPreviewLink()
2557
+ if !didLeave {
2558
+ self.logger.error("Could not leave preview session before routing incoming preview deeplink")
2559
+ self.isLeavingPreviewForIncomingLink = false
2560
+ self.hidePreviewTransitionLoader(reason: "incoming-preview-deeplink-failed")
2561
+ }
2562
+ }
2563
+ }
2564
+
2565
+ private func fetchPreviewPayload(_ payloadUrl: URL) throws -> PreviewPayload {
2566
+ var request = URLRequest(url: payloadUrl)
2567
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
2568
+
2569
+ let semaphore = DispatchSemaphore(value: 0)
2570
+ var responseData: Data?
2571
+ var response: URLResponse?
2572
+ var responseError: Error?
2573
+
2574
+ URLSession.shared.dataTask(with: request) { data, urlResponse, error in
2575
+ responseData = data
2576
+ response = urlResponse
2577
+ responseError = error
2578
+ semaphore.signal()
2579
+ }.resume()
2580
+
2581
+ if semaphore.wait(timeout: .now() + 60) == .timedOut {
2582
+ throw makePreviewError("Preview payload request timed out")
2583
+ }
2584
+
2585
+ if let responseError = responseError {
2586
+ throw responseError
2587
+ }
2588
+
2589
+ let data = responseData ?? Data()
2590
+ if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
2591
+ if let payload = try? JSONDecoder().decode(PreviewPayload.self, from: data) {
2592
+ throw makePreviewError(payload.message ?? payload.error ?? "Preview payload request failed with HTTP \(httpResponse.statusCode)")
2593
+ }
2594
+ let message = String(data: data, encoding: .utf8) ?? "Preview payload request failed with HTTP \(httpResponse.statusCode)"
2595
+ throw makePreviewError(message)
2596
+ }
2597
+
2598
+ return try JSONDecoder().decode(PreviewPayload.self, from: data)
2599
+ }
2600
+
2601
+ private func refreshPreviewSessionFromPayloadUrl(_ payloadUrl: URL) -> Bool {
2602
+ do {
2603
+ let payload = try self.fetchPreviewPayload(payloadUrl)
2604
+ guard let version = payload.version, !version.isEmpty else {
2605
+ throw makePreviewError("Preview payload is missing a version")
2606
+ }
2607
+ guard payload.url != nil || payload.manifest?.isEmpty == false else {
2608
+ throw makePreviewError("Preview payload is missing download information")
2609
+ }
2610
+
2611
+ let current = self.implementation.getCurrentBundle()
2612
+ if current.getVersionName() == version {
2613
+ self.logger.info("Preview payload unchanged, reloading current bundle")
2614
+ return self.reloadWithoutWaitingForAppReady()
2615
+ }
2616
+
2617
+ let next = try self.downloadBundle(
2618
+ // Fallback URL is only provided when payload.url is missing; when manifestEntries is present,
2619
+ // downloadBundle routes through downloadManifest and ignores urlString.
2620
+ urlString: payload.url ?? "https://404.capgo.app/no.zip",
2621
+ version: version,
2622
+ sessionKey: payload.sessionKey ?? "",
2623
+ checksum: payload.checksum ?? "",
2624
+ manifestEntries: payload.manifest
2625
+ )
2626
+
2627
+ guard self.implementation.set(id: next.getId()) else {
2628
+ throw makePreviewError("Downloaded preview bundle cannot be applied")
2629
+ }
2630
+
2631
+ _ = self.recordPreviewBundle(next, replacing: current.getId())
2632
+ self.notifyBundleSet(next)
2633
+ return self.reloadWithoutWaitingForAppReady()
2634
+ } catch {
2635
+ self.logger.error("Could not refresh preview session: \(error.localizedDescription)")
2636
+ return false
2637
+ }
2638
+ }
2639
+
2640
+ private func clearPreviewSessionForNativeBuildChange() {
2641
+ guard self.previewSessionEnabled || self.implementation.getPreviewFallbackBundle() != nil || !self.previewSessions().isEmpty else {
2642
+ return
2643
+ }
2644
+ logger.info("Native build changed; clearing preview session state")
2645
+ self.previewSessionEnabled = false
2646
+ self.previewSessionAlertPending = false
2647
+ self.isLeavingPreviewForIncomingLink = false
2648
+ self.implementation.previewSession = false
2649
+ self.shakeMenuEnabled = self.getBooleanConfig("shakeMenu", defaultValue: false)
2650
+ self.shakeChannelSelectorEnabled = self.getBooleanConfig("allowShakeChannelSelector", defaultValue: false)
2651
+ self.shakeMenuGesture = Self.normalizedShakeMenuGesture(self.getStringConfig("shakeMenuGesture", defaultValue: Self.shakeMenuGestureShake))
2652
+ self.syncShakeMenuGestureRecognizer()
2653
+ self.restorePreviewPreviousAppId()
2654
+ self.restorePreviewPreviousDefaultChannel()
2655
+ _ = self.implementation.setPreviewFallbackBundle(fallback: nil)
2656
+ _ = self.implementation.setNextBundle(next: Optional<String>.none)
2657
+ self.clearPreviewSessionPreferences()
2658
+ UserDefaults.standard.removeObject(forKey: self.previewSessionsDefaultsKey)
2659
+ UserDefaults.standard.synchronize()
2660
+ }
2661
+
2662
+ private func restorePreviewPreviousNextBundle() {
2663
+ guard let previousNextBundleId = UserDefaults.standard.string(forKey: self.previewPreviousNextBundleDefaultsKey),
2664
+ !previousNextBundleId.isEmpty else {
2665
+ _ = self.implementation.setNextBundle(next: Optional<String>.none)
2666
+ return
2667
+ }
2668
+ if !self.implementation.setNextBundle(next: previousNextBundleId) {
2669
+ logger.warn("Could not restore pre-preview next bundle: \(previousNextBundleId)")
2670
+ _ = self.implementation.setNextBundle(next: Optional<String>.none)
2671
+ }
2672
+ }
2673
+
2674
+ private func showPreviewSessionNoticeIfNeeded() {
2675
+ guard self.previewSessionEnabled && self.previewSessionAlertPending else {
2676
+ return
2677
+ }
2678
+ self.previewSessionAlertPending = false
2679
+ UserDefaults.standard.set(false, forKey: self.previewSessionAlertPendingDefaultsKey)
2680
+ UserDefaults.standard.synchronize()
2681
+
2682
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(600)) {
2683
+ guard self.previewSessionEnabled else {
2684
+ return
2685
+ }
2686
+ if let topVC = UIApplication.topViewController(),
2687
+ topVC.isKind(of: UIAlertController.self) {
2688
+ self.previewSessionAlertPending = true
2689
+ UserDefaults.standard.set(true, forKey: self.previewSessionAlertPendingDefaultsKey)
2690
+ UserDefaults.standard.synchronize()
2691
+ return
2692
+ }
2693
+
2694
+ let alert = UIAlertController(
2695
+ title: "Preview started",
2696
+ message: "Shake your device anytime to reload or leave the test app.",
2697
+ preferredStyle: .alert
2698
+ )
2699
+ alert.addAction(UIAlertAction(title: "Got it", style: .default))
2700
+ if let topVC = UIApplication.topViewController() {
2701
+ topVC.present(alert, animated: true)
2702
+ } else {
2703
+ self.previewSessionAlertPending = true
2704
+ UserDefaults.standard.set(true, forKey: self.previewSessionAlertPendingDefaultsKey)
2705
+ UserDefaults.standard.synchronize()
2706
+ }
2707
+ }
2708
+ }
2709
+
2710
+ func delete(_ call: CAPPluginCall) {
2711
+ guard let id = call.getString("id") else {
2712
+ logger.error("Delete called without version")
2713
+ call.reject("Delete called without id")
2714
+ return
2715
+ }
2716
+ let res = implementation.delete(id: id)
2717
+ if res {
2718
+ call.resolve()
2719
+ } else {
2720
+ logger.error("Delete failed, id \(id) doesn't exist or it cannot be deleted (perhaps it is the 'next' bundle)")
2721
+ call.reject("Delete failed, id \(id) does not exist or it cannot be deleted (perhaps it is the 'next' bundle)")
2722
+ }
2723
+ }
2724
+
2725
+ func setBundleError(_ call: CAPPluginCall) {
2726
+ if !allowManualBundleError {
2727
+ logger.error("setBundleError called without allowManualBundleError")
2728
+ call.reject("setBundleError not allowed. Set allowManualBundleError to true in your config to enable it.")
2729
+ return
2730
+ }
2731
+ guard let id = call.getString("id") else {
2732
+ logger.error("setBundleError called without id")
2733
+ call.reject("setBundleError called without id")
2734
+ return
2735
+ }
2736
+ let bundle = implementation.getBundleInfo(id: id)
2737
+ if bundle.isUnknown() {
2738
+ logger.error("setBundleError called with unknown bundle \(id)")
2739
+ call.reject("Bundle \(id) does not exist")
2740
+ return
2741
+ }
2742
+ if bundle.isBuiltin() {
2743
+ logger.error("setBundleError called on builtin bundle")
2744
+ call.reject("Cannot set builtin bundle to error state")
2745
+ return
2746
+ }
2747
+ if self._isAutoUpdateEnabled() {
2748
+ logger.warn("setBundleError used while autoUpdate is enabled; this method is intended for manual mode")
2749
+ }
2750
+ implementation.setError(bundle: bundle)
2751
+ let updated = implementation.getBundleInfo(id: id)
2752
+ call.resolve(["bundle": updated.toJSON()])
2753
+ }
2754
+
2755
+ func list(_ call: CAPPluginCall) {
2756
+ let raw = call.getBool("raw", false)
2757
+ let res = implementation.list(raw: raw)
2758
+ var resArr: [[String: String]] = []
2759
+ for v in res {
2760
+ resArr.append(v.toJSON())
2761
+ }
2762
+ call.resolve([
2763
+ "bundles": resArr
2764
+ ])
2765
+ }
2766
+
2767
+ func getLatest(_ call: CAPPluginCall) {
2768
+ let channel = call.getString("channel")
2769
+ let includeBundleSize = call.getBool("includeBundleSize", false)
2770
+ let appId = self.normalizedPreviewAppId(call.getString("appId"))
2771
+ if appId != nil && !self.allowPreview {
2772
+ logger.error("getLatest preview override called without allowPreview")
2773
+ call.reject("getLatest preview override not allowed. Set allowPreview to true in your config to enable it.")
2774
+ return
2775
+ }
2776
+ self.saveCallForAsyncHandling(call)
2777
+ runGetLatestWork {
2778
+ let res = self.implementation.getLatest(
2779
+ url: URL(string: self.updateUrl)!,
2780
+ channel: channel,
2781
+ appIdOverride: appId
2782
+ )
2783
+ if includeBundleSize {
2784
+ self.attachBundleSize(to: res)
2785
+ }
2786
+ if let error = res.error, !error.isEmpty {
2787
+ let responseKind = self.updateResponseKind(kind: res.kind)
2788
+ res.kind = responseKind
2789
+ self.notifyBreakingEventsIfNeeded(response: res, version: res.version)
2790
+ if responseKind == "failed" {
2791
+ self.rejectCall(call, message: error)
2792
+ } else {
2793
+ if res.version.isEmpty {
2794
+ res.version = self.implementation.getCurrentBundle().getVersionName()
2795
+ }
2796
+ self.resolveCall(call, data: res.toDict())
2797
+ }
2798
+ } else if let kind = res.kind, !kind.isEmpty {
2799
+ let responseKind = self.updateResponseKind(kind: kind)
2800
+ res.kind = responseKind
2801
+ self.notifyBreakingEventsIfNeeded(response: res, version: res.version)
2802
+ if responseKind != "failed" {
2803
+ if res.version.isEmpty {
2804
+ res.version = self.implementation.getCurrentBundle().getVersionName()
2805
+ }
2806
+ self.resolveCall(call, data: res.toDict())
2807
+ } else {
2808
+ self.rejectCall(call, message: res.message ?? "server did not provide a message")
2809
+ }
2810
+ } else if let message = res.message, !message.isEmpty {
2811
+ self.notifyBreakingEventsIfNeeded(response: res, version: res.version)
2812
+ self.rejectCall(call, message: message)
2813
+ } else {
2814
+ self.resolveCall(call, data: res.toDict())
2815
+ }
2816
+ }
2817
+ }
2818
+
2819
+ private func attachBundleSize(to res: AppVersion) {
2820
+ guard let manifest = res.manifest, !manifest.isEmpty, let updateUrl = URL(string: self.updateUrl) else {
2821
+ return
2822
+ }
2823
+ let missing = self.implementation.getMissingBundleFiles(manifest: manifest, sessionKey: res.sessionKey ?? "")
2824
+ res.missing = [
2825
+ "missing": missing.map { $0.toDict() },
2826
+ "total": manifest.count,
2827
+ "missingCount": missing.count,
2828
+ "reusableCount": manifest.count - missing.count
2829
+ ]
2830
+ res.downloadSize = self.implementation.getBundleDownloadSize(updateUrl: updateUrl, version: res.version, manifest: missing)
2831
+ }
2832
+
2833
+ func getMissingBundleFiles(_ call: CAPPluginCall) {
2834
+ guard let manifest = manifestEntries(from: call.getArray("manifest")) else {
2835
+ call.reject("getMissingBundleFiles called without manifest")
2836
+ return
2837
+ }
2838
+ let sessionKey = call.getString("sessionKey", "")
2839
+ self.saveCallForAsyncHandling(call)
2840
+ DispatchQueue.global(qos: .utility).async {
2841
+ let res = self.implementation.missingBundleFilesResult(manifest: manifest, sessionKey: sessionKey)
2842
+ self.resolveCall(call, data: res)
2843
+ }
2844
+ }
2845
+
2846
+ func getBundleDownloadSize(_ call: CAPPluginCall) {
2847
+ guard let manifest = manifestEntries(from: call.getArray("manifest")) else {
2848
+ call.reject("getBundleDownloadSize called without manifest")
2849
+ return
2850
+ }
2851
+ guard let updateUrl = URL(string: self.updateUrl) else {
2852
+ call.reject("getBundleDownloadSize called without valid updateUrl")
2853
+ return
2854
+ }
2855
+ let version = call.getString("version")
2856
+ self.saveCallForAsyncHandling(call)
2857
+ DispatchQueue.global(qos: .utility).async {
2858
+ let res = self.implementation.getBundleDownloadSize(updateUrl: updateUrl, version: version, manifest: manifest)
2859
+ self.resolveCall(call, data: res)
2860
+ }
2861
+ }
2862
+
2863
+ public func triggerBackgroundUpdateCheck() -> String {
2864
+ guard !self.updateUrl.isEmpty, URL(string: self.updateUrl) != nil else {
2865
+ logger.error("Error no url or wrong format")
2866
+ return "unavailable"
2867
+ }
2868
+ if self.shouldBlockAutoUpdateForPreviewSession() {
2869
+ return "preview_session"
2870
+ }
2871
+ if self.isDownloadStuckOrTimedOut() {
2872
+ logger.info("Download already in progress, skipping duplicate download request")
2873
+ return "already_running"
2874
+ }
2875
+ self.backgroundDownload()
2876
+ return "queued"
2877
+ }
2878
+
2879
+ func triggerUpdateCheck(_ call: CAPPluginCall) {
2880
+ let status = self.triggerBackgroundUpdateCheck()
2881
+ call.resolve([
2882
+ "status": status,
2883
+ "queued": status == "queued"
2884
+ ])
2885
+ }
2886
+
2887
+ func unsetChannel(_ call: CAPPluginCall) {
2888
+ let triggerAutoUpdate = call.getBool("triggerAutoUpdate", false)
2889
+ self.saveCallForAsyncHandling(call)
2890
+ DispatchQueue.global(qos: .utility).async {
2891
+ let configDefaultChannel = self.readConfigString("defaultChannel", "")!
2892
+ let res = self.implementation.unsetChannel(defaultChannelKey: self.defaultChannelDefaultsKey, configDefaultChannel: configDefaultChannel)
2893
+ if res.error != "" {
2894
+ self.rejectCall(call, message: res.error, code: "UNSETCHANNEL_FAILED", data: [
2895
+ "message": res.error,
2896
+ "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
2897
+ ])
2898
+ } else {
2899
+ guard self.persistDefaultChannelStateFromDefaults() else {
2900
+ self.rejectCall(call, message: "Channel override removed but local persistence failed", code: "UNSETCHANNEL_PERSISTENCE_FAILED")
2901
+ return
2902
+ }
2903
+ if self._isAutoUpdateEnabled() && triggerAutoUpdate {
2904
+ self.logger.info("Calling autoupdater after channel change!")
2905
+ // Check if download is already in progress (with timeout protection)
2906
+ if !self.isDownloadStuckOrTimedOut() {
2907
+ self.backgroundDownload()
2908
+ } else {
2909
+ self.logger.info("Download already in progress, skipping duplicate download request")
2910
+ }
2911
+ }
2912
+ self.resolveCall(call, data: res.toDict())
2913
+ }
2914
+ }
2915
+ }
2916
+
2917
+ func setChannel(_ call: CAPPluginCall) {
2918
+ guard let channel = call.getString("channel") else {
2919
+ logger.error("setChannel called without channel")
2920
+ call.reject("setChannel called without channel", "SETCHANNEL_INVALID_PARAMS", error: nil, data: [
2921
+ "message": "setChannel called without channel",
2922
+ "error": "missing_parameter"
2923
+ ])
2924
+ return
2925
+ }
2926
+ let triggerAutoUpdate = call.getBool("triggerAutoUpdate") ?? false
2927
+ self.saveCallForAsyncHandling(call)
2928
+ DispatchQueue.global(qos: .utility).async {
2929
+ let configDefaultChannel = self.readConfigString("defaultChannel", "")!
2930
+ let res = self.implementation.setChannel(
2931
+ channel: channel,
2932
+ defaultChannelKey: self.defaultChannelDefaultsKey,
2933
+ allowSetDefaultChannel: self.allowSetDefaultChannel,
2934
+ configDefaultChannel: configDefaultChannel
2935
+ )
2936
+ if res.error != "" {
2937
+ // Fire channelPrivate event if channel doesn't allow self-assignment
2938
+ if res.error.contains("cannot_update_via_private_channel") || res.error.contains("channel_self_set_not_allowed") {
2939
+ self.notifyListenersOnMain("channelPrivate", data: [
2940
+ "channel": channel,
2941
+ "message": res.error
2942
+ ])
2943
+ }
2944
+ self.rejectCall(call, message: res.error, code: "SETCHANNEL_FAILED", data: [
2945
+ "message": res.error,
2946
+ "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"
2947
+ ])
2948
+ } else {
2949
+ guard self.persistDefaultChannelStateFromDefaults() else {
2950
+ self.rejectCall(call, message: "Channel changed but local persistence failed", code: "SETCHANNEL_PERSISTENCE_FAILED")
2951
+ return
2952
+ }
2953
+ if self._isAutoUpdateEnabled() && triggerAutoUpdate {
2954
+ self.logger.info("Calling autoupdater after channel change!")
2955
+ // Check if download is already in progress (with timeout protection)
2956
+ if !self.isDownloadStuckOrTimedOut() {
2957
+ self.backgroundDownload()
2958
+ } else {
2959
+ self.logger.info("Download already in progress, skipping duplicate download request")
2960
+ }
2961
+ }
2962
+ self.resolveCall(call, data: res.toDict())
2963
+ }
2964
+ }
2965
+ }
2966
+
2967
+ func getChannel(_ call: CAPPluginCall) {
2968
+ self.saveCallForAsyncHandling(call)
2969
+ DispatchQueue.global(qos: .utility).async {
2970
+ let res = self.implementation.getChannel(defaultChannelKey: self.defaultChannelDefaultsKey)
2971
+ if res.error != "" {
2972
+ self.rejectCall(call, message: res.error, code: "GETCHANNEL_FAILED", data: [
2973
+ "message": res.error,
2974
+ "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
2975
+ ])
2976
+ } else {
2977
+ guard self.persistDefaultChannelStateFromDefaults() else {
2978
+ self.rejectCall(call, message: "Channel synchronized but local persistence failed", code: "GETCHANNEL_PERSISTENCE_FAILED")
2979
+ return
2980
+ }
2981
+ self.resolveCall(call, data: res.toDict())
2982
+ }
2983
+ }
2984
+ }
2985
+
2986
+ func listChannels(_ call: CAPPluginCall) {
2987
+ self.saveCallForAsyncHandling(call)
2988
+ DispatchQueue.global(qos: .utility).async {
2989
+ let res = self.implementation.listChannels()
2990
+ if res.error != "" {
2991
+ self.rejectCall(call, message: res.error, code: "LISTCHANNELS_FAILED", data: [
2992
+ "message": res.error,
2993
+ "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
2994
+ ])
2995
+ } else {
2996
+ var payload: JSObject = [:]
2997
+ payload["channels"] = res.channels
2998
+ self.resolveCall(call, data: payload)
2999
+ }
3000
+ }
3001
+ }
3002
+
3003
+ func setCustomId(_ call: CAPPluginCall) {
3004
+ guard let customId = call.getString("customId") else {
3005
+ logger.error("setCustomId called without customId")
3006
+ call.reject("setCustomId called without customId")
3007
+ return
3008
+ }
3009
+ self.implementation.customId = customId
3010
+ if persistCustomId {
3011
+ if customId.isEmpty {
3012
+ UserDefaults.standard.removeObject(forKey: customIdDefaultsKey)
3013
+ } else {
3014
+ UserDefaults.standard.set(customId, forKey: customIdDefaultsKey)
3015
+ }
3016
+ UserDefaults.standard.synchronize()
3017
+ }
3018
+ call.resolve()
3019
+ }
3020
+
3021
+ @objc func _reset(toLastSuccessful: Bool, usePendingBundle: Bool) -> Bool {
3022
+ self.performReset(toLastSuccessful: toLastSuccessful, usePendingBundle: usePendingBundle, isInternal: false)
3023
+ }
3024
+
3025
+ func performReset(toLastSuccessful: Bool, usePendingBundle: Bool, isInternal: Bool) -> Bool {
3026
+ guard self.canPerformResetTransition() else { return false }
3027
+
3028
+ let fallback: BundleInfo = self.implementation.getFallbackBundle()
3029
+ let pending: BundleInfo? = self.implementation.getNextBundle()
3030
+ let previousState = self.implementation.captureResetState()
3031
+ let previousBundleName = self.implementation.getCurrentBundle().getVersionName()
3032
+
3033
+ if usePendingBundle {
3034
+ guard let pending = pending, !pending.isErrorStatus() else {
3035
+ logger.error("No pending bundle available to reset to")
3036
+ return false
3037
+ }
3038
+ guard self.implementation.canSet(bundle: pending) else {
3039
+ logger.error("Pending bundle is not installable")
3040
+ return false
3041
+ }
3042
+ self.implementation.prepareResetStateForTransition()
3043
+ logger.info("Resetting to pending bundle: \(pending.toString())")
3044
+ let didApplyPendingBundle: Bool
3045
+ if pending.isBuiltin() {
3046
+ didApplyPendingBundle = true
3047
+ } else {
3048
+ didApplyPendingBundle = self.implementation.set(bundle: pending)
3049
+ }
3050
+ if didApplyPendingBundle && self._reload() {
3051
+ self.implementation.finalizeResetTransition(previousBundleName: previousBundleName, isInternal: isInternal)
3052
+ self.notifyBundleSet(pending)
3053
+ _ = self.implementation.setNextBundle(next: Optional<String>.none)
3054
+ return true
3055
+ }
3056
+ self.implementation.restoreResetState(previousState)
3057
+ self.restoreLiveBundleStateAfterFailedReload()
3058
+ return false
3059
+ }
3060
+
3061
+ // If developer wants to reset to the last successful bundle, and that bundle is not
3062
+ // the built-in bundle, set it as the bundle to use and reload.
3063
+ if toLastSuccessful && !fallback.isBuiltin() {
3064
+ if self.implementation.canSet(bundle: fallback) {
3065
+ self.implementation.prepareResetStateForTransition()
3066
+ logger.info("Resetting to: \(fallback.toString())")
3067
+ if self.implementation.set(bundle: fallback) && self._reload() {
3068
+ self.implementation.finalizeResetTransition(previousBundleName: previousBundleName, isInternal: isInternal)
3069
+ self.notifyBundleSet(fallback)
3070
+ return true
3071
+ }
3072
+ if !isInternal {
3073
+ self.implementation.restoreResetState(previousState)
3074
+ self.restoreLiveBundleStateAfterFailedReload()
3075
+ return false
3076
+ }
3077
+ logger.warn("Fallback reload failed during internal reset, resetting to builtin instead")
3078
+ } else {
3079
+ logger.warn("Fallback bundle is not installable, resetting to builtin instead")
3080
+ }
3081
+ }
3082
+
3083
+ self.implementation.prepareResetStateForTransition()
3084
+ logger.info("Resetting to builtin version")
3085
+ if self._reload() {
3086
+ self.implementation.finalizeResetTransition(previousBundleName: previousBundleName, isInternal: isInternal)
3087
+ return true
3088
+ }
3089
+ if !isInternal {
3090
+ self.implementation.restoreResetState(previousState)
3091
+ self.restoreLiveBundleStateAfterFailedReload()
3092
+ }
3093
+ return false
3094
+ }
3095
+
3096
+ func canPerformResetTransition() -> Bool {
3097
+ return self.updaterWebView != nil
3098
+ }
3099
+
3100
+ func reset(_ call: CAPPluginCall) {
3101
+ let toLastSuccessful = call.getBool("toLastSuccessful") ?? false
3102
+ let usePendingBundle = call.getBool("usePendingBundle") ?? false
3103
+ if self._reset(toLastSuccessful: toLastSuccessful, usePendingBundle: usePendingBundle) {
3104
+ call.resolve()
3105
+ } else {
3106
+ logger.error("Reset failed")
3107
+ call.reject("Reset failed")
3108
+ }
3109
+ }
3110
+
3111
+ func current(_ call: CAPPluginCall) {
3112
+ let bundle: BundleInfo = self.implementation.getCurrentBundle()
3113
+ call.resolve([
3114
+ "bundle": bundle.toJSON(),
3115
+ "native": self.currentVersionNative.description
3116
+ ])
3117
+ }
3118
+
3119
+ func notifyAppReady(_ call: CAPPluginCall) {
3120
+ self.semaphoreDown()
3121
+ let bundle = self.implementation.getCurrentBundle()
3122
+ self.implementation.setSuccess(bundle: bundle, autoDeletePrevious: self.autoDeletePrevious)
3123
+ logger.info("Current bundle loaded successfully. [notifyAppReady was called] \(bundle.toString())")
3124
+ self.clearIncomingPreviewTransition()
3125
+ self.hidePreviewTransitionLoader(reason: "notify-app-ready")
3126
+
3127
+ call.resolve(["bundle": bundle.toJSON()])
3128
+ }
3129
+
3130
+ func setMultiDelay(_ call: CAPPluginCall) {
3131
+ guard let delayConditionList = call.getValue("delayConditions") else {
3132
+ logger.error("setMultiDelay called without delayCondition")
3133
+ call.reject("setMultiDelay called without delayCondition")
3134
+ return
3135
+ }
3136
+
3137
+ // Handle background conditions with empty value (set to "0")
3138
+ if var modifiableList = delayConditionList as? [[String: Any]] {
3139
+ for i in 0..<modifiableList.count {
3140
+ if let kind = modifiableList[i]["kind"] as? String,
3141
+ kind == "background",
3142
+ let value = modifiableList[i]["value"] as? String,
3143
+ value.isEmpty {
3144
+ modifiableList[i]["value"] = "0"
3145
+ }
3146
+ }
3147
+ let delayConditions: String = toJson(object: modifiableList)
3148
+ if delayUpdateUtils.setMultiDelay(delayConditions: delayConditions) {
3149
+ call.resolve()
3150
+ } else {
3151
+ call.reject("Failed to delay update")
3152
+ }
3153
+ } else {
3154
+ let delayConditions: String = toJson(object: delayConditionList)
3155
+ if delayUpdateUtils.setMultiDelay(delayConditions: delayConditions) {
3156
+ call.resolve()
3157
+ } else {
3158
+ call.reject("Failed to delay update")
3159
+ }
3160
+ }
3161
+ }
3162
+
3163
+ // Note: _setMultiDelay and _cancelDelay methods have been moved to DelayUpdateUtils class
3164
+
3165
+ func cancelDelay(_ call: CAPPluginCall) {
3166
+ if delayUpdateUtils.cancelDelay(source: "JS") {
3167
+ call.resolve()
3168
+ } else {
3169
+ call.reject("Failed to cancel delay")
3170
+ }
3171
+ }
3172
+
3173
+ // Note: _checkCancelDelay method has been moved to DelayUpdateUtils class
3174
+
3175
+ private func _isAutoUpdateEnabled() -> Bool {
3176
+ if self.isPreviewSessionStateActive() {
3177
+ return false
3178
+ }
3179
+ let serverUrl: String? = nil // Cordova: no dev server URL
3180
+ if let serverUrl, !serverUrl.isEmpty {
3181
+ logger.warn("AutoUpdate is automatic disabled when serverUrl is set.")
3182
+ }
3183
+ return self.autoUpdate && self.updateUrl != "" && (serverUrl == nil || serverUrl?.isEmpty == true)
3184
+ }
3185
+
3186
+ func isAutoUpdateEnabled(_ call: CAPPluginCall) {
3187
+ call.resolve([
3188
+ "enabled": self._isAutoUpdateEnabled()
3189
+ ])
3190
+ }
3191
+
3192
+ func isAutoUpdateAvailable(_ call: CAPPluginCall) {
3193
+ let serverUrl: String? = nil // Cordova: no dev server URL
3194
+ call.resolve([
3195
+ "available": serverUrl == nil || serverUrl?.isEmpty == true
3196
+ ])
3197
+ }
3198
+
3199
+ func checkAppReady() {
3200
+ self.appReadyCheck?.cancel()
3201
+ self.appReadyCheck = DispatchWorkItem(block: {
3202
+ self.DeferredNotifyAppReadyCheck()
3203
+ })
3204
+ logger.info("Wait for \(self.appReadyTimeout) ms, then check for notifyAppReady")
3205
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.appReadyTimeout), execute: self.appReadyCheck!)
3206
+ }
3207
+
3208
+ func checkRevert() {
3209
+ // Automatically roll back to fallback version if notifyAppReady has not been called yet
3210
+ let current: BundleInfo = self.implementation.getCurrentBundle()
3211
+ if current.isBuiltin() {
3212
+ logger.info("Built-in bundle is active. We skip the check for notifyAppReady.")
3213
+ return
3214
+ }
3215
+ if self.isPreviewSessionStateActive() {
3216
+ logger.info("Preview session is active. We skip the check for notifyAppReady.")
3217
+ return
3218
+ }
3219
+
3220
+ logger.info("Current bundle is: \(current.toString())")
3221
+
3222
+ if BundleStatus.SUCCESS.storedValue != current.getStatus() {
3223
+ logger.error("notifyAppReady was not called, roll back current bundle: \(current.toString())")
3224
+ logger.error("Did you forget to call 'notifyAppReady()' in your Capacitor App code?")
3225
+ self.notifyJSListeners("updateFailed", data: [
3226
+ "bundle": current.toJSON()
3227
+ ])
3228
+ self.persistLastFailedBundle(current)
3229
+ self.implementation.sendStats(action: "update_fail", versionName: current.getVersionName())
3230
+ self.implementation.setError(bundle: current)
3231
+ _ = self.performReset(toLastSuccessful: true, usePendingBundle: false, isInternal: true)
3232
+ if self.autoDeleteFailed && !current.isBuiltin() {
3233
+ logger.info("Deleting failing bundle: \(current.toString())")
3234
+ let res = self.implementation.delete(id: current.getId(), removeInfo: false)
3235
+ if !res {
3236
+ logger.info("Delete version deleted: \(current.toString())")
3237
+ } else {
3238
+ logger.error("Failed to delete failed bundle: \(current.toString())")
3239
+ }
3240
+ }
3241
+ } else {
3242
+ logger.info("notifyAppReady was called. This is fine: \(current.toString())")
3243
+ }
3244
+ }
3245
+
3246
+ func DeferredNotifyAppReadyCheck() {
3247
+ self.checkRevert()
3248
+ self.appReadyCheck = nil
3249
+ }
3250
+
3251
+ func endBackGroundTask() {
3252
+ UIApplication.shared.endBackgroundTask(self.backgroundTaskID)
3253
+ self.backgroundTaskID = UIBackgroundTaskIdentifier.invalid
3254
+ }
3255
+
3256
+ private func notifyBundleSet(_ bundle: BundleInfo) {
3257
+ self.notifyJSListeners("set", data: ["bundle": bundle.toJSON()])
3258
+ }
3259
+
3260
+ func sendReadyToJs(current: BundleInfo, msg: String) {
3261
+ logger.info("sendReadyToJs")
3262
+ DispatchQueue.global().async {
3263
+ self.semaphoreWait(waitTime: self.appReadyTimeout)
3264
+ self.notifyJSListeners("appReady", data: ["bundle": current.toJSON(), "status": msg])
3265
+
3266
+ // Auto hide splashscreen if enabled
3267
+ // We show it on background when conditions are met, so we should hide it on foreground regardless of update outcome
3268
+ if self.autoSplashscreen {
3269
+ self.hideSplashscreen()
3270
+ }
3271
+ self.hidePreviewTransitionLoader(reason: "app-ready")
3272
+ }
3273
+ }
3274
+
3275
+ private func hideSplashscreen() {
3276
+ if Thread.isMainThread {
3277
+ self.performHideSplashscreen()
3278
+ } else {
3279
+ DispatchQueue.main.async {
3280
+ self.performHideSplashscreen()
3281
+ }
3282
+ }
3283
+ }
3284
+
3285
+ private func performHideSplashscreen() {
3286
+ self.cancelSplashscreenTimeout()
3287
+ self.removeSplashscreenLoader()
3288
+ self.splashscreenInvocationToken += 1
3289
+ self.invokeSplashscreenMethod(
3290
+ methodName: "hide",
3291
+ callbackId: "autoHideSplashscreen",
3292
+ options: self.splashscreenOptions(methodName: "hide"),
3293
+ retriesRemaining: self.splashscreenMaxRetries,
3294
+ requestToken: self.splashscreenInvocationToken
3295
+ )
3296
+ }
3297
+
3298
+ private func showSplashscreen() {
3299
+ if Thread.isMainThread {
3300
+ self.performShowSplashscreen()
3301
+ } else {
3302
+ DispatchQueue.main.async {
3303
+ self.performShowSplashscreen()
3304
+ }
3305
+ }
3306
+ }
3307
+
3308
+ private func performShowSplashscreen() {
3309
+ self.cancelSplashscreenTimeout()
3310
+ self.autoSplashscreenTimedOut = false
3311
+ self.splashscreenInvocationToken += 1
3312
+ self.invokeSplashscreenMethod(
3313
+ methodName: "show",
3314
+ callbackId: "autoShowSplashscreen",
3315
+ options: self.splashscreenOptions(methodName: "show"),
3316
+ retriesRemaining: self.splashscreenMaxRetries,
3317
+ requestToken: self.splashscreenInvocationToken
3318
+ )
3319
+
3320
+ self.addSplashscreenLoaderIfNeeded()
3321
+ self.scheduleSplashscreenTimeout()
3322
+ }
3323
+
3324
+ private func splashscreenOptions(methodName: String) -> [String: Any] {
3325
+ methodName == "show" ? ["autoHide": false] : [:]
3326
+ }
3327
+
3328
+ private func splashscreenCompletedMessage(methodName: String) -> String {
3329
+ methodName == "show" ? "Splashscreen shown automatically" : "Splashscreen hidden automatically"
3330
+ }
3331
+
3332
+ func splashscreenOptionsForTesting(methodName: String) -> [String: Any] {
3333
+ self.splashscreenOptions(methodName: methodName)
3334
+ }
3335
+
3336
+ func isCurrentSplashscreenInvocationTokenForTesting(_ requestToken: Int) -> Bool {
3337
+ requestToken == self.splashscreenInvocationToken
3338
+ }
3339
+
3340
+ func advanceSplashscreenInvocationTokenForTesting() {
3341
+ self.splashscreenInvocationToken += 1
3342
+ }
3343
+
3344
+ private func invokeSplashscreenMethod(
3345
+ methodName: String,
3346
+ callbackId: String,
3347
+ options: [String: Any],
3348
+ retriesRemaining: Int,
3349
+ requestToken: Int
3350
+ ) {
3351
+ guard requestToken == self.splashscreenInvocationToken else {
3352
+ return
3353
+ }
3354
+ self.logger.warn("autoSplashscreen: Cordova splash screen integration is not available; skipping \(methodName)")
3355
+ }
3356
+
3357
+ private func retrySplashscreenMethod(
3358
+ methodName: String,
3359
+ callbackId: String,
3360
+ options: [String: Any],
3361
+ retriesRemaining: Int,
3362
+ requestToken: Int,
3363
+ message: String
3364
+ ) {
3365
+ guard retriesRemaining > 0 else {
3366
+ if methodName == "show" {
3367
+ self.logger.warn(message)
3368
+ } else {
3369
+ self.logger.error(message)
3370
+ }
3371
+ return
3372
+ }
3373
+
3374
+ self.logger.info("\(message). Retrying.")
3375
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.splashscreenRetryDelayMilliseconds)) { [weak self] in
3376
+ guard let self = self, requestToken == self.splashscreenInvocationToken else {
3377
+ return
3378
+ }
3379
+ self.invokeSplashscreenMethod(
3380
+ methodName: methodName,
3381
+ callbackId: callbackId,
3382
+ options: options,
3383
+ retriesRemaining: retriesRemaining - 1,
3384
+ requestToken: requestToken
3385
+ )
3386
+ }
3387
+ }
3388
+
3389
+ private func createLoaderOverlay(
3390
+ backgroundColor: UIColor,
3391
+ isUserInteractionEnabled: Bool,
3392
+ indicatorColor: UIColor?
3393
+ ) -> (container: UIView, indicator: UIActivityIndicatorView) {
3394
+ let container = UIView()
3395
+ container.translatesAutoresizingMaskIntoConstraints = false
3396
+ container.backgroundColor = backgroundColor
3397
+ container.isUserInteractionEnabled = isUserInteractionEnabled
3398
+
3399
+ let indicatorStyle: UIActivityIndicatorView.Style
3400
+ if #available(iOS 13.0, *) {
3401
+ indicatorStyle = .large
3402
+ } else {
3403
+ indicatorStyle = .whiteLarge
3404
+ }
3405
+
3406
+ let indicator = UIActivityIndicatorView(style: indicatorStyle)
3407
+ indicator.translatesAutoresizingMaskIntoConstraints = false
3408
+ indicator.hidesWhenStopped = false
3409
+ if let indicatorColor = indicatorColor {
3410
+ indicator.color = indicatorColor
3411
+ }
3412
+ indicator.startAnimating()
3413
+
3414
+ return (container, indicator)
3415
+ }
3416
+
3417
+ private func attachLoaderOverlay(
3418
+ _ overlay: (container: UIView, indicator: UIActivityIndicatorView),
3419
+ to rootView: UIView
3420
+ ) {
3421
+ overlay.container.addSubview(overlay.indicator)
3422
+ rootView.addSubview(overlay.container)
3423
+
3424
+ NSLayoutConstraint.activate([
3425
+ overlay.container.leadingAnchor.constraint(equalTo: rootView.leadingAnchor),
3426
+ overlay.container.trailingAnchor.constraint(equalTo: rootView.trailingAnchor),
3427
+ overlay.container.topAnchor.constraint(equalTo: rootView.topAnchor),
3428
+ overlay.container.bottomAnchor.constraint(equalTo: rootView.bottomAnchor),
3429
+ overlay.indicator.centerXAnchor.constraint(equalTo: overlay.container.centerXAnchor),
3430
+ overlay.indicator.centerYAnchor.constraint(equalTo: overlay.container.centerYAnchor)
3431
+ ])
3432
+ }
3433
+
3434
+ private func addSplashscreenLoaderIfNeeded() {
3435
+ guard self.autoSplashscreenLoader else {
3436
+ return
3437
+ }
3438
+
3439
+ let addLoader = {
3440
+ guard self.splashscreenLoaderContainer == nil else {
3441
+ return
3442
+ }
3443
+ guard let rootView = self.viewController?.view else {
3444
+ self.logger.warn("autoSplashscreen: Unable to access root view for loader overlay")
3445
+ return
3446
+ }
3447
+
3448
+ let indicatorColor: UIColor?
3449
+ if #available(iOS 13.0, *) {
3450
+ indicatorColor = UIColor.label
3451
+ } else {
3452
+ indicatorColor = nil
3453
+ }
3454
+ let overlay = self.createLoaderOverlay(
3455
+ backgroundColor: UIColor.clear,
3456
+ isUserInteractionEnabled: false,
3457
+ indicatorColor: indicatorColor
3458
+ )
3459
+ self.attachLoaderOverlay(overlay, to: rootView)
3460
+ self.splashscreenLoaderContainer = overlay.container
3461
+ self.splashscreenLoaderView = overlay.indicator
3462
+ }
3463
+
3464
+ if Thread.isMainThread {
3465
+ addLoader()
3466
+ } else {
3467
+ DispatchQueue.main.async {
3468
+ addLoader()
3469
+ }
3470
+ }
3471
+ }
3472
+
3473
+ private func removeSplashscreenLoader() {
3474
+ let removeLoader = {
3475
+ self.splashscreenLoaderView?.stopAnimating()
3476
+ self.splashscreenLoaderContainer?.removeFromSuperview()
3477
+ self.splashscreenLoaderView = nil
3478
+ self.splashscreenLoaderContainer = nil
3479
+ }
3480
+
3481
+ if Thread.isMainThread {
3482
+ removeLoader()
3483
+ } else {
3484
+ DispatchQueue.main.async {
3485
+ removeLoader()
3486
+ }
3487
+ }
3488
+ }
3489
+
3490
+ private func showPreviewTransitionLoader(reason: String) {
3491
+ self.previewTransitionLoaderRequested = true
3492
+ let showLoader = {
3493
+ guard self.previewTransitionLoaderRequested else {
3494
+ return
3495
+ }
3496
+
3497
+ if let container = self.previewTransitionLoaderContainer {
3498
+ self.previewTransitionLoaderTimeoutWorkItem?.cancel()
3499
+ self.schedulePreviewTransitionLoaderTimeout()
3500
+ container.superview?.bringSubviewToFront(container)
3501
+ return
3502
+ }
3503
+
3504
+ guard let rootView = self.viewController?.view else {
3505
+ self.logger.warn("Preview transition loader unavailable: root view missing for \(reason)")
3506
+ self.previewTransitionLoaderRequested = false
3507
+ return
3508
+ }
3509
+
3510
+ self.previewTransitionLoaderTimeoutWorkItem?.cancel()
3511
+ self.schedulePreviewTransitionLoaderTimeout()
3512
+
3513
+ let indicatorColor: UIColor?
3514
+ if #available(iOS 13.0, *) {
3515
+ indicatorColor = UIColor.white
3516
+ } else {
3517
+ indicatorColor = nil
3518
+ }
3519
+ let overlay = self.createLoaderOverlay(
3520
+ backgroundColor: UIColor.black.withAlphaComponent(0.18),
3521
+ isUserInteractionEnabled: true,
3522
+ indicatorColor: indicatorColor
3523
+ )
3524
+ self.attachLoaderOverlay(overlay, to: rootView)
3525
+ self.previewTransitionLoaderContainer = overlay.container
3526
+ self.previewTransitionLoaderView = overlay.indicator
3527
+ self.logger.info("Preview transition loader shown: \(reason)")
3528
+ }
3529
+
3530
+ if Thread.isMainThread {
3531
+ showLoader()
3532
+ } else {
3533
+ DispatchQueue.main.async {
3534
+ showLoader()
3535
+ }
3536
+ }
3537
+ }
3538
+
3539
+ private func hidePreviewTransitionLoader(reason: String) {
3540
+ if !self.previewTransitionLoaderRequested &&
3541
+ self.previewTransitionLoaderContainer == nil &&
3542
+ self.previewTransitionLoaderTimeoutWorkItem == nil {
3543
+ return
3544
+ }
3545
+
3546
+ let hideLoader = {
3547
+ self.previewTransitionLoaderRequested = false
3548
+ self.previewTransitionLoaderTimeoutWorkItem?.cancel()
3549
+ self.previewTransitionLoaderTimeoutWorkItem = nil
3550
+ guard self.previewTransitionLoaderContainer != nil else {
3551
+ return
3552
+ }
3553
+ self.previewTransitionLoaderView?.stopAnimating()
3554
+ self.previewTransitionLoaderContainer?.removeFromSuperview()
3555
+ self.previewTransitionLoaderView = nil
3556
+ self.previewTransitionLoaderContainer = nil
3557
+ self.logger.info("Preview transition loader hidden: \(reason)")
3558
+ }
3559
+
3560
+ if Thread.isMainThread {
3561
+ hideLoader()
3562
+ } else {
3563
+ DispatchQueue.main.async {
3564
+ hideLoader()
3565
+ }
3566
+ }
3567
+ }
3568
+
3569
+ private func schedulePreviewTransitionLoaderTimeout() {
3570
+ self.previewTransitionLoaderTimeoutWorkItem?.cancel()
3571
+ let workItem = DispatchWorkItem { [weak self] in
3572
+ self?.hidePreviewTransitionLoader(reason: "preview-transition-timeout")
3573
+ }
3574
+ self.previewTransitionLoaderTimeoutWorkItem = workItem
3575
+ DispatchQueue.main.asyncAfter(
3576
+ deadline: .now() + .milliseconds(Self.previewLoaderTimeoutMs),
3577
+ execute: workItem
3578
+ )
3579
+ }
3580
+
3581
+ private func scheduleSplashscreenTimeout() {
3582
+ guard self.autoSplashscreenTimeout > 0 else {
3583
+ return
3584
+ }
3585
+
3586
+ let scheduleTimeout = {
3587
+ self.autoSplashscreenTimeoutWorkItem?.cancel()
3588
+
3589
+ let workItem = DispatchWorkItem { [weak self] in
3590
+ guard let self = self else { return }
3591
+ self.autoSplashscreenTimedOut = true
3592
+ self.logger.info("autoSplashscreen timeout reached, hiding splashscreen")
3593
+ self.hideSplashscreen()
3594
+ }
3595
+ self.autoSplashscreenTimeoutWorkItem = workItem
3596
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.autoSplashscreenTimeout), execute: workItem)
3597
+ }
3598
+
3599
+ if Thread.isMainThread {
3600
+ scheduleTimeout()
3601
+ } else {
3602
+ DispatchQueue.main.async {
3603
+ scheduleTimeout()
3604
+ }
3605
+ }
3606
+ }
3607
+
3608
+ private func cancelSplashscreenTimeout() {
3609
+ let cancelTimeout = {
3610
+ self.autoSplashscreenTimeoutWorkItem?.cancel()
3611
+ self.autoSplashscreenTimeoutWorkItem = nil
3612
+ }
3613
+
3614
+ if Thread.isMainThread {
3615
+ cancelTimeout()
3616
+ } else {
3617
+ DispatchQueue.main.async {
3618
+ cancelTimeout()
3619
+ }
3620
+ }
3621
+ }
3622
+
3623
+ private func checkIfRecentlyInstalledOrUpdated() -> Bool {
3624
+ let userDefaults = UserDefaults.standard
3625
+ let currentVersion = self.currentBuildVersion
3626
+ let lastKnownVersion = userDefaults.string(forKey: "LatestNativeBuildVersion") ?? "0"
3627
+
3628
+ if lastKnownVersion == "0" {
3629
+ // First time running, consider it as recently installed
3630
+ return true
3631
+ } else if lastKnownVersion != currentVersion {
3632
+ // Version changed, consider it as recently updated
3633
+ return true
3634
+ }
3635
+
3636
+ return false
3637
+ }
3638
+
3639
+ private func shouldUseDirectUpdate() -> Bool {
3640
+ if !self.autoUpdate || self.autoUpdateMode == Self.autoUpdateModeOnlyDownload {
3641
+ return false
3642
+ }
3643
+ if self.autoSplashscreenTimedOut {
3644
+ return false
3645
+ }
3646
+ switch directUpdateMode {
3647
+ case "false":
3648
+ return false
3649
+ case "always":
3650
+ return true
3651
+ case "atInstall":
3652
+ if self.wasRecentlyInstalledOrUpdated {
3653
+ // Reset the flag after first use to prevent subsequent foreground events from using direct update
3654
+ self.wasRecentlyInstalledOrUpdated = false
3655
+ return true
3656
+ }
3657
+ return false
3658
+ case "onLaunch":
3659
+ if !self.getOnLaunchDirectUpdateUsed() {
3660
+ return true
3661
+ }
3662
+ return false
3663
+ default:
3664
+ logger.error("Invalid directUpdateMode: \"\(self.directUpdateMode)\". Supported values are: \"false\", \"always\", \"atInstall\", \"onLaunch\". Defaulting to \"false\" behavior.")
3665
+ return false
3666
+ }
3667
+ }
3668
+
3669
+ private func configureAutoUpdateModeFromConfig() {
3670
+ if let configuredMode = readConfigString("autoUpdate"),
3671
+ configuredMode != "",
3672
+ configuredMode != "true",
3673
+ configuredMode != "false" {
3674
+ autoUpdateMode = Self.normalizedAutoUpdateMode(configuredMode)
3675
+ if autoUpdateMode != configuredMode {
3676
+ logger.error(
3677
+ "Invalid autoUpdate value: \"\(configuredMode)\". Supported values are: true, false, " +
3678
+ "\"off\", \"atBackground\", \"atInstall\", \"onLaunch\", \"always\", \"onlyDownload\". Defaulting to \"atBackground\"."
3679
+ )
3680
+ }
3681
+ } else {
3682
+ let configuredMode = readConfigString("autoUpdate")
3683
+ let enabled = configuredMode != nil ? configuredMode == "true" : readConfigBool("autoUpdate", true)
3684
+ autoUpdateMode = enabled
3685
+ ? Self.autoUpdateModeForLegacyDirectUpdateMode(resolveLegacyDirectUpdateModeFromConfig())
3686
+ : Self.autoUpdateModeOff
3687
+ }
3688
+
3689
+ autoUpdate = Self.isAutoUpdateModeEnabled(autoUpdateMode)
3690
+ directUpdateMode = Self.directUpdateModeForAutoUpdateMode(autoUpdateMode)
3691
+ directUpdate = Self.isDirectUpdateMode(directUpdateMode)
3692
+ }
3693
+
3694
+ private func resolveLegacyDirectUpdateModeFromConfig() -> String {
3695
+ if let directUpdateString = readConfigString("directUpdate") {
3696
+ if directUpdateString == "true" {
3697
+ return Self.autoUpdateModeAlways
3698
+ }
3699
+ if directUpdateString == "false" || Self.isDirectUpdateMode(directUpdateString) {
3700
+ return directUpdateString
3701
+ }
3702
+ logger.error(
3703
+ "Invalid directUpdate value: \"\(directUpdateString)\". Supported values are: false, true, " +
3704
+ "\"always\", \"atInstall\", \"onLaunch\". Defaulting to \"false\"."
3705
+ )
3706
+ return "false"
3707
+ }
3708
+
3709
+ return readConfigBool("directUpdate", false) ? Self.autoUpdateModeAlways : "false"
3710
+ }
3711
+
3712
+ static func normalizedAutoUpdateMode(_ value: String?) -> String {
3713
+ guard let value else {
3714
+ return autoUpdateModeBackground
3715
+ }
3716
+ switch value {
3717
+ case "false", autoUpdateModeOff:
3718
+ return autoUpdateModeOff
3719
+ case "true", autoUpdateModeBackground:
3720
+ return autoUpdateModeBackground
3721
+ case autoUpdateModeInstall, autoUpdateModeLaunch, autoUpdateModeAlways, autoUpdateModeOnlyDownload:
3722
+ return value
3723
+ default:
3724
+ return autoUpdateModeBackground
3725
+ }
3726
+ }
3727
+
3728
+ static func normalizedShakeMenuGesture(_ value: String?) -> String {
3729
+ guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
3730
+ return shakeMenuGestureShake
3731
+ }
3732
+ let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
3733
+ if normalized == shakeMenuGestureThreeFingerPinch {
3734
+ return shakeMenuGestureThreeFingerPinch
3735
+ }
3736
+ return shakeMenuGestureShake
3737
+ }
3738
+
3739
+ static func isSupportedShakeMenuGesture(_ value: String?) -> Bool {
3740
+ guard let value else {
3741
+ return true
3742
+ }
3743
+ let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
3744
+ if normalized.isEmpty {
3745
+ return false
3746
+ }
3747
+ return normalized == shakeMenuGestureShake || normalized == shakeMenuGestureThreeFingerPinch
3748
+ }
3749
+
3750
+ static func autoUpdateModeForLegacyDirectUpdateMode(_ directUpdateMode: String) -> String {
3751
+ switch directUpdateMode {
3752
+ case autoUpdateModeInstall, autoUpdateModeLaunch, autoUpdateModeAlways:
3753
+ return directUpdateMode
3754
+ default:
3755
+ return autoUpdateModeBackground
3756
+ }
3757
+ }
3758
+
3759
+ static func directUpdateModeForAutoUpdateMode(_ autoUpdateMode: String) -> String {
3760
+ switch autoUpdateMode {
3761
+ case autoUpdateModeInstall, autoUpdateModeLaunch, autoUpdateModeAlways:
3762
+ return autoUpdateMode
3763
+ default:
3764
+ return "false"
3765
+ }
3766
+ }
3767
+
3768
+ static func isAutoUpdateModeEnabled(_ autoUpdateMode: String) -> Bool {
3769
+ autoUpdateMode != autoUpdateModeOff
3770
+ }
3771
+
3772
+ static func shouldAutoUpdateModeSetNextBundle(_ autoUpdateMode: String) -> Bool {
3773
+ isAutoUpdateModeEnabled(autoUpdateMode) && autoUpdateMode != autoUpdateModeOnlyDownload
3774
+ }
3775
+
3776
+ static func isDirectUpdateMode(_ directUpdateMode: String) -> Bool {
3777
+ directUpdateMode == autoUpdateModeInstall || directUpdateMode == autoUpdateModeLaunch || directUpdateMode == autoUpdateModeAlways
3778
+ }
3779
+
3780
+ private func shouldAutoSetNextBundle() -> Bool {
3781
+ Self.shouldAutoUpdateModeSetNextBundle(autoUpdateMode)
3782
+ }
3783
+
3784
+ static func shouldConsumeOnLaunchDirectUpdate(directUpdateMode: String, plannedDirectUpdate: Bool) -> Bool {
3785
+ plannedDirectUpdate && directUpdateMode == "onLaunch"
3786
+ }
3787
+
3788
+ static func normalizedPeriodCheckDelaySeconds(_ value: Int) -> Int {
3789
+ guard value > 0 else {
3790
+ return 0
3791
+ }
3792
+ return max(600, value)
3793
+ }
3794
+
3795
+ private func getOnLaunchDirectUpdateUsed() -> Bool {
3796
+ self.onLaunchDirectUpdateStateLock.lock()
3797
+ defer { self.onLaunchDirectUpdateStateLock.unlock() }
3798
+ return self.onLaunchDirectUpdateUsed
3799
+ }
3800
+
3801
+ private func setOnLaunchDirectUpdateUsed(_ used: Bool) {
3802
+ self.onLaunchDirectUpdateStateLock.lock()
3803
+ self.onLaunchDirectUpdateUsed = used
3804
+ self.onLaunchDirectUpdateStateLock.unlock()
3805
+ }
3806
+
3807
+ private func consumeOnLaunchDirectUpdateAttempt(plannedDirectUpdate: Bool) {
3808
+ guard Self.shouldConsumeOnLaunchDirectUpdate(directUpdateMode: self.directUpdateMode, plannedDirectUpdate: plannedDirectUpdate) else {
3809
+ return
3810
+ }
3811
+
3812
+ self.setOnLaunchDirectUpdateUsed(true)
3813
+ }
3814
+
3815
+ func configureDirectUpdateModeForTesting(_ directUpdateMode: String, onLaunchDirectUpdateUsed: Bool = false) {
3816
+ self.directUpdateMode = directUpdateMode
3817
+ self.autoUpdateMode = Self.autoUpdateModeForLegacyDirectUpdateMode(directUpdateMode)
3818
+ self.autoUpdate = Self.isAutoUpdateModeEnabled(self.autoUpdateMode)
3819
+ self.directUpdate = Self.isDirectUpdateMode(self.directUpdateMode)
3820
+ self.setOnLaunchDirectUpdateUsed(onLaunchDirectUpdateUsed)
3821
+ }
3822
+
3823
+ func setUpdateUrlForTesting(_ updateUrl: String) {
3824
+ self.updateUrl = updateUrl
3825
+ }
3826
+
3827
+ func setAutoUpdateModeForTesting(_ autoUpdateMode: String) {
3828
+ self.autoUpdateMode = Self.normalizedAutoUpdateMode(autoUpdateMode)
3829
+ self.autoUpdate = Self.isAutoUpdateModeEnabled(self.autoUpdateMode)
3830
+ self.directUpdateMode = Self.directUpdateModeForAutoUpdateMode(self.autoUpdateMode)
3831
+ self.directUpdate = Self.isDirectUpdateMode(self.directUpdateMode)
3832
+ }
3833
+
3834
+ func setCurrentBuildVersionForTesting(_ currentBuildVersion: String) {
3835
+ self.currentBuildVersion = currentBuildVersion
3836
+ }
3837
+
3838
+ func shouldUseDirectUpdateForTesting() -> Bool {
3839
+ self.shouldUseDirectUpdate()
3840
+ }
3841
+
3842
+ var hasConsumedOnLaunchDirectUpdateForTesting: Bool {
3843
+ self.getOnLaunchDirectUpdateUsed()
3844
+ }
3845
+
3846
+ private func notifyBreakingEvents(version: String) {
3847
+ guard !version.isEmpty else {
3848
+ return
3849
+ }
3850
+ let payload: [String: Any] = ["version": version]
3851
+ self.notifyJSListeners("breakingAvailable", data: payload)
3852
+ self.notifyJSListeners("majorAvailable", data: payload)
3853
+ }
3854
+
3855
+ private func shouldNotifyBreakingEvents(response: AppVersion) -> Bool {
3856
+ if response.breaking == true {
3857
+ return true
3858
+ }
3859
+
3860
+ return response.error == "disable_auto_update_to_major" || response.message == "store_update_required"
3861
+ }
3862
+
3863
+ private func notifyBreakingEventsIfNeeded(response: AppVersion, version: String) {
3864
+ if self.shouldNotifyBreakingEvents(response: response) {
3865
+ let eventVersion = version.isEmpty ? self.implementation.getCurrentBundle().getVersionName() : version
3866
+ self.notifyBreakingEvents(version: eventVersion)
3867
+ }
3868
+ }
3869
+
3870
+ static func normalizedUpdateResponseKind(kind: String?) -> String {
3871
+ if let kind, ["up_to_date", "blocked", "failed"].contains(kind) {
3872
+ return kind
3873
+ }
3874
+ return "failed"
3875
+ }
3876
+
3877
+ private func updateResponseKind(kind: String?) -> String {
3878
+ Self.normalizedUpdateResponseKind(kind: kind)
3879
+ }
3880
+
3881
+ private func endBackgroundDownloadAfterLatestError(
3882
+ backendError: String,
3883
+ res: AppVersion,
3884
+ current: BundleInfo,
3885
+ plannedDirectUpdate: Bool
3886
+ ) {
3887
+ let statusCode = res.statusCode
3888
+ let responseKind = self.updateResponseKind(kind: res.kind)
3889
+ let responseMessage = res.message?.isEmpty == false ? res.message : nil
3890
+ let message = responseMessage ?? (backendError.isEmpty ? "server did not provide a message" : backendError)
3891
+ let latestVersionName = res.version.isEmpty ? current.getVersionName() : res.version
3892
+ self.notifyJSListeners("updateCheckResult", data: [
3893
+ "kind": responseKind,
3894
+ "error": backendError,
3895
+ "message": message,
3896
+ "statusCode": statusCode,
3897
+ "version": latestVersionName,
3898
+ "bundle": current.toJSON()
3899
+ ])
3900
+ self.notifyBreakingEventsIfNeeded(response: res, version: res.version)
3901
+
3902
+ if responseKind == "up_to_date" {
3903
+ self.logger.info("No new version available")
3904
+ } else if responseKind == "blocked" {
3905
+ self.logger.info("Update check blocked with error: \(backendError)")
3906
+ } else {
3907
+ self.logger.error("getLatest failed with error: \(backendError)")
3908
+ }
3909
+
3910
+ let isFailure = responseKind == "failed"
3911
+ self.endBackGroundTaskWithNotif(
3912
+ msg: message,
3913
+ latestVersionName: latestVersionName,
3914
+ current: current,
3915
+ error: isFailure,
3916
+ plannedDirectUpdate: plannedDirectUpdate,
3917
+ sendStats: isFailure
3918
+ )
3919
+ }
3920
+
3921
+ func endBackGroundTaskWithNotif(
3922
+ msg: String,
3923
+ latestVersionName: String,
3924
+ current: BundleInfo,
3925
+ error: Bool = true,
3926
+ plannedDirectUpdate: Bool = false,
3927
+ failureAction: String = "download_fail",
3928
+ failureEvent: String = "downloadFailed",
3929
+ sendStats: Bool = true,
3930
+ notifyNoNeedUpdate: Bool = true
3931
+ ) {
3932
+ // Clear download in progress flag - this is called at the end of every download attempt
3933
+ // whether it succeeds, fails, or is skipped (e.g., already up to date)
3934
+ downloadLock.lock()
3935
+ defer { downloadLock.unlock() }
3936
+ downloadInProgress = false
3937
+ downloadStartTime = nil
3938
+
3939
+ self.consumeOnLaunchDirectUpdateAttempt(plannedDirectUpdate: plannedDirectUpdate)
3940
+
3941
+ if error {
3942
+ if sendStats {
3943
+ self.implementation.sendStats(action: failureAction, versionName: current.getVersionName())
3944
+ }
3945
+ self.notifyJSListeners(failureEvent, data: ["version": latestVersionName])
3946
+ }
3947
+ if notifyNoNeedUpdate {
3948
+ self.notifyJSListeners("noNeedUpdate", data: ["bundle": current.toJSON()])
3949
+ }
3950
+ self.sendReadyToJs(current: current, msg: msg)
3951
+ logger.info("endBackGroundTaskWithNotif \(msg) current: \(current.getVersionName()) latestVersionName: \(latestVersionName)")
3952
+ self.endBackGroundTask()
3953
+ }
3954
+
3955
+ private func isDownloadStuckOrTimedOut() -> Bool {
3956
+ downloadLock.lock()
3957
+ defer { downloadLock.unlock() }
3958
+
3959
+ guard downloadInProgress else {
3960
+ return false
3961
+ }
3962
+
3963
+ // Check if download has timed out
3964
+ if let startTime = downloadStartTime {
3965
+ let elapsed = Date().timeIntervalSince(startTime)
3966
+ if elapsed > downloadTimeout {
3967
+ self.logger.warn("Download has been in progress for \(elapsed) seconds, exceeding timeout of \(downloadTimeout) seconds. Clearing stuck state.")
3968
+ downloadInProgress = false
3969
+ downloadStartTime = nil
3970
+ return false // Now it's not stuck anymore, caller can proceed
3971
+ }
3972
+ }
3973
+
3974
+ return true
3975
+ }
3976
+
3977
+ private func clearDownloadInProgressState() {
3978
+ downloadLock.lock()
3979
+ defer { downloadLock.unlock() }
3980
+ downloadInProgress = false
3981
+ downloadStartTime = nil
3982
+ }
3983
+
3984
+ func runBackgroundDownloadWork(_ work: @escaping () -> Void) {
3985
+ // Live update checks/downloads are user-visible work. Using `.background`
3986
+ // lets the scheduler starve them for minutes while the app is active.
3987
+ DispatchQueue.global(qos: .utility).async(execute: work)
3988
+ }
3989
+
3990
+ private func beginDownloadBackgroundTask() {
3991
+ let registerTask = {
3992
+ self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "Finish Download Tasks") {
3993
+ self.endBackGroundTask()
3994
+ }
3995
+ }
3996
+
3997
+ if Thread.isMainThread {
3998
+ registerTask()
3999
+ } else {
4000
+ DispatchQueue.main.sync(execute: registerTask)
4001
+ }
4002
+ }
4003
+
4004
+ func runGetLatestWork(_ work: @escaping () -> Void) {
4005
+ DispatchQueue.global(qos: .background).async(execute: work)
4006
+ }
4007
+
4008
+ func backgroundDownload() {
4009
+ if self.shouldBlockAutoUpdateForPreviewSession() {
4010
+ return
4011
+ }
4012
+ // Set download in progress flag (thread-safe)
4013
+ downloadLock.lock()
4014
+ downloadInProgress = true
4015
+ downloadStartTime = Date()
4016
+ downloadLock.unlock()
4017
+
4018
+ let plannedDirectUpdate = self.shouldUseDirectUpdate()
4019
+ let messageUpdate: String
4020
+ if plannedDirectUpdate {
4021
+ messageUpdate = "Update will occur now."
4022
+ } else if self.shouldAutoSetNextBundle() {
4023
+ messageUpdate = "Update will occur next time app moves to background."
4024
+ } else {
4025
+ messageUpdate = "Update will be downloaded and made available."
4026
+ }
4027
+ guard let url = URL(string: self.updateUrl) else {
4028
+ logger.error("Error no url or wrong format")
4029
+ // Clear the flag if we return early
4030
+ downloadLock.lock()
4031
+ defer { downloadLock.unlock() }
4032
+ downloadInProgress = false
4033
+ downloadStartTime = nil
4034
+ return
4035
+ }
4036
+
4037
+ self.runBackgroundDownloadWork {
4038
+ // Wait for cleanup to complete before starting download
4039
+ self.waitForCleanupIfNeeded()
4040
+ if self.shouldBlockAutoUpdateForPreviewSession() {
4041
+ self.clearDownloadInProgressState()
4042
+ return
4043
+ }
4044
+ self.beginDownloadBackgroundTask()
4045
+ self.logger.info("Check for update via \(self.updateUrl)")
4046
+ let res = self.implementation.getLatest(url: url, channel: nil)
4047
+ let current = self.implementation.getCurrentBundle()
4048
+ if self.shouldBlockAutoUpdateForPreviewSession() {
4049
+ self.clearDownloadInProgressState()
4050
+ self.endBackGroundTask()
4051
+ return
4052
+ }
4053
+
4054
+ // Handle network errors and other failures first
4055
+ let backendError = res.error ?? ""
4056
+ let backendKind = res.kind ?? ""
4057
+ if !backendError.isEmpty || !backendKind.isEmpty {
4058
+ self.endBackgroundDownloadAfterLatestError(
4059
+ backendError: backendError,
4060
+ res: res,
4061
+ current: current,
4062
+ plannedDirectUpdate: plannedDirectUpdate
4063
+ )
4064
+ return
4065
+ }
4066
+ if res.version == "builtin" {
4067
+ self.logger.info("Latest version is builtin")
4068
+ let directUpdateAllowed = plannedDirectUpdate && !self.autoSplashscreenTimedOut
4069
+ if directUpdateAllowed {
4070
+ self.logger.info("Direct update to builtin version")
4071
+ _ = self._reset(toLastSuccessful: false, usePendingBundle: false)
4072
+ self.endBackGroundTaskWithNotif(
4073
+ msg: "Updated to builtin version",
4074
+ latestVersionName: res.version,
4075
+ current: self.implementation.getCurrentBundle(),
4076
+ error: false,
4077
+ plannedDirectUpdate: plannedDirectUpdate
4078
+ )
4079
+ } else if self.shouldAutoSetNextBundle() {
4080
+ if plannedDirectUpdate && !directUpdateAllowed {
4081
+ self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will apply later.")
4082
+ }
4083
+ self.logger.info("Setting next bundle to builtin")
4084
+ _ = self.implementation.setNextBundle(next: BundleInfo.ID_BUILTIN)
4085
+ self.endBackGroundTaskWithNotif(
4086
+ msg: "Next update will be to builtin version",
4087
+ latestVersionName: res.version,
4088
+ current: current,
4089
+ error: false,
4090
+ plannedDirectUpdate: plannedDirectUpdate
4091
+ )
4092
+ } else {
4093
+ self.logger.info("autoUpdate is set to onlyDownload, builtin version will not be set as next bundle")
4094
+ let builtinUpdateAvailable = !current.isBuiltin()
4095
+ if builtinUpdateAvailable {
4096
+ let builtinBundle = self.implementation.getBundleInfo(id: BundleInfo.ID_BUILTIN)
4097
+ self.notifyJSListeners("updateAvailable", data: ["bundle": builtinBundle.toJSON()])
4098
+ }
4099
+ self.endBackGroundTaskWithNotif(
4100
+ msg: "Latest version is builtin, autoUpdate onlyDownload",
4101
+ latestVersionName: res.version,
4102
+ current: current,
4103
+ error: false,
4104
+ plannedDirectUpdate: plannedDirectUpdate,
4105
+ notifyNoNeedUpdate: !builtinUpdateAvailable
4106
+ )
4107
+ }
4108
+ return
4109
+ }
4110
+ let sessionKey = res.sessionKey ?? ""
4111
+ let latestVersionName = res.version
4112
+ guard let downloadUrl = URL(string: res.url) else {
4113
+ self.notifyBreakingEventsIfNeeded(response: res, version: latestVersionName)
4114
+ self.logger.error("Error no url or wrong format")
4115
+ self.endBackGroundTaskWithNotif(
4116
+ msg: "Error no url or wrong format",
4117
+ latestVersionName: latestVersionName,
4118
+ current: current,
4119
+ plannedDirectUpdate: plannedDirectUpdate
4120
+ )
4121
+ return
4122
+ }
4123
+ if latestVersionName != "" && current.getVersionName() != latestVersionName {
4124
+ do {
4125
+ self.logger.info("New bundle: \(latestVersionName) found. Current is: \(current.getVersionName()). \(messageUpdate)")
4126
+ var nextImpl = self.implementation.getBundleInfoByVersionName(version: latestVersionName)
4127
+ if nextImpl == nil || nextImpl?.isDeleted() == true {
4128
+ if nextImpl?.isDeleted() == true {
4129
+ self.logger.info("Latest bundle already exists and will be deleted, download will overwrite it.")
4130
+ let res = self.implementation.delete(id: nextImpl!.getId(), removeInfo: true)
4131
+ if res {
4132
+ self.logger.info("Failed bundle deleted: \(nextImpl!.toString())")
4133
+ } else {
4134
+ self.logger.error("Failed to delete failed bundle: \(nextImpl!.toString())")
4135
+ }
4136
+ }
4137
+ self.consumeOnLaunchDirectUpdateAttempt(plannedDirectUpdate: plannedDirectUpdate)
4138
+ if res.manifest != nil {
4139
+ nextImpl = try self.implementation.downloadManifest(manifest: res.manifest!, version: latestVersionName, sessionKey: sessionKey, link: res.link, comment: res.comment)
4140
+ } else {
4141
+ nextImpl = try self.implementation.download(url: downloadUrl, version: latestVersionName, sessionKey: sessionKey, link: res.link, comment: res.comment)
4142
+ }
4143
+ }
4144
+ guard let next = nextImpl else {
4145
+ self.logger.error("Error downloading file")
4146
+ self.endBackGroundTaskWithNotif(
4147
+ msg: "Error downloading file",
4148
+ latestVersionName: latestVersionName,
4149
+ current: current,
4150
+ plannedDirectUpdate: plannedDirectUpdate
4151
+ )
4152
+ return
4153
+ }
4154
+ if next.isErrorStatus() {
4155
+ self.logger.error("Latest bundle already exists and is in error state. Aborting update.")
4156
+ self.endBackGroundTaskWithNotif(
4157
+ msg: "Latest version is in error state. Aborting update.",
4158
+ latestVersionName: latestVersionName,
4159
+ current: current,
4160
+ plannedDirectUpdate: plannedDirectUpdate
4161
+ )
4162
+ return
4163
+ }
4164
+ if self.shouldBlockAutoUpdateForPreviewSession() {
4165
+ self.clearDownloadInProgressState()
4166
+ self.endBackGroundTask()
4167
+ return
4168
+ }
4169
+ res.checksum = try CryptoCipher.decryptChecksum(checksum: res.checksum, publicKey: self.implementation.publicKey)
4170
+ CryptoCipher.logChecksumInfo(label: "Bundle checksum", hexChecksum: next.getChecksum())
4171
+ CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: res.checksum)
4172
+ if res.checksum != "" && next.getChecksum() != res.checksum && res.manifest == nil {
4173
+ self.logger.error("Error checksum \(next.getChecksum()) \(res.checksum)")
4174
+ self.implementation.sendStats(action: "checksum_fail", versionName: next.getVersionName())
4175
+ let id = next.getId()
4176
+ let resDel = self.implementation.delete(id: id)
4177
+ if !resDel {
4178
+ self.logger.error("Delete failed, id \(id) doesn't exist")
4179
+ }
4180
+ self.endBackGroundTaskWithNotif(
4181
+ msg: "Error checksum",
4182
+ latestVersionName: latestVersionName,
4183
+ current: current,
4184
+ plannedDirectUpdate: plannedDirectUpdate
4185
+ )
4186
+ return
4187
+ }
4188
+ if self.shouldBlockAutoUpdateForPreviewSession() {
4189
+ self.clearDownloadInProgressState()
4190
+ self.endBackGroundTask()
4191
+ return
4192
+ }
4193
+ let directUpdateAllowed = plannedDirectUpdate && !self.autoSplashscreenTimedOut
4194
+ if directUpdateAllowed {
4195
+ let delayUpdatePreferences = UserDefaults.standard.string(forKey: DelayUpdateUtils.DELAY_CONDITION_PREFERENCES) ?? "[]"
4196
+ let delayConditionList: [DelayCondition] = self.fromJsonArr(json: delayUpdatePreferences).map { obj -> DelayCondition in
4197
+ let kind: String = obj.value(forKey: "kind") as! String
4198
+ let value: String? = obj.value(forKey: "value") as? String
4199
+ return DelayCondition(kind: kind, value: value)
4200
+ }
4201
+ if !delayConditionList.isEmpty {
4202
+ self.logger.info("Update delayed until delay conditions met")
4203
+ self.endBackGroundTaskWithNotif(
4204
+ msg: "Update delayed until delay conditions met",
4205
+ latestVersionName: latestVersionName,
4206
+ current: next,
4207
+ error: false,
4208
+ plannedDirectUpdate: plannedDirectUpdate
4209
+ )
4210
+ return
4211
+ }
4212
+ if self.applyDownloadedBundleForDirectUpdate(next) {
4213
+ self.notifyBundleSet(next)
4214
+ self.endBackGroundTaskWithNotif(
4215
+ msg: "update installed",
4216
+ latestVersionName: latestVersionName,
4217
+ current: next,
4218
+ error: false,
4219
+ plannedDirectUpdate: plannedDirectUpdate
4220
+ )
4221
+ } else {
4222
+ _ = self.implementation.setNextBundle(next: next.getId())
4223
+ self.notifyJSListeners("updateAvailable", data: ["bundle": next.toJSON()])
4224
+ self.endBackGroundTaskWithNotif(
4225
+ msg: "Direct update reload failed, update will install next background",
4226
+ latestVersionName: latestVersionName,
4227
+ current: self.implementation.getCurrentBundle(),
4228
+ error: false,
4229
+ plannedDirectUpdate: plannedDirectUpdate
4230
+ )
4231
+ }
4232
+ } else if self.shouldAutoSetNextBundle() {
4233
+ if plannedDirectUpdate && !directUpdateAllowed {
4234
+ self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will install on next app background.")
4235
+ }
4236
+ self.notifyJSListeners("updateAvailable", data: ["bundle": next.toJSON()])
4237
+ _ = self.implementation.setNextBundle(next: next.getId())
4238
+ self.endBackGroundTaskWithNotif(
4239
+ msg: "update downloaded, will install next background",
4240
+ latestVersionName: latestVersionName,
4241
+ current: current,
4242
+ error: false,
4243
+ plannedDirectUpdate: plannedDirectUpdate
4244
+ )
4245
+ } else {
4246
+ self.logger.info("autoUpdate is set to onlyDownload, downloaded update will not be set as next bundle")
4247
+ self.notifyJSListeners("updateAvailable", data: ["bundle": next.toJSON()])
4248
+ self.endBackGroundTaskWithNotif(
4249
+ msg: "update downloaded, autoUpdate onlyDownload",
4250
+ latestVersionName: latestVersionName,
4251
+ current: current,
4252
+ error: false,
4253
+ plannedDirectUpdate: plannedDirectUpdate,
4254
+ notifyNoNeedUpdate: false
4255
+ )
4256
+ }
4257
+ return
4258
+ } catch {
4259
+ self.logger.error("Error downloading file \(error.localizedDescription)")
4260
+ let current: BundleInfo = self.implementation.getCurrentBundle()
4261
+ self.endBackGroundTaskWithNotif(
4262
+ msg: "Error downloading file",
4263
+ latestVersionName: latestVersionName,
4264
+ current: current,
4265
+ plannedDirectUpdate: plannedDirectUpdate
4266
+ )
4267
+ return
4268
+ }
4269
+ } else {
4270
+ self.logger.info("No need to update, \(current.getId()) is the latest bundle.")
4271
+ self.endBackGroundTaskWithNotif(
4272
+ msg: "No need to update, \(current.getId()) is the latest bundle.",
4273
+ latestVersionName: latestVersionName,
4274
+ current: current,
4275
+ error: false,
4276
+ plannedDirectUpdate: plannedDirectUpdate
4277
+ )
4278
+ return
4279
+ }
4280
+ }
4281
+ }
4282
+
4283
+ private func installNext() {
4284
+ if self.shouldBlockAutoUpdateForPreviewSession() {
4285
+ return
4286
+ }
4287
+ let delayUpdatePreferences = UserDefaults.standard.string(forKey: DelayUpdateUtils.DELAY_CONDITION_PREFERENCES) ?? "[]"
4288
+ let delayConditionList: [DelayCondition] = fromJsonArr(json: delayUpdatePreferences).map { obj -> DelayCondition in
4289
+ let kind: String = obj.value(forKey: "kind") as! String
4290
+ let value: String? = obj.value(forKey: "value") as? String
4291
+ return DelayCondition(kind: kind, value: value)
4292
+ }
4293
+ if !delayConditionList.isEmpty {
4294
+ logger.info("Update delayed until delay conditions met")
4295
+ return
4296
+ }
4297
+ let current: BundleInfo = self.implementation.getCurrentBundle()
4298
+ let next: BundleInfo? = self.implementation.getNextBundle()
4299
+
4300
+ if next != nil && !next!.isErrorStatus() && next!.getVersionName() != current.getVersionName() {
4301
+ logger.info("Next bundle is: \(next!.toString())")
4302
+ if self.implementation.set(bundle: next!) && self._reload() {
4303
+ logger.info("Updated to bundle: \(next!.toString())")
4304
+ self.notifyBundleSet(next!)
4305
+ _ = self.implementation.setNextBundle(next: Optional<String>.none)
4306
+ } else {
4307
+ logger.error("Update to bundle: \(next!.toString()) Failed!")
4308
+ }
4309
+ }
4310
+ }
4311
+
4312
+ @objc private func toJson(object: Any) -> String {
4313
+ guard let data = try? JSONSerialization.data(withJSONObject: object, options: []) else {
4314
+ return ""
4315
+ }
4316
+ return String(data: data, encoding: String.Encoding.utf8) ?? ""
4317
+ }
4318
+
4319
+ @objc private func fromJsonArr(json: String) -> [NSObject] {
4320
+ guard let jsonData = json.data(using: .utf8) else {
4321
+ return []
4322
+ }
4323
+ let object = try? JSONSerialization.jsonObject(
4324
+ with: jsonData,
4325
+ options: .mutableContainers
4326
+ ) as? [NSObject]
4327
+ return object ?? []
4328
+ }
4329
+
4330
+ @objc func appMovedToForeground() {
4331
+ appHealthTracker?.markForeground(true)
4332
+ let current: BundleInfo = self.implementation.getCurrentBundle()
4333
+ self.implementation.sendStats(action: "app_moved_to_foreground", versionName: current.getVersionName())
4334
+ self.delayUpdateUtils.checkCancelDelay(source: .foreground)
4335
+ self.delayUpdateUtils.unsetBackgroundTimestamp()
4336
+ if backgroundWork != nil && taskRunning {
4337
+ backgroundWork!.cancel()
4338
+ logger.info("Background Timer Task canceled, Activity resumed before timer completes")
4339
+ }
4340
+ if self._isAutoUpdateEnabled() {
4341
+ // Check if download is already in progress (with timeout protection)
4342
+ if !isDownloadStuckOrTimedOut() {
4343
+ self.backgroundDownload()
4344
+ } else {
4345
+ logger.info("Download already in progress, skipping duplicate download request")
4346
+ }
4347
+ } else {
4348
+ logger.info("Auto update is disabled")
4349
+ self.sendReadyToJs(current: current, msg: "disabled")
4350
+ }
4351
+ self.checkAppReady()
4352
+ }
4353
+
4354
+ private var periodicUpdateTimer: Timer?
4355
+
4356
+ @objc func checkForUpdateAfterDelay() {
4357
+ if periodCheckDelay == 0 || !self._isAutoUpdateEnabled() {
4358
+ return
4359
+ }
4360
+ guard let url = URL(string: self.updateUrl) else {
4361
+ logger.error("Error no url or wrong format")
4362
+ return
4363
+ }
4364
+
4365
+ // Clean up any existing timer
4366
+ periodicUpdateTimer?.invalidate()
4367
+
4368
+ periodicUpdateTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(periodCheckDelay), repeats: true) { [weak self] timer in
4369
+ guard let self = self else {
4370
+ timer.invalidate()
4371
+ return
4372
+ }
4373
+ DispatchQueue.global(qos: .utility).async {
4374
+ if self.shouldBlockAutoUpdateForPreviewSession() {
4375
+ return
4376
+ }
4377
+ let res = self.implementation.getLatest(url: url, channel: nil)
4378
+ let current = self.implementation.getCurrentBundle()
4379
+ if self.shouldBlockAutoUpdateForPreviewSession() {
4380
+ return
4381
+ }
4382
+
4383
+ if res.version != current.getVersionName() {
4384
+ self.logger.info("New version found: \(res.version)")
4385
+ // Check if download is already in progress (with timeout protection)
4386
+ if !self.isDownloadStuckOrTimedOut() {
4387
+ self.backgroundDownload()
4388
+ } else {
4389
+ self.logger.info("Download already in progress, skipping duplicate download request")
4390
+ }
4391
+ }
4392
+ }
4393
+ }
4394
+ RunLoop.current.add(periodicUpdateTimer!, forMode: .default)
4395
+ }
4396
+
4397
+ @objc func appMovedToBackground() {
4398
+ // Reset timeout flag at start of each background cycle
4399
+ self.autoSplashscreenTimedOut = false
4400
+ appHealthTracker?.markForeground(false)
4401
+
4402
+ let current: BundleInfo = self.implementation.getCurrentBundle()
4403
+ self.implementation.sendStats(action: "app_moved_to_background", versionName: current.getVersionName())
4404
+ logger.info("Check for pending update")
4405
+
4406
+ // Show splashscreen only if autoSplashscreen is enabled AND autoUpdate is enabled AND directUpdate would be used
4407
+ if self.autoSplashscreen {
4408
+ var canShowSplashscreen = true
4409
+
4410
+ if !self._isAutoUpdateEnabled() {
4411
+ logger.warn("autoSplashscreen is enabled but autoUpdate is disabled. Splashscreen will not be shown. Enable autoUpdate or disable autoSplashscreen.")
4412
+ canShowSplashscreen = false
4413
+ }
4414
+
4415
+ if !self.shouldUseDirectUpdate() {
4416
+ if self.directUpdateMode == "false" {
4417
+ logger.warn("autoSplashscreen is enabled but directUpdate is not configured for immediate updates. Set directUpdate to 'always' or disable autoSplashscreen.")
4418
+ } else if self.directUpdateMode == "atInstall" || self.directUpdateMode == "onLaunch" {
4419
+ logger.info("autoSplashscreen is enabled but directUpdate is set to \"\(self.directUpdateMode)\". This is normal. Skipping autoSplashscreen logic.")
4420
+ }
4421
+ canShowSplashscreen = false
4422
+ }
4423
+
4424
+ if canShowSplashscreen {
4425
+ self.showSplashscreen()
4426
+ }
4427
+ }
4428
+
4429
+ // Set background timestamp
4430
+ let backgroundTimestamp = Int64(Date().timeIntervalSince1970 * 1000) // Convert to milliseconds
4431
+ self.delayUpdateUtils.setBackgroundTimestamp(backgroundTimestamp)
4432
+ self.delayUpdateUtils.checkCancelDelay(source: .background)
4433
+ self.installNext()
4434
+ }
4435
+
4436
+ func getNextBundle(_ call: CAPPluginCall) {
4437
+ let bundle = self.implementation.getNextBundle()
4438
+ if bundle == nil || bundle?.isUnknown() == true {
4439
+ call.resolve()
4440
+ return
4441
+ }
4442
+
4443
+ call.resolve(bundle!.toJSON())
4444
+ }
4445
+
4446
+ func getFailedUpdate(_ call: CAPPluginCall) {
4447
+ let bundle = self.readLastFailedBundle()
4448
+ if bundle == nil || bundle?.isUnknown() == true {
4449
+ call.resolve()
4450
+ return
4451
+ }
4452
+
4453
+ self.persistLastFailedBundle(nil)
4454
+ call.resolve([
4455
+ "bundle": bundle!.toJSON()
4456
+ ])
4457
+ }
4458
+
4459
+ func setShakeMenu(_ call: CAPPluginCall) {
4460
+ guard let enabled = call.getBool("enabled") else {
4461
+ logger.error("setShakeMenu called without enabled parameter")
4462
+ call.reject("setShakeMenu called without enabled parameter")
4463
+ return
4464
+ }
4465
+
4466
+ self.shakeMenuEnabled = enabled
4467
+ self.syncShakeMenuGestureRecognizer()
4468
+ logger.info("Shake menu \(enabled ? "enabled" : "disabled") with \(self.shakeMenuGesture) gesture")
4469
+ call.resolve()
4470
+ }
4471
+
4472
+ func isShakeMenuEnabled(_ call: CAPPluginCall) {
4473
+ call.resolve([
4474
+ "enabled": self.shakeMenuEnabled,
4475
+ "gesture": self.shakeMenuGesture
4476
+ ])
4477
+ }
4478
+
4479
+ func setShakeChannelSelector(_ call: CAPPluginCall) {
4480
+ guard let enabled = call.getBool("enabled") else {
4481
+ logger.error("setShakeChannelSelector called without enabled parameter")
4482
+ call.reject("setShakeChannelSelector called without enabled parameter")
4483
+ return
4484
+ }
4485
+
4486
+ self.shakeChannelSelectorEnabled = enabled
4487
+ self.syncShakeMenuGestureRecognizer()
4488
+ logger.info("Shake channel selector \(enabled ? "enabled" : "disabled")")
4489
+ call.resolve()
4490
+ }
4491
+
4492
+ func isShakeChannelSelectorEnabled(_ call: CAPPluginCall) {
4493
+ call.resolve([
4494
+ "enabled": self.shakeChannelSelectorEnabled
4495
+ ])
4496
+ }
4497
+
4498
+ func getAppId(_ call: CAPPluginCall) {
4499
+ call.resolve([
4500
+ "appId": implementation.appId
4501
+ ])
4502
+ }
4503
+
4504
+ func setAppId(_ call: CAPPluginCall) {
4505
+ if !readConfigBool("allowModifyAppId", false) {
4506
+ logger.error("setAppId called without allowModifyAppId")
4507
+ call.reject("setAppId called without allowModifyAppId set allowModifyAppId in your config to true to allow it")
4508
+ return
4509
+ }
4510
+ guard let appId = call.getString("appId") else {
4511
+ logger.error("setAppId called without appId")
4512
+ call.reject("setAppId called without appId")
4513
+ return
4514
+ }
4515
+ implementation.appId = appId
4516
+ call.resolve()
4517
+ }
4518
+
4519
+ // MARK: - App Store Update Methods
4520
+
4521
+ /// AppUpdateAvailability enum values matching TypeScript definitions
4522
+ private enum AppUpdateAvailability: Int {
4523
+ case unknown = 0
4524
+ case updateNotAvailable = 1
4525
+ case updateAvailable = 2
4526
+ case updateInProgress = 3
4527
+ }
4528
+
4529
+ func getAppUpdateInfo(_ call: CAPPluginCall) {
4530
+ let country = call.getString("country", "US")
4531
+ let bundleId = implementation.appId
4532
+
4533
+ logger.info("Getting App Store update info for \(bundleId) in country \(country)")
4534
+
4535
+ self.saveCallForAsyncHandling(call)
4536
+ DispatchQueue.global(qos: .background).async {
4537
+ let urlString = "https://itunes.apple.com/lookup?bundleId=\(bundleId)&country=\(country)"
4538
+ guard let url = URL(string: urlString) else {
4539
+ self.rejectCall(call, message: "Invalid URL for App Store lookup")
4540
+ return
4541
+ }
4542
+
4543
+ let task = URLSession.shared.dataTask(with: url) { data, _, error in
4544
+ if let error = error {
4545
+ self.logger.error("App Store lookup failed: \(error.localizedDescription)")
4546
+ self.rejectCall(call, message: "App Store lookup failed: \(error.localizedDescription)")
4547
+ return
4548
+ }
4549
+
4550
+ guard let data = data else {
4551
+ self.rejectCall(call, message: "No data received from App Store")
4552
+ return
4553
+ }
4554
+
4555
+ do {
4556
+ guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
4557
+ let resultCount = json["resultCount"] as? Int else {
4558
+ self.rejectCall(call, message: "Invalid response from App Store")
4559
+ return
4560
+ }
4561
+
4562
+ let currentVersionName = Bundle.main.versionName ?? "0.0.0"
4563
+ let currentVersionCode = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0"
4564
+
4565
+ var result: [String: Any] = [
4566
+ "currentVersionName": currentVersionName,
4567
+ "currentVersionCode": currentVersionCode,
4568
+ "updateAvailability": AppUpdateAvailability.unknown.rawValue
4569
+ ]
4570
+
4571
+ if resultCount > 0,
4572
+ let results = json["results"] as? [[String: Any]],
4573
+ let appInfo = results.first {
4574
+
4575
+ let availableVersion = appInfo["version"] as? String
4576
+ let releaseDate = appInfo["currentVersionReleaseDate"] as? String
4577
+ let minimumOsVersion = appInfo["minimumOsVersion"] as? String
4578
+
4579
+ result["availableVersionName"] = availableVersion
4580
+ result["availableVersionCode"] = availableVersion // iOS doesn't have separate version code
4581
+ result["availableVersionReleaseDate"] = releaseDate
4582
+ result["minimumOsVersion"] = minimumOsVersion
4583
+
4584
+ // Determine update availability by comparing versions
4585
+ if let availableVersion = availableVersion {
4586
+ do {
4587
+ let currentVer = try Version(currentVersionName)
4588
+ let availableVer = try Version(availableVersion)
4589
+ if availableVer > currentVer {
4590
+ result["updateAvailability"] = AppUpdateAvailability.updateAvailable.rawValue
4591
+ } else {
4592
+ result["updateAvailability"] = AppUpdateAvailability.updateNotAvailable.rawValue
4593
+ }
4594
+ } catch {
4595
+ // If version parsing fails, do string comparison
4596
+ if availableVersion != currentVersionName {
4597
+ result["updateAvailability"] = AppUpdateAvailability.updateAvailable.rawValue
4598
+ } else {
4599
+ result["updateAvailability"] = AppUpdateAvailability.updateNotAvailable.rawValue
4600
+ }
4601
+ }
4602
+ } else {
4603
+ result["updateAvailability"] = AppUpdateAvailability.updateNotAvailable.rawValue
4604
+ }
4605
+
4606
+ // iOS doesn't support in-app updates like Android
4607
+ result["immediateUpdateAllowed"] = false
4608
+ result["flexibleUpdateAllowed"] = false
4609
+ } else {
4610
+ // App not found in App Store (maybe not published yet)
4611
+ result["updateAvailability"] = AppUpdateAvailability.updateNotAvailable.rawValue
4612
+ self.logger.info("App not found in App Store for bundleId: \(bundleId)")
4613
+ }
4614
+
4615
+ self.resolveCall(call, data: result)
4616
+ } catch {
4617
+ self.logger.error("Failed to parse App Store response: \(error.localizedDescription)")
4618
+ self.rejectCall(call, message: "Failed to parse App Store response: \(error.localizedDescription)")
4619
+ }
4620
+ }
4621
+ task.resume()
4622
+ }
4623
+ }
4624
+
4625
+ func openAppStore(_ call: CAPPluginCall) {
4626
+ let appId = call.getString("appId")
4627
+ let bundleId = implementation.appId
4628
+ self.saveCallForAsyncHandling(call)
4629
+
4630
+ func openAppStorePage(urlString: String, invalidMessage: String = "Invalid App Store URL", failureMessage: String = "Failed to open App Store") {
4631
+ guard let url = URL(string: urlString) else {
4632
+ self.rejectCall(call, message: invalidMessage)
4633
+ return
4634
+ }
4635
+ DispatchQueue.main.async {
4636
+ UIApplication.shared.open(url) { success in
4637
+ if success {
4638
+ self.resolveCall(call)
4639
+ } else {
4640
+ self.rejectCall(call, message: failureMessage)
4641
+ }
4642
+ }
4643
+ }
4644
+ }
4645
+
4646
+ func openFallbackAppStorePage() {
4647
+ guard let encodedBundleId = bundleId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
4648
+ self.rejectCall(call, message: "Failed to build App Store fallback URL")
4649
+ return
4650
+ }
4651
+ openAppStorePage(urlString: "https://apps.apple.com/app/\(encodedBundleId)")
4652
+ }
4653
+
4654
+ if let appId = appId {
4655
+ openAppStorePage(urlString: "https://apps.apple.com/app/id\(appId)")
4656
+ } else {
4657
+ let lookupUrl = "https://itunes.apple.com/lookup?bundleId=\(bundleId)"
4658
+
4659
+ DispatchQueue.global(qos: .background).async {
4660
+ guard let url = URL(string: lookupUrl) else {
4661
+ openFallbackAppStorePage()
4662
+ return
4663
+ }
4664
+
4665
+ let task = URLSession.shared.dataTask(with: url) { data, _, error in
4666
+ if let error = error {
4667
+ self.logger.error("App Store lookup failed: \(error.localizedDescription)")
4668
+ openFallbackAppStorePage()
4669
+ return
4670
+ }
4671
+
4672
+ guard let data = data,
4673
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
4674
+ let results = json["results"] as? [[String: Any]],
4675
+ let appInfo = results.first,
4676
+ let trackId = appInfo["trackId"] as? Int else {
4677
+ openFallbackAppStorePage()
4678
+ return
4679
+ }
4680
+
4681
+ openAppStorePage(urlString: "https://apps.apple.com/app/id\(trackId)")
4682
+ }
4683
+ task.resume()
4684
+ }
4685
+ }
4686
+ }
4687
+
4688
+ func performImmediateUpdate(_ call: CAPPluginCall) {
4689
+ // iOS doesn't support in-app updates like Android's Play Store
4690
+ // Redirect users to the App Store instead
4691
+ logger.warn("performImmediateUpdate is not supported on iOS. Use openAppStore() instead.")
4692
+ call.reject("In-app updates are not supported on iOS. Use openAppStore() to direct users to the App Store.", "NOT_SUPPORTED")
4693
+ }
4694
+
4695
+ func startFlexibleUpdate(_ call: CAPPluginCall) {
4696
+ // iOS doesn't support flexible in-app updates
4697
+ logger.warn("startFlexibleUpdate is not supported on iOS. Use openAppStore() instead.")
4698
+ call.reject("Flexible updates are not supported on iOS. Use openAppStore() to direct users to the App Store.", "NOT_SUPPORTED")
4699
+ }
4700
+
4701
+ func completeFlexibleUpdate(_ call: CAPPluginCall) {
4702
+ // iOS doesn't support flexible in-app updates
4703
+ logger.warn("completeFlexibleUpdate is not supported on iOS.")
4704
+ call.reject("Flexible updates are not supported on iOS.", "NOT_SUPPORTED")
4705
+ }
4706
+ }