@capgo/capacitor-updater 6.14.25 → 6.14.29

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 (54) hide show
  1. package/CapgoCapacitorUpdater.podspec +3 -2
  2. package/Package.swift +2 -2
  3. package/README.md +341 -74
  4. package/android/build.gradle +20 -8
  5. package/android/proguard-rules.pro +22 -5
  6. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +52 -16
  7. package/android/src/main/java/ee/forgr/capacitor_updater/Callback.java +2 -2
  8. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +1196 -514
  9. package/android/src/main/java/ee/forgr/capacitor_updater/{CapacitorUpdater.java → CapgoUpdater.java} +522 -154
  10. package/android/src/main/java/ee/forgr/capacitor_updater/{CryptoCipher.java → CryptoCipherV1.java} +17 -9
  11. package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipherV2.java +15 -26
  12. package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +0 -3
  13. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +260 -0
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +300 -119
  15. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +63 -25
  16. package/android/src/main/java/ee/forgr/capacitor_updater/Logger.java +338 -0
  17. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +72 -0
  18. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +169 -0
  19. package/dist/docs.json +652 -63
  20. package/dist/esm/definitions.d.ts +265 -15
  21. package/dist/esm/definitions.js.map +1 -1
  22. package/dist/esm/history.d.ts +1 -0
  23. package/dist/esm/history.js +283 -0
  24. package/dist/esm/history.js.map +1 -0
  25. package/dist/esm/index.d.ts +1 -0
  26. package/dist/esm/index.js +1 -0
  27. package/dist/esm/index.js.map +1 -1
  28. package/dist/esm/web.d.ts +12 -1
  29. package/dist/esm/web.js +29 -2
  30. package/dist/esm/web.js.map +1 -1
  31. package/dist/plugin.cjs.js +311 -2
  32. package/dist/plugin.cjs.js.map +1 -1
  33. package/dist/plugin.js +311 -2
  34. package/dist/plugin.js.map +1 -1
  35. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/AES.swift +6 -3
  36. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1575 -0
  37. package/ios/{Plugin/CapacitorUpdater.swift → Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift} +365 -139
  38. package/ios/{Plugin/CryptoCipher.swift → Sources/CapacitorUpdaterPlugin/CryptoCipherV1.swift} +13 -6
  39. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/CryptoCipherV2.swift +33 -27
  40. package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +220 -0
  41. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/InternalUtils.swift +47 -0
  42. package/ios/Sources/CapacitorUpdaterPlugin/Logger.swift +310 -0
  43. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/RSA.swift +1 -0
  44. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +112 -0
  45. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/UserDefaultsExtension.swift +0 -2
  46. package/package.json +20 -16
  47. package/ios/Plugin/CapacitorUpdaterPlugin.swift +0 -1031
  48. /package/{LICENCE → LICENSE} +0 -0
  49. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BigInt.swift +0 -0
  50. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleInfo.swift +0 -0
  51. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleStatus.swift +0 -0
  52. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayCondition.swift +0 -0
  53. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayUntilNext.swift +0 -0
  54. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/Info.plist +0 -0
@@ -0,0 +1,1575 @@
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
+ import Capacitor
9
+ import UIKit
10
+ import WebKit
11
+ import Version
12
+
13
+ /**
14
+ * Please read the Capacitor iOS Plugin Development Guide
15
+ * here: https://capacitorjs.com/docs/plugins/ios
16
+ */
17
+ @objc(CapacitorUpdaterPlugin)
18
+ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
19
+ let logger = Logger(withTag: "✨ CapgoUpdater")
20
+
21
+ public let identifier = "CapacitorUpdaterPlugin"
22
+ public let jsName = "CapacitorUpdater"
23
+ public let pluginMethods: [CAPPluginMethod] = [
24
+ CAPPluginMethod(name: "download", returnType: CAPPluginReturnPromise),
25
+ CAPPluginMethod(name: "setUpdateUrl", returnType: CAPPluginReturnPromise),
26
+ CAPPluginMethod(name: "setStatsUrl", returnType: CAPPluginReturnPromise),
27
+ CAPPluginMethod(name: "setChannelUrl", returnType: CAPPluginReturnPromise),
28
+ CAPPluginMethod(name: "set", returnType: CAPPluginReturnPromise),
29
+ CAPPluginMethod(name: "list", returnType: CAPPluginReturnPromise),
30
+ CAPPluginMethod(name: "delete", returnType: CAPPluginReturnPromise),
31
+ CAPPluginMethod(name: "setBundleError", returnType: CAPPluginReturnPromise),
32
+ CAPPluginMethod(name: "reset", returnType: CAPPluginReturnPromise),
33
+ CAPPluginMethod(name: "current", returnType: CAPPluginReturnPromise),
34
+ CAPPluginMethod(name: "reload", returnType: CAPPluginReturnPromise),
35
+ CAPPluginMethod(name: "notifyAppReady", returnType: CAPPluginReturnPromise),
36
+ CAPPluginMethod(name: "setDelay", returnType: CAPPluginReturnPromise),
37
+ CAPPluginMethod(name: "setMultiDelay", returnType: CAPPluginReturnPromise),
38
+ CAPPluginMethod(name: "cancelDelay", returnType: CAPPluginReturnPromise),
39
+ CAPPluginMethod(name: "getLatest", returnType: CAPPluginReturnPromise),
40
+ CAPPluginMethod(name: "setChannel", returnType: CAPPluginReturnPromise),
41
+ CAPPluginMethod(name: "unsetChannel", returnType: CAPPluginReturnPromise),
42
+ CAPPluginMethod(name: "getChannel", returnType: CAPPluginReturnPromise),
43
+ CAPPluginMethod(name: "listChannels", returnType: CAPPluginReturnPromise),
44
+ CAPPluginMethod(name: "setCustomId", returnType: CAPPluginReturnPromise),
45
+ CAPPluginMethod(name: "getDeviceId", returnType: CAPPluginReturnPromise),
46
+ CAPPluginMethod(name: "getPluginVersion", returnType: CAPPluginReturnPromise),
47
+ CAPPluginMethod(name: "next", returnType: CAPPluginReturnPromise),
48
+ CAPPluginMethod(name: "isAutoUpdateEnabled", returnType: CAPPluginReturnPromise),
49
+ CAPPluginMethod(name: "getBuiltinVersion", returnType: CAPPluginReturnPromise),
50
+ CAPPluginMethod(name: "isAutoUpdateAvailable", returnType: CAPPluginReturnPromise),
51
+ CAPPluginMethod(name: "getNextBundle", returnType: CAPPluginReturnPromise),
52
+ CAPPluginMethod(name: "getFailedUpdate", returnType: CAPPluginReturnPromise),
53
+ CAPPluginMethod(name: "setShakeMenu", returnType: CAPPluginReturnPromise),
54
+ CAPPluginMethod(name: "isShakeMenuEnabled", returnType: CAPPluginReturnPromise)
55
+ ]
56
+ public var implementation = CapgoUpdater()
57
+ private let PLUGIN_VERSION: String = "6.14.29"
58
+ static let updateUrlDefault = "https://plugin.capgo.app/updates"
59
+ static let statsUrlDefault = "https://plugin.capgo.app/stats"
60
+ static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
61
+ private let keepUrlPathFlagKey = "__capgo_keep_url_path_after_reload"
62
+ private let customIdDefaultsKey = "CapacitorUpdater.customId"
63
+ private let updateUrlDefaultsKey = "CapacitorUpdater.updateUrl"
64
+ private let statsUrlDefaultsKey = "CapacitorUpdater.statsUrl"
65
+ private let channelUrlDefaultsKey = "CapacitorUpdater.channelUrl"
66
+ private let lastFailedBundleDefaultsKey = "CapacitorUpdater.lastFailedBundle"
67
+ // Note: DELAY_CONDITION_PREFERENCES is now defined in DelayUpdateUtils.DELAY_CONDITION_PREFERENCES
68
+ private var updateUrl = ""
69
+ private var backgroundTaskID: UIBackgroundTaskIdentifier = UIBackgroundTaskIdentifier.invalid
70
+ private var currentVersionNative: Version = "0.0.0"
71
+ private var currentBuildVersion: String = "0"
72
+ private var autoUpdate = false
73
+ private var appReadyTimeout = 10000
74
+ private var appReadyCheck: DispatchWorkItem?
75
+ private var resetWhenUpdate = true
76
+ private var directUpdate = false
77
+ private var directUpdateMode: String = "false"
78
+ private var wasRecentlyInstalledOrUpdated = false
79
+ private var autoSplashscreen = false
80
+ private var autoSplashscreenLoader = false
81
+ private var autoSplashscreenTimeout = 10000
82
+ private var autoSplashscreenTimeoutWorkItem: DispatchWorkItem?
83
+ private var splashscreenLoaderView: UIActivityIndicatorView?
84
+ private var splashscreenLoaderContainer: UIView?
85
+ private var autoSplashscreenTimedOut = false
86
+ private var autoDeleteFailed = false
87
+ private var autoDeletePrevious = false
88
+ private var keepUrlPathAfterReload = false
89
+ private var backgroundWork: DispatchWorkItem?
90
+ private var taskRunning = false
91
+ private var periodCheckDelay = 0
92
+ private var persistCustomId = false
93
+ private var persistModifyUrl = false
94
+ private var allowManualBundleError = false
95
+ private var keepUrlPathFlagLastValue: Bool?
96
+ public var shakeMenuEnabled = false
97
+ let semaphoreReady = DispatchSemaphore(value: 0)
98
+
99
+ private var delayUpdateUtils: DelayUpdateUtils!
100
+
101
+ override public func load() {
102
+ let disableJSLogging = getConfig().getBoolean("disableJSLogging", false)
103
+ // Set webView for logging to JavaScript console
104
+ if let webView = self.bridge?.webView, !disableJSLogging {
105
+ logger.setWebView(webView: webView)
106
+ logger.info("WebView set successfully for logging")
107
+ } else {
108
+ logger.error("Failed to get webView for logging")
109
+ }
110
+ #if targetEnvironment(simulator)
111
+ logger.info("::::: SIMULATOR :::::")
112
+ logger.info("Application directory: \(NSHomeDirectory())")
113
+ #endif
114
+
115
+ self.semaphoreUp()
116
+ self.implementation.deviceID = (UserDefaults.standard.string(forKey: "appUUID") ?? UUID().uuidString).lowercased()
117
+ UserDefaults.standard.set( self.implementation.deviceID, forKey: "appUUID")
118
+ UserDefaults.standard.synchronize()
119
+ persistCustomId = getConfig().getBoolean("persistCustomId", false)
120
+ if persistCustomId {
121
+ let storedCustomId = UserDefaults.standard.string(forKey: customIdDefaultsKey) ?? ""
122
+ if !storedCustomId.isEmpty {
123
+ implementation.customId = storedCustomId
124
+ logger.info("Loaded persisted customId")
125
+ }
126
+ }
127
+ persistModifyUrl = getConfig().getBoolean("persistModifyUrl", false)
128
+ allowManualBundleError = getConfig().getBoolean("allowManualBundleError", false)
129
+ logger.info("init for device \(self.implementation.deviceID)")
130
+ guard let versionName = getConfig().getString("version", Bundle.main.versionName) else {
131
+ logger.error("Cannot get version name")
132
+ // crash the app on purpose
133
+ fatalError("Cannot get version name")
134
+ }
135
+ do {
136
+ currentVersionNative = try Version(versionName)
137
+ } catch {
138
+ logger.error("Cannot parse versionName \(versionName)")
139
+ }
140
+ currentBuildVersion = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0"
141
+ logger.info("version native \(self.currentVersionNative.description)")
142
+ implementation.versionBuild = getConfig().getString("version", Bundle.main.versionName)!
143
+ autoDeleteFailed = getConfig().getBoolean("autoDeleteFailed", true)
144
+ autoDeletePrevious = getConfig().getBoolean("autoDeletePrevious", true)
145
+ keepUrlPathAfterReload = getConfig().getBoolean("keepUrlPathAfterReload", false)
146
+ syncKeepUrlPathFlag(enabled: keepUrlPathAfterReload)
147
+
148
+ // Handle directUpdate configuration - support string values and backward compatibility
149
+ if let directUpdateString = getConfig().getString("directUpdate") {
150
+ directUpdateMode = directUpdateString
151
+ directUpdate = directUpdateString == "always" || directUpdateString == "atInstall"
152
+ } else {
153
+ let directUpdateBool = getConfig().getBoolean("directUpdate", false)
154
+ if directUpdateBool {
155
+ directUpdateMode = "always" // backward compatibility: true = always
156
+ directUpdate = true
157
+ } else {
158
+ directUpdateMode = "false"
159
+ directUpdate = false
160
+ }
161
+ }
162
+
163
+ autoSplashscreen = getConfig().getBoolean("autoSplashscreen", false)
164
+ autoSplashscreenLoader = getConfig().getBoolean("autoSplashscreenLoader", false)
165
+ let splashscreenTimeoutValue = getConfig().getInt("autoSplashscreenTimeout", 10000)
166
+ autoSplashscreenTimeout = max(0, splashscreenTimeoutValue)
167
+ updateUrl = getConfig().getString("updateUrl", CapacitorUpdaterPlugin.updateUrlDefault)!
168
+ if persistModifyUrl, let storedUpdateUrl = UserDefaults.standard.object(forKey: updateUrlDefaultsKey) as? String {
169
+ updateUrl = storedUpdateUrl
170
+ logger.info("Loaded persisted updateUrl")
171
+ }
172
+ autoUpdate = getConfig().getBoolean("autoUpdate", true)
173
+ appReadyTimeout = getConfig().getInt("appReadyTimeout", 10000)
174
+ implementation.timeout = Double(getConfig().getInt("responseTimeout", 20))
175
+ resetWhenUpdate = getConfig().getBoolean("resetWhenUpdate", true)
176
+ shakeMenuEnabled = getConfig().getBoolean("shakeMenu", false)
177
+ let periodCheckDelayValue = getConfig().getInt("periodCheckDelay", 0)
178
+ if periodCheckDelayValue >= 0 && periodCheckDelayValue > 600 {
179
+ periodCheckDelay = 600
180
+ } else {
181
+ periodCheckDelay = periodCheckDelayValue
182
+ }
183
+
184
+ implementation.privateKey = getConfig().getString("privateKey", "")!
185
+ implementation.publicKey = getConfig().getString("publicKey", "")!
186
+ if !implementation.privateKey.isEmpty {
187
+ implementation.hasOldPrivateKeyPropertyInConfig = true
188
+ }
189
+ implementation.notifyDownloadRaw = notifyDownload
190
+ implementation.PLUGIN_VERSION = self.PLUGIN_VERSION
191
+
192
+ // Set logger for shared classes
193
+ implementation.setLogger(logger)
194
+ CryptoCipherV2.setLogger(logger)
195
+ CryptoCipherV1.setLogger(logger)
196
+ CryptoCipherV2.setLogger(logger)
197
+
198
+ // Initialize DelayUpdateUtils
199
+ self.delayUpdateUtils = DelayUpdateUtils(currentVersionNative: currentVersionNative, logger: logger)
200
+ let config = (self.bridge?.viewController as? CAPBridgeViewController)?.instanceDescriptor().legacyConfig
201
+ implementation.appId = Bundle.main.infoDictionary?["CFBundleIdentifier"] as? String ?? ""
202
+ implementation.appId = config?["appId"] as? String ?? implementation.appId
203
+ implementation.appId = getConfig().getString("appId", implementation.appId)!
204
+ if implementation.appId == "" {
205
+ // crash the app on purpose it should not happen
206
+ fatalError("appId is missing in capacitor.config.json or plugin config, and cannot be retrieved from the native app, please add it globally or in the plugin config")
207
+ }
208
+ logger.info("appId \(implementation.appId)")
209
+ implementation.statsUrl = getConfig().getString("statsUrl", CapacitorUpdaterPlugin.statsUrlDefault)!
210
+ implementation.channelUrl = getConfig().getString("channelUrl", CapacitorUpdaterPlugin.channelUrlDefault)!
211
+ if persistModifyUrl {
212
+ if let storedStatsUrl = UserDefaults.standard.object(forKey: statsUrlDefaultsKey) as? String {
213
+ implementation.statsUrl = storedStatsUrl
214
+ logger.info("Loaded persisted statsUrl")
215
+ }
216
+ if let storedChannelUrl = UserDefaults.standard.object(forKey: channelUrlDefaultsKey) as? String {
217
+ implementation.channelUrl = storedChannelUrl
218
+ logger.info("Loaded persisted channelUrl")
219
+ }
220
+ }
221
+ implementation.defaultChannel = getConfig().getString("defaultChannel", "")!
222
+ self.implementation.autoReset()
223
+
224
+ // Check if app was recently installed/updated BEFORE cleanupObsoleteVersions updates LatestVersionNative
225
+ self.wasRecentlyInstalledOrUpdated = self.checkIfRecentlyInstalledOrUpdated()
226
+
227
+ if resetWhenUpdate {
228
+ self.cleanupObsoleteVersions()
229
+ }
230
+
231
+ // Load the server
232
+ // This is very much swift specific, android does not do that
233
+ // In android we depend on the serverBasePath capacitor property
234
+ // In IOS we do not. Instead during the plugin initialization we try to call setServerBasePath
235
+ // The idea is to prevent having to store the bundle in 2 locations for hot reload and persisten storage
236
+ // According to martin it is not possible to use serverBasePath on ios in a way that allows us to store the bundle once
237
+
238
+ if !self.initialLoad() {
239
+ logger.error("unable to force reload, the plugin might fallback to the builtin version")
240
+ }
241
+
242
+ let nc = NotificationCenter.default
243
+ nc.addObserver(self, selector: #selector(appMovedToBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
244
+ nc.addObserver(self, selector: #selector(appMovedToForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
245
+
246
+ // Check for 'kill' delay condition on app launch
247
+ // This handles cases where the app was killed (willTerminateNotification is not reliable for system kills)
248
+ self.delayUpdateUtils.checkCancelDelay(source: .killed)
249
+
250
+ self.appMovedToForeground()
251
+ self.checkForUpdateAfterDelay()
252
+ }
253
+
254
+ private func syncKeepUrlPathFlag(enabled: Bool) {
255
+ let script: String
256
+ if enabled {
257
+ 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); })();"
258
+ } else {
259
+ 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); })();"
260
+ }
261
+ DispatchQueue.main.async { [weak self] in
262
+ guard let self = self, let webView = self.bridge?.webView else {
263
+ return
264
+ }
265
+ if self.keepUrlPathFlagLastValue != enabled {
266
+ let userScript = WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true)
267
+ webView.configuration.userContentController.addUserScript(userScript)
268
+ self.keepUrlPathFlagLastValue = enabled
269
+ }
270
+ webView.evaluateJavaScript(script, completionHandler: nil)
271
+ }
272
+ }
273
+
274
+ private func persistLastFailedBundle(_ bundle: BundleInfo?) {
275
+ if let bundle = bundle {
276
+ do {
277
+ try UserDefaults.standard.setObj(bundle, forKey: lastFailedBundleDefaultsKey)
278
+ } catch {
279
+ logger.error("Failed to persist failed bundle info \(error.localizedDescription)")
280
+ }
281
+ } else {
282
+ UserDefaults.standard.removeObject(forKey: lastFailedBundleDefaultsKey)
283
+ }
284
+ UserDefaults.standard.synchronize()
285
+ }
286
+
287
+ private func readLastFailedBundle() -> BundleInfo? {
288
+ do {
289
+ let bundle: BundleInfo = try UserDefaults.standard.getObj(forKey: lastFailedBundleDefaultsKey, castTo: BundleInfo.self)
290
+ return bundle
291
+ } catch ObjectSavableError.noValue {
292
+ return nil
293
+ } catch {
294
+ logger.error("Failed to read failed bundle info \(error.localizedDescription)")
295
+ UserDefaults.standard.removeObject(forKey: lastFailedBundleDefaultsKey)
296
+ UserDefaults.standard.synchronize()
297
+ return nil
298
+ }
299
+ }
300
+
301
+ private func initialLoad() -> Bool {
302
+ guard let bridge = self.bridge else { return false }
303
+ if keepUrlPathAfterReload {
304
+ syncKeepUrlPathFlag(enabled: true)
305
+ }
306
+
307
+ let id = self.implementation.getCurrentBundleId()
308
+ var dest: URL
309
+ if BundleInfo.ID_BUILTIN == id {
310
+ dest = Bundle.main.resourceURL!.appendingPathComponent("public")
311
+ } else {
312
+ dest = self.implementation.getBundleDirectory(id: id)
313
+ }
314
+
315
+ if !FileManager.default.fileExists(atPath: dest.path) {
316
+ logger.error("Initial load fail - file at path \(dest.path) doesn't exist. Defaulting to buildin!! \(id)")
317
+ dest = Bundle.main.resourceURL!.appendingPathComponent("public")
318
+ }
319
+
320
+ logger.info("Initial load \(id)")
321
+ // We don't use the viewcontroller here as it does not work during the initial load state
322
+ bridge.setServerBasePath(dest.path)
323
+ return true
324
+ }
325
+
326
+ private func semaphoreWait(waitTime: Int) {
327
+ // print("\\(CapgoUpdater.TAG) semaphoreWait \\(waitTime)")
328
+ let result = semaphoreReady.wait(timeout: .now() + .milliseconds(waitTime))
329
+ if result == .timedOut {
330
+ logger.error("Semaphore wait timed out after \(waitTime)ms")
331
+ }
332
+ }
333
+
334
+ private func semaphoreUp() {
335
+ DispatchQueue.global().async {
336
+ self.semaphoreWait(waitTime: 0)
337
+ }
338
+ }
339
+
340
+ private func semaphoreDown() {
341
+ semaphoreReady.signal()
342
+ }
343
+
344
+ private func cleanupObsoleteVersions() {
345
+ let previous = UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? "0"
346
+ if previous != "0" && self.currentBuildVersion != previous {
347
+ _ = self._reset(toLastSuccessful: false)
348
+ let res = implementation.list()
349
+ res.forEach { version in
350
+ logger.info("Deleting obsolete bundle: \(version.getId())")
351
+ let res = implementation.delete(id: version.getId())
352
+ if !res {
353
+ logger.error("Delete failed, id \(version.getId()) doesn't exist")
354
+ }
355
+ }
356
+
357
+ let storedBundles = implementation.list(raw: true)
358
+ let allowedIds = Set(storedBundles.compactMap { info -> String? in
359
+ let id = info.getId()
360
+ return id.isEmpty ? nil : id
361
+ })
362
+ implementation.cleanupDownloadDirectories(allowedIds: allowedIds)
363
+ }
364
+ UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
365
+ UserDefaults.standard.synchronize()
366
+ }
367
+
368
+ @objc func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false) {
369
+ let bundle = self.implementation.getBundleInfo(id: id)
370
+ self.notifyListeners("download", data: ["percent": percent, "bundle": bundle.toJSON()])
371
+ if percent == 100 {
372
+ self.notifyListeners("downloadComplete", data: ["bundle": bundle.toJSON()])
373
+ self.implementation.sendStats(action: "download_complete", versionName: bundle.getVersionName())
374
+ } else if percent.isMultiple(of: 10) || ignoreMultipleOfTen {
375
+ self.implementation.sendStats(action: "download_\(percent)", versionName: bundle.getVersionName())
376
+ }
377
+ }
378
+
379
+ @objc func setUpdateUrl(_ call: CAPPluginCall) {
380
+ if !getConfig().getBoolean("allowModifyUrl", false) {
381
+ logger.error("setUpdateUrl called without allowModifyUrl")
382
+ call.reject("setUpdateUrl called without allowModifyUrl set allowModifyUrl in your config to true to allow it")
383
+ return
384
+ }
385
+ guard let url = call.getString("url") else {
386
+ logger.error("setUpdateUrl called without url")
387
+ call.reject("setUpdateUrl called without url")
388
+ return
389
+ }
390
+ self.updateUrl = url
391
+ if persistModifyUrl {
392
+ UserDefaults.standard.set(url, forKey: updateUrlDefaultsKey)
393
+ UserDefaults.standard.synchronize()
394
+ }
395
+ call.resolve()
396
+ }
397
+
398
+ @objc func setStatsUrl(_ call: CAPPluginCall) {
399
+ if !getConfig().getBoolean("allowModifyUrl", false) {
400
+ logger.error("setStatsUrl called without allowModifyUrl")
401
+ call.reject("setStatsUrl called without allowModifyUrl set allowModifyUrl in your config to true to allow it")
402
+ return
403
+ }
404
+ guard let url = call.getString("url") else {
405
+ logger.error("setStatsUrl called without url")
406
+ call.reject("setStatsUrl called without url")
407
+ return
408
+ }
409
+ self.implementation.statsUrl = url
410
+ if persistModifyUrl {
411
+ UserDefaults.standard.set(url, forKey: statsUrlDefaultsKey)
412
+ UserDefaults.standard.synchronize()
413
+ }
414
+ call.resolve()
415
+ }
416
+
417
+ @objc func setChannelUrl(_ call: CAPPluginCall) {
418
+ if !getConfig().getBoolean("allowModifyUrl", false) {
419
+ logger.error("setChannelUrl called without allowModifyUrl")
420
+ call.reject("setChannelUrl called without allowModifyUrl set allowModifyUrl in your config to true to allow it")
421
+ return
422
+ }
423
+ guard let url = call.getString("url") else {
424
+ logger.error("setChannelUrl called without url")
425
+ call.reject("setChannelUrl called without url")
426
+ return
427
+ }
428
+ self.implementation.channelUrl = url
429
+ if persistModifyUrl {
430
+ UserDefaults.standard.set(url, forKey: channelUrlDefaultsKey)
431
+ UserDefaults.standard.synchronize()
432
+ }
433
+ call.resolve()
434
+ }
435
+
436
+ @objc func getBuiltinVersion(_ call: CAPPluginCall) {
437
+ call.resolve(["version": implementation.versionBuild])
438
+ }
439
+
440
+ @objc func getDeviceId(_ call: CAPPluginCall) {
441
+ call.resolve(["deviceId": implementation.deviceID])
442
+ }
443
+
444
+ @objc func getPluginVersion(_ call: CAPPluginCall) {
445
+ call.resolve(["version": self.PLUGIN_VERSION])
446
+ }
447
+
448
+ @objc func download(_ call: CAPPluginCall) {
449
+ guard let urlString = call.getString("url") else {
450
+ logger.error("Download called without url")
451
+ call.reject("Download called without url")
452
+ return
453
+ }
454
+ guard let version = call.getString("version") else {
455
+ logger.error("Download called without version")
456
+ call.reject("Download called without version")
457
+ return
458
+ }
459
+
460
+ let sessionKey = call.getString("sessionKey", "")
461
+ var checksum = call.getString("checksum", "")
462
+ let manifestArray = call.getArray("manifest")
463
+ let url = URL(string: urlString)
464
+ logger.info("Downloading \(String(describing: url))")
465
+ DispatchQueue.global(qos: .background).async {
466
+ do {
467
+ let next: BundleInfo
468
+ if let manifestArray = manifestArray {
469
+ // Convert JSArray to [ManifestEntry]
470
+ var manifestEntries: [ManifestEntry] = []
471
+ for item in manifestArray {
472
+ if let manifestDict = item as? [String: Any] {
473
+ let entry = ManifestEntry(
474
+ file_name: manifestDict["file_name"] as? String,
475
+ file_hash: manifestDict["file_hash"] as? String,
476
+ download_url: manifestDict["download_url"] as? String
477
+ )
478
+ manifestEntries.append(entry)
479
+ }
480
+ }
481
+ next = try self.implementation.downloadManifest(manifest: manifestEntries, version: version, sessionKey: sessionKey)
482
+ } else {
483
+ next = try self.implementation.download(url: url!, version: version, sessionKey: sessionKey)
484
+ }
485
+ // If public key is present but no checksum provided, refuse installation
486
+ if self.implementation.publicKey != "" && checksum == "" {
487
+ self.logger.error("Public key present but no checksum provided")
488
+ self.implementation.sendStats(action: "checksum_required", versionName: next.getVersionName())
489
+ let id = next.getId()
490
+ let resDel = self.implementation.delete(id: id)
491
+ if !resDel {
492
+ self.logger.error("Delete failed, id \(id) doesn't exist")
493
+ }
494
+ throw ObjectSavableError.checksum
495
+ }
496
+
497
+ checksum = try CryptoCipherV2.decryptChecksum(checksum: checksum, publicKey: self.implementation.publicKey)
498
+ if (checksum != "" || self.implementation.publicKey != "") && next.getChecksum() != checksum {
499
+ self.logger.error("Error checksum \(next.getChecksum()) \(checksum)")
500
+ self.implementation.sendStats(action: "checksum_fail", versionName: next.getVersionName())
501
+ let id = next.getId()
502
+ let resDel = self.implementation.delete(id: id)
503
+ if !resDel {
504
+ self.logger.error("Delete failed, id \(id) doesn't exist")
505
+ }
506
+ throw ObjectSavableError.checksum
507
+ } else {
508
+ self.logger.info("Good checksum \(next.getChecksum()) \(checksum)")
509
+ }
510
+ self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
511
+ call.resolve(next.toJSON())
512
+ } catch {
513
+ self.logger.error("Failed to download from: \(String(describing: url)) \(error.localizedDescription)")
514
+ self.notifyListeners("downloadFailed", data: ["version": version])
515
+ self.implementation.sendStats(action: "download_fail")
516
+ call.reject("Failed to download from: \(url!)", error.localizedDescription)
517
+ }
518
+ }
519
+ }
520
+
521
+ public func _reload() -> Bool {
522
+ guard let bridge = self.bridge else { return false }
523
+ self.semaphoreUp()
524
+ let id = self.implementation.getCurrentBundleId()
525
+ let dest: URL
526
+ if BundleInfo.ID_BUILTIN == id {
527
+ dest = Bundle.main.resourceURL!.appendingPathComponent("public")
528
+ } else {
529
+ dest = self.implementation.getBundleDirectory(id: id)
530
+ }
531
+ logger.info("Reloading \(id)")
532
+
533
+ let performReload: () -> Bool = {
534
+ guard let vc = bridge.viewController as? CAPBridgeViewController else {
535
+ self.logger.error("Cannot get viewController")
536
+ return false
537
+ }
538
+ guard let capBridge = vc.bridge else {
539
+ self.logger.error("Cannot get capBridge")
540
+ return false
541
+ }
542
+ if self.keepUrlPathAfterReload {
543
+ if let currentURL = vc.webView?.url {
544
+ capBridge.setServerBasePath(dest.path)
545
+ var urlComponents = URLComponents(url: capBridge.config.serverURL, resolvingAgainstBaseURL: false)!
546
+ urlComponents.path = currentURL.path
547
+ urlComponents.query = currentURL.query
548
+ urlComponents.fragment = currentURL.fragment
549
+ if let finalUrl = urlComponents.url {
550
+ _ = vc.webView?.load(URLRequest(url: finalUrl))
551
+ } else {
552
+ self.logger.error("Unable to build final URL when keeping path after reload; falling back to base path")
553
+ vc.setServerBasePath(path: dest.path)
554
+ }
555
+ } else {
556
+ self.logger.error("vc.webView?.url is null? Falling back to base path reload.")
557
+ vc.setServerBasePath(path: dest.path)
558
+ }
559
+ } else {
560
+ vc.setServerBasePath(path: dest.path)
561
+ }
562
+ self.checkAppReady()
563
+ self.notifyListeners("appReloaded", data: [:])
564
+ return true
565
+ }
566
+
567
+ if Thread.isMainThread {
568
+ return performReload()
569
+ } else {
570
+ var result = false
571
+ DispatchQueue.main.sync {
572
+ result = performReload()
573
+ }
574
+ return result
575
+ }
576
+ }
577
+
578
+ @objc func reload(_ call: CAPPluginCall) {
579
+ if self._reload() {
580
+ call.resolve()
581
+ } else {
582
+ logger.error("Reload failed")
583
+ call.reject("Reload failed")
584
+ }
585
+ }
586
+
587
+ @objc func next(_ call: CAPPluginCall) {
588
+ guard let id = call.getString("id") else {
589
+ logger.error("Next called without id")
590
+ call.reject("Next called without id")
591
+ return
592
+ }
593
+ logger.info("Setting next active id \(id)")
594
+ if !self.implementation.setNextBundle(next: id) {
595
+ logger.error("Set next version failed. id \(id) does not exist.")
596
+ call.reject("Set next version failed. id \(id) does not exist.")
597
+ } else {
598
+ call.resolve(self.implementation.getBundleInfo(id: id).toJSON())
599
+ }
600
+ }
601
+
602
+ @objc func set(_ call: CAPPluginCall) {
603
+ guard let id = call.getString("id") else {
604
+ logger.error("Set called without id")
605
+ call.reject("Set called without id")
606
+ return
607
+ }
608
+ let res = implementation.set(id: id)
609
+ logger.info("Set active bundle: \(id)")
610
+ if !res {
611
+ logger.info("Bundle successfully set to: \(id) ")
612
+ call.reject("Update failed, id \(id) doesn't exist")
613
+ } else {
614
+ self.reload(call)
615
+ }
616
+ }
617
+
618
+ @objc func delete(_ call: CAPPluginCall) {
619
+ guard let id = call.getString("id") else {
620
+ logger.error("Delete called without version")
621
+ call.reject("Delete called without id")
622
+ return
623
+ }
624
+ let res = implementation.delete(id: id)
625
+ if res {
626
+ call.resolve()
627
+ } else {
628
+ logger.error("Delete failed, id \(id) doesn't exist or it cannot be deleted (perhaps it is the 'next' bundle)")
629
+ call.reject("Delete failed, id \(id) does not exist or it cannot be deleted (perhaps it is the 'next' bundle)")
630
+ }
631
+ }
632
+
633
+ @objc func setBundleError(_ call: CAPPluginCall) {
634
+ if !allowManualBundleError {
635
+ logger.error("setBundleError called without allowManualBundleError")
636
+ call.reject("setBundleError not allowed. Set allowManualBundleError to true in your config to enable it.")
637
+ return
638
+ }
639
+ guard let id = call.getString("id") else {
640
+ logger.error("setBundleError called without id")
641
+ call.reject("setBundleError called without id")
642
+ return
643
+ }
644
+ let bundle = implementation.getBundleInfo(id: id)
645
+ if bundle.isUnknown() {
646
+ logger.error("setBundleError called with unknown bundle \(id)")
647
+ call.reject("Bundle \(id) does not exist")
648
+ return
649
+ }
650
+ if bundle.isBuiltin() {
651
+ logger.error("setBundleError called on builtin bundle")
652
+ call.reject("Cannot set builtin bundle to error state")
653
+ return
654
+ }
655
+ if self._isAutoUpdateEnabled() {
656
+ logger.warn("setBundleError used while autoUpdate is enabled; this method is intended for manual mode")
657
+ }
658
+ implementation.setError(bundle: bundle)
659
+ let updated = implementation.getBundleInfo(id: id)
660
+ call.resolve(["bundle": updated.toJSON()])
661
+ }
662
+
663
+ @objc func list(_ call: CAPPluginCall) {
664
+ let raw = call.getBool("raw", false)
665
+ let res = implementation.list(raw: raw)
666
+ var resArr: [[String: String]] = []
667
+ for v in res {
668
+ resArr.append(v.toJSON())
669
+ }
670
+ call.resolve([
671
+ "bundles": resArr
672
+ ])
673
+ }
674
+
675
+ @objc func getLatest(_ call: CAPPluginCall) {
676
+ let channel = call.getString("channel")
677
+ DispatchQueue.global(qos: .background).async {
678
+ let res = self.implementation.getLatest(url: URL(string: self.updateUrl)!, channel: channel)
679
+ if res.error != nil {
680
+ call.reject( res.error!)
681
+ } else if res.message != nil {
682
+ call.reject( res.message!)
683
+ } else {
684
+ call.resolve(res.toDict())
685
+ }
686
+ }
687
+ }
688
+
689
+ @objc func unsetChannel(_ call: CAPPluginCall) {
690
+ let triggerAutoUpdate = call.getBool("triggerAutoUpdate", false)
691
+ DispatchQueue.global(qos: .background).async {
692
+ let res = self.implementation.unsetChannel()
693
+ if res.error != "" {
694
+ call.reject(res.error, "UNSETCHANNEL_FAILED", nil, [
695
+ "message": res.error,
696
+ "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
697
+ ])
698
+ } else {
699
+ if self._isAutoUpdateEnabled() && triggerAutoUpdate {
700
+ self.logger.info("Calling autoupdater after channel change!")
701
+ self.backgroundDownload()
702
+ }
703
+ call.resolve(res.toDict())
704
+ }
705
+ }
706
+ }
707
+
708
+ @objc func setChannel(_ call: CAPPluginCall) {
709
+ guard let channel = call.getString("channel") else {
710
+ logger.error("setChannel called without channel")
711
+ call.reject("setChannel called without channel", "SETCHANNEL_INVALID_PARAMS", nil, [
712
+ "message": "setChannel called without channel",
713
+ "error": "missing_parameter"
714
+ ])
715
+ return
716
+ }
717
+ let triggerAutoUpdate = call.getBool("triggerAutoUpdate") ?? false
718
+ DispatchQueue.global(qos: .background).async {
719
+ let res = self.implementation.setChannel(channel: channel)
720
+ if res.error != "" {
721
+ call.reject(res.error, "SETCHANNEL_FAILED", nil, [
722
+ "message": res.error,
723
+ "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
724
+ ])
725
+ } else {
726
+ if self._isAutoUpdateEnabled() && triggerAutoUpdate {
727
+ self.logger.info("Calling autoupdater after channel change!")
728
+ self.backgroundDownload()
729
+ }
730
+ call.resolve(res.toDict())
731
+ }
732
+ }
733
+ }
734
+
735
+ @objc func getChannel(_ call: CAPPluginCall) {
736
+ DispatchQueue.global(qos: .background).async {
737
+ let res = self.implementation.getChannel()
738
+ if res.error != "" {
739
+ call.reject(res.error, "GETCHANNEL_FAILED", nil, [
740
+ "message": res.error,
741
+ "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
742
+ ])
743
+ } else {
744
+ call.resolve(res.toDict())
745
+ }
746
+ }
747
+ }
748
+
749
+ @objc func listChannels(_ call: CAPPluginCall) {
750
+ DispatchQueue.global(qos: .background).async {
751
+ let res = self.implementation.listChannels()
752
+ if res.error != "" {
753
+ call.reject(res.error, "LISTCHANNELS_FAILED", nil, [
754
+ "message": res.error,
755
+ "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
756
+ ])
757
+ } else {
758
+ call.resolve(res.toDict())
759
+ }
760
+ }
761
+ }
762
+
763
+ @objc func setCustomId(_ call: CAPPluginCall) {
764
+ guard let customId = call.getString("customId") else {
765
+ logger.error("setCustomId called without customId")
766
+ call.reject("setCustomId called without customId")
767
+ return
768
+ }
769
+ self.implementation.customId = customId
770
+ if persistCustomId {
771
+ if customId.isEmpty {
772
+ UserDefaults.standard.removeObject(forKey: customIdDefaultsKey)
773
+ } else {
774
+ UserDefaults.standard.set(customId, forKey: customIdDefaultsKey)
775
+ }
776
+ UserDefaults.standard.synchronize()
777
+ }
778
+ call.resolve()
779
+ }
780
+
781
+ @objc func _reset(toLastSuccessful: Bool) -> Bool {
782
+ guard let bridge = self.bridge else { return false }
783
+
784
+ if (bridge.viewController as? CAPBridgeViewController) != nil {
785
+ let fallback: BundleInfo = self.implementation.getFallbackBundle()
786
+
787
+ // If developer wants to reset to the last successful bundle, and that bundle is not
788
+ // the built-in bundle, set it as the bundle to use and reload.
789
+ if toLastSuccessful && !fallback.isBuiltin() {
790
+ logger.info("Resetting to: \(fallback.toString())")
791
+ return self.implementation.set(bundle: fallback) && self._reload()
792
+ }
793
+
794
+ logger.info("Resetting to builtin version")
795
+
796
+ // Otherwise, reset back to the built-in bundle and reload.
797
+ self.implementation.reset()
798
+ return self._reload()
799
+ }
800
+
801
+ return false
802
+ }
803
+
804
+ @objc func reset(_ call: CAPPluginCall) {
805
+ let toLastSuccessful = call.getBool("toLastSuccessful") ?? false
806
+ if self._reset(toLastSuccessful: toLastSuccessful) {
807
+ call.resolve()
808
+ } else {
809
+ logger.error("Reset failed")
810
+ call.reject("Reset failed")
811
+ }
812
+ }
813
+
814
+ @objc func current(_ call: CAPPluginCall) {
815
+ let bundle: BundleInfo = self.implementation.getCurrentBundle()
816
+ call.resolve([
817
+ "bundle": bundle.toJSON(),
818
+ "native": self.currentVersionNative.description
819
+ ])
820
+ }
821
+
822
+ @objc func notifyAppReady(_ call: CAPPluginCall) {
823
+ self.semaphoreDown()
824
+ let bundle = self.implementation.getCurrentBundle()
825
+ self.implementation.setSuccess(bundle: bundle, autoDeletePrevious: self.autoDeletePrevious)
826
+ logger.info("Current bundle loaded successfully. [notifyAppReady was called] \(bundle.toString())")
827
+ call.resolve(["bundle": bundle.toJSON()])
828
+ }
829
+
830
+ @objc func setMultiDelay(_ call: CAPPluginCall) {
831
+ guard let delayConditionList = call.getValue("delayConditions") else {
832
+ logger.error("setMultiDelay called without delayCondition")
833
+ call.reject("setMultiDelay called without delayCondition")
834
+ return
835
+ }
836
+
837
+ // Handle background conditions with empty value (set to "0")
838
+ if var modifiableList = delayConditionList as? [[String: Any]] {
839
+ for i in 0..<modifiableList.count {
840
+ if let kind = modifiableList[i]["kind"] as? String,
841
+ kind == "background",
842
+ let value = modifiableList[i]["value"] as? String,
843
+ value.isEmpty {
844
+ modifiableList[i]["value"] = "0"
845
+ }
846
+ }
847
+ let delayConditions: String = toJson(object: modifiableList)
848
+ if delayUpdateUtils.setMultiDelay(delayConditions: delayConditions) {
849
+ call.resolve()
850
+ } else {
851
+ call.reject("Failed to delay update")
852
+ }
853
+ } else {
854
+ let delayConditions: String = toJson(object: delayConditionList)
855
+ if delayUpdateUtils.setMultiDelay(delayConditions: delayConditions) {
856
+ call.resolve()
857
+ } else {
858
+ call.reject("Failed to delay update")
859
+ }
860
+ }
861
+ }
862
+
863
+ // Note: _setMultiDelay and _cancelDelay methods have been moved to DelayUpdateUtils class
864
+
865
+ @objc func cancelDelay(_ call: CAPPluginCall) {
866
+ if delayUpdateUtils.cancelDelay(source: "JS") {
867
+ call.resolve()
868
+ } else {
869
+ call.reject("Failed to cancel delay")
870
+ }
871
+ }
872
+
873
+ // Note: _checkCancelDelay method has been moved to DelayUpdateUtils class
874
+
875
+ private func _isAutoUpdateEnabled() -> Bool {
876
+ let instanceDescriptor = (self.bridge?.viewController as? CAPBridgeViewController)?.instanceDescriptor()
877
+ if instanceDescriptor?.serverURL != nil {
878
+ logger.warn("AutoUpdate is automatic disabled when serverUrl is set.")
879
+ }
880
+ return self.autoUpdate && self.updateUrl != "" && instanceDescriptor?.serverURL == nil
881
+ }
882
+
883
+ @objc func isAutoUpdateEnabled(_ call: CAPPluginCall) {
884
+ call.resolve([
885
+ "enabled": self._isAutoUpdateEnabled()
886
+ ])
887
+ }
888
+
889
+ @objc func isAutoUpdateAvailable(_ call: CAPPluginCall) {
890
+ let instanceDescriptor = (self.bridge?.viewController as? CAPBridgeViewController)?.instanceDescriptor()
891
+ let isAvailable = instanceDescriptor?.serverURL == nil
892
+ call.resolve([
893
+ "available": isAvailable
894
+ ])
895
+ }
896
+
897
+ func checkAppReady() {
898
+ self.appReadyCheck?.cancel()
899
+ self.appReadyCheck = DispatchWorkItem(block: {
900
+ self.DeferredNotifyAppReadyCheck()
901
+ })
902
+ logger.info("Wait for \(self.appReadyTimeout) ms, then check for notifyAppReady")
903
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.appReadyTimeout), execute: self.appReadyCheck!)
904
+ }
905
+
906
+ func checkRevert() {
907
+ // Automatically roll back to fallback version if notifyAppReady has not been called yet
908
+ let current: BundleInfo = self.implementation.getCurrentBundle()
909
+ if current.isBuiltin() {
910
+ logger.info("Built-in bundle is active. We skip the check for notifyAppReady.")
911
+ return
912
+ }
913
+
914
+ logger.info("Current bundle is: \(current.toString())")
915
+
916
+ if BundleStatus.SUCCESS.localizedString != current.getStatus() {
917
+ logger.error("notifyAppReady was not called, roll back current bundle: \(current.toString())")
918
+ logger.error("Did you forget to call 'notifyAppReady()' in your Capacitor App code?")
919
+ self.notifyListeners("updateFailed", data: [
920
+ "bundle": current.toJSON()
921
+ ])
922
+ self.persistLastFailedBundle(current)
923
+ self.implementation.sendStats(action: "update_fail", versionName: current.getVersionName())
924
+ self.implementation.setError(bundle: current)
925
+ _ = self._reset(toLastSuccessful: true)
926
+ if self.autoDeleteFailed && !current.isBuiltin() {
927
+ logger.info("Deleting failing bundle: \(current.toString())")
928
+ let res = self.implementation.delete(id: current.getId(), removeInfo: false)
929
+ if !res {
930
+ logger.info("Delete version deleted: \(current.toString())")
931
+ } else {
932
+ logger.error("Failed to delete failed bundle: \(current.toString())")
933
+ }
934
+ }
935
+ } else {
936
+ logger.info("notifyAppReady was called. This is fine: \(current.toString())")
937
+ }
938
+ }
939
+
940
+ func DeferredNotifyAppReadyCheck() {
941
+ self.checkRevert()
942
+ self.appReadyCheck = nil
943
+ }
944
+
945
+ func endBackGroundTask() {
946
+ UIApplication.shared.endBackgroundTask(self.backgroundTaskID)
947
+ self.backgroundTaskID = UIBackgroundTaskIdentifier.invalid
948
+ }
949
+
950
+ func sendReadyToJs(current: BundleInfo, msg: String) {
951
+ logger.info("sendReadyToJs")
952
+ DispatchQueue.global().async {
953
+ self.semaphoreWait(waitTime: self.appReadyTimeout)
954
+ self.notifyListeners("appReady", data: ["bundle": current.toJSON(), "status": msg], retainUntilConsumed: true)
955
+
956
+ // Auto hide splashscreen if enabled
957
+ // We show it on background when conditions are met, so we should hide it on foreground regardless of update outcome
958
+ if self.autoSplashscreen {
959
+ self.hideSplashscreen()
960
+ }
961
+ }
962
+ }
963
+
964
+ private func hideSplashscreen() {
965
+ if Thread.isMainThread {
966
+ self.performHideSplashscreen()
967
+ } else {
968
+ DispatchQueue.main.async {
969
+ self.performHideSplashscreen()
970
+ }
971
+ }
972
+ }
973
+
974
+ private func performHideSplashscreen() {
975
+ self.cancelSplashscreenTimeout()
976
+ self.removeSplashscreenLoader()
977
+
978
+ guard let bridge = self.bridge else {
979
+ self.logger.warn("Bridge not available for hiding splashscreen with autoSplashscreen")
980
+ return
981
+ }
982
+
983
+ // Create a plugin call for the hide method
984
+ let call = CAPPluginCall(callbackId: "autoHideSplashscreen", options: [:], success: { (_, _) in
985
+ self.logger.info("Splashscreen hidden automatically")
986
+ }, error: { (_) in
987
+ self.logger.error("Failed to auto-hide splashscreen")
988
+ })
989
+
990
+ // Try to call the SplashScreen hide method directly through the bridge
991
+ if let splashScreenPlugin = bridge.plugin(withName: "SplashScreen") {
992
+ // Use runtime method invocation to call hide method
993
+ let selector = NSSelectorFromString("hide:")
994
+ if splashScreenPlugin.responds(to: selector) {
995
+ _ = splashScreenPlugin.perform(selector, with: call)
996
+ self.logger.info("Called SplashScreen hide method")
997
+ } else {
998
+ self.logger.warn("autoSplashscreen: SplashScreen plugin does not respond to hide: method. Make sure @capacitor/splash-screen plugin is properly installed.")
999
+ }
1000
+ } else {
1001
+ self.logger.warn("autoSplashscreen: SplashScreen plugin not found. Install @capacitor/splash-screen plugin.")
1002
+ }
1003
+ }
1004
+
1005
+ private func showSplashscreen() {
1006
+ if Thread.isMainThread {
1007
+ self.performShowSplashscreen()
1008
+ } else {
1009
+ DispatchQueue.main.async {
1010
+ self.performShowSplashscreen()
1011
+ }
1012
+ }
1013
+ }
1014
+
1015
+ private func performShowSplashscreen() {
1016
+ self.cancelSplashscreenTimeout()
1017
+ self.autoSplashscreenTimedOut = false
1018
+
1019
+ guard let bridge = self.bridge else {
1020
+ self.logger.warn("Bridge not available for showing splashscreen with autoSplashscreen")
1021
+ return
1022
+ }
1023
+
1024
+ // Create a plugin call for the show method
1025
+ let call = CAPPluginCall(callbackId: "autoShowSplashscreen", options: [:], success: { (_, _) in
1026
+ self.logger.info("Splashscreen shown automatically")
1027
+ }, error: { (_) in
1028
+ self.logger.error("Failed to auto-show splashscreen")
1029
+ })
1030
+
1031
+ // Try to call the SplashScreen show method directly through the bridge
1032
+ if let splashScreenPlugin = bridge.plugin(withName: "SplashScreen") {
1033
+ // Use runtime method invocation to call show method
1034
+ let selector = NSSelectorFromString("show:")
1035
+ if splashScreenPlugin.responds(to: selector) {
1036
+ _ = splashScreenPlugin.perform(selector, with: call)
1037
+ self.logger.info("Called SplashScreen show method")
1038
+ } else {
1039
+ self.logger.warn("autoSplashscreen: SplashScreen plugin does not respond to show: method. Make sure @capacitor/splash-screen plugin is properly installed.")
1040
+ }
1041
+ } else {
1042
+ self.logger.warn("autoSplashscreen: SplashScreen plugin not found. Install @capacitor/splash-screen plugin.")
1043
+ }
1044
+
1045
+ self.addSplashscreenLoaderIfNeeded()
1046
+ self.scheduleSplashscreenTimeout()
1047
+ }
1048
+
1049
+ private func addSplashscreenLoaderIfNeeded() {
1050
+ guard self.autoSplashscreenLoader else {
1051
+ return
1052
+ }
1053
+
1054
+ let addLoader = {
1055
+ guard self.splashscreenLoaderContainer == nil else {
1056
+ return
1057
+ }
1058
+ guard let rootView = self.bridge?.viewController?.view else {
1059
+ self.logger.warn("autoSplashscreen: Unable to access root view for loader overlay")
1060
+ return
1061
+ }
1062
+
1063
+ let container = UIView()
1064
+ container.translatesAutoresizingMaskIntoConstraints = false
1065
+ container.backgroundColor = UIColor.clear
1066
+ container.isUserInteractionEnabled = false
1067
+
1068
+ let indicatorStyle: UIActivityIndicatorView.Style
1069
+ if #available(iOS 13.0, *) {
1070
+ indicatorStyle = .large
1071
+ } else {
1072
+ indicatorStyle = .whiteLarge
1073
+ }
1074
+
1075
+ let indicator = UIActivityIndicatorView(style: indicatorStyle)
1076
+ indicator.translatesAutoresizingMaskIntoConstraints = false
1077
+ indicator.hidesWhenStopped = false
1078
+ if #available(iOS 13.0, *) {
1079
+ indicator.color = UIColor.label
1080
+ }
1081
+ indicator.startAnimating()
1082
+
1083
+ container.addSubview(indicator)
1084
+ rootView.addSubview(container)
1085
+
1086
+ NSLayoutConstraint.activate([
1087
+ container.leadingAnchor.constraint(equalTo: rootView.leadingAnchor),
1088
+ container.trailingAnchor.constraint(equalTo: rootView.trailingAnchor),
1089
+ container.topAnchor.constraint(equalTo: rootView.topAnchor),
1090
+ container.bottomAnchor.constraint(equalTo: rootView.bottomAnchor),
1091
+ indicator.centerXAnchor.constraint(equalTo: container.centerXAnchor),
1092
+ indicator.centerYAnchor.constraint(equalTo: container.centerYAnchor)
1093
+ ])
1094
+
1095
+ self.splashscreenLoaderContainer = container
1096
+ self.splashscreenLoaderView = indicator
1097
+ }
1098
+
1099
+ if Thread.isMainThread {
1100
+ addLoader()
1101
+ } else {
1102
+ DispatchQueue.main.async {
1103
+ addLoader()
1104
+ }
1105
+ }
1106
+ }
1107
+
1108
+ private func removeSplashscreenLoader() {
1109
+ let removeLoader = {
1110
+ self.splashscreenLoaderView?.stopAnimating()
1111
+ self.splashscreenLoaderContainer?.removeFromSuperview()
1112
+ self.splashscreenLoaderView = nil
1113
+ self.splashscreenLoaderContainer = nil
1114
+ }
1115
+
1116
+ if Thread.isMainThread {
1117
+ removeLoader()
1118
+ } else {
1119
+ DispatchQueue.main.async {
1120
+ removeLoader()
1121
+ }
1122
+ }
1123
+ }
1124
+
1125
+ private func scheduleSplashscreenTimeout() {
1126
+ guard self.autoSplashscreenTimeout > 0 else {
1127
+ return
1128
+ }
1129
+
1130
+ let scheduleTimeout = {
1131
+ self.autoSplashscreenTimeoutWorkItem?.cancel()
1132
+
1133
+ let workItem = DispatchWorkItem { [weak self] in
1134
+ guard let self = self else { return }
1135
+ self.autoSplashscreenTimedOut = true
1136
+ self.logger.info("autoSplashscreen timeout reached, hiding splashscreen")
1137
+ self.hideSplashscreen()
1138
+ }
1139
+ self.autoSplashscreenTimeoutWorkItem = workItem
1140
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.autoSplashscreenTimeout), execute: workItem)
1141
+ }
1142
+
1143
+ if Thread.isMainThread {
1144
+ scheduleTimeout()
1145
+ } else {
1146
+ DispatchQueue.main.async {
1147
+ scheduleTimeout()
1148
+ }
1149
+ }
1150
+ }
1151
+
1152
+ private func cancelSplashscreenTimeout() {
1153
+ let cancelTimeout = {
1154
+ self.autoSplashscreenTimeoutWorkItem?.cancel()
1155
+ self.autoSplashscreenTimeoutWorkItem = nil
1156
+ }
1157
+
1158
+ if Thread.isMainThread {
1159
+ cancelTimeout()
1160
+ } else {
1161
+ DispatchQueue.main.async {
1162
+ cancelTimeout()
1163
+ }
1164
+ }
1165
+ }
1166
+
1167
+ private func checkIfRecentlyInstalledOrUpdated() -> Bool {
1168
+ let userDefaults = UserDefaults.standard
1169
+ let currentVersion = self.currentBuildVersion
1170
+ let lastKnownVersion = userDefaults.string(forKey: "LatestNativeBuildVersion") ?? "0"
1171
+
1172
+ if lastKnownVersion == "0" {
1173
+ // First time running, consider it as recently installed
1174
+ return true
1175
+ } else if lastKnownVersion != currentVersion {
1176
+ // Version changed, consider it as recently updated
1177
+ return true
1178
+ }
1179
+
1180
+ return false
1181
+ }
1182
+
1183
+ private func shouldUseDirectUpdate() -> Bool {
1184
+ if self.autoSplashscreenTimedOut {
1185
+ return false
1186
+ }
1187
+ switch directUpdateMode {
1188
+ case "false":
1189
+ return false
1190
+ case "always":
1191
+ return true
1192
+ case "atInstall":
1193
+ if self.wasRecentlyInstalledOrUpdated {
1194
+ // Reset the flag after first use to prevent subsequent foreground events from using direct update
1195
+ self.wasRecentlyInstalledOrUpdated = false
1196
+ return true
1197
+ }
1198
+ return false
1199
+ default:
1200
+ return false
1201
+ }
1202
+ }
1203
+
1204
+ private func notifyBreakingEvents(version: String) {
1205
+ guard !version.isEmpty else {
1206
+ return
1207
+ }
1208
+ let payload: [String: Any] = ["version": version]
1209
+ self.notifyListeners("breakingAvailable", data: payload)
1210
+ self.notifyListeners("majorAvailable", data: payload)
1211
+ }
1212
+
1213
+ func endBackGroundTaskWithNotif(
1214
+ msg: String,
1215
+ latestVersionName: String,
1216
+ current: BundleInfo,
1217
+ error: Bool = true,
1218
+ failureAction: String = "download_fail",
1219
+ failureEvent: String = "downloadFailed"
1220
+ ) {
1221
+ if error {
1222
+ self.implementation.sendStats(action: failureAction, versionName: current.getVersionName())
1223
+ self.notifyListeners(failureEvent, data: ["version": latestVersionName])
1224
+ }
1225
+ self.notifyListeners("noNeedUpdate", data: ["bundle": current.toJSON()])
1226
+ self.sendReadyToJs(current: current, msg: msg)
1227
+ logger.info("endBackGroundTaskWithNotif \(msg) current: \(current.getVersionName()) latestVersionName: \(latestVersionName)")
1228
+ self.endBackGroundTask()
1229
+ }
1230
+
1231
+ func backgroundDownload() {
1232
+ let plannedDirectUpdate = self.shouldUseDirectUpdate()
1233
+ let messageUpdate = plannedDirectUpdate ? "Update will occur now." : "Update will occur next time app moves to background."
1234
+ guard let url = URL(string: self.updateUrl) else {
1235
+ logger.error("Error no url or wrong format")
1236
+ return
1237
+ }
1238
+ DispatchQueue.global(qos: .background).async {
1239
+ self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "Finish Download Tasks") {
1240
+ // End the task if time expires.
1241
+ self.endBackGroundTask()
1242
+ }
1243
+ self.logger.info("Check for update via \(self.updateUrl)")
1244
+ let res = self.implementation.getLatest(url: url, channel: nil)
1245
+ let current = self.implementation.getCurrentBundle()
1246
+
1247
+ // Handle network errors and other failures first
1248
+ if let backendError = res.error, !backendError.isEmpty {
1249
+ self.logger.error("getLatest failed with error: \(backendError)")
1250
+ if backendError == "response_error" {
1251
+ self.endBackGroundTaskWithNotif(
1252
+ msg: "Network error: \(backendError)",
1253
+ latestVersionName: res.version,
1254
+ current: current,
1255
+ error: true
1256
+ )
1257
+ } else {
1258
+ self.endBackGroundTaskWithNotif(
1259
+ msg: backendError,
1260
+ latestVersionName: res.version,
1261
+ current: current,
1262
+ error: true,
1263
+ failureAction: "backend_refusal",
1264
+ failureEvent: "backendRefused"
1265
+ )
1266
+ }
1267
+ return
1268
+ }
1269
+
1270
+ if let message = res.message, !message.isEmpty {
1271
+ self.logger.info("API message: \(message)")
1272
+ if res.breaking == true || res.major == true {
1273
+ self.notifyBreakingEvents(version: res.version)
1274
+ }
1275
+ self.endBackGroundTaskWithNotif(
1276
+ msg: message,
1277
+ latestVersionName: res.version,
1278
+ current: current,
1279
+ error: true,
1280
+ failureAction: "backend_refusal",
1281
+ failureEvent: "backendRefused"
1282
+ )
1283
+ return
1284
+ }
1285
+ if res.version == "builtin" {
1286
+ self.logger.info("Latest version is builtin")
1287
+ let directUpdateAllowed = plannedDirectUpdate && !self.autoSplashscreenTimedOut
1288
+ if directUpdateAllowed {
1289
+ self.logger.info("Direct update to builtin version")
1290
+ _ = self._reset(toLastSuccessful: false)
1291
+ self.endBackGroundTaskWithNotif(msg: "Updated to builtin version", latestVersionName: res.version, current: self.implementation.getCurrentBundle(), error: false)
1292
+ } else {
1293
+ if plannedDirectUpdate && !directUpdateAllowed {
1294
+ self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will apply later.")
1295
+ }
1296
+ self.logger.info("Setting next bundle to builtin")
1297
+ _ = self.implementation.setNextBundle(next: BundleInfo.ID_BUILTIN)
1298
+ self.endBackGroundTaskWithNotif(msg: "Next update will be to builtin version", latestVersionName: res.version, current: current, error: false)
1299
+ }
1300
+ return
1301
+ }
1302
+ let sessionKey = res.sessionKey ?? ""
1303
+ guard let downloadUrl = URL(string: res.url) else {
1304
+ self.logger.error("Error no url or wrong format")
1305
+ self.endBackGroundTaskWithNotif(msg: "Error no url or wrong format", latestVersionName: res.version, current: current)
1306
+ return
1307
+ }
1308
+ let latestVersionName = res.version
1309
+ if latestVersionName != "" && current.getVersionName() != latestVersionName {
1310
+ do {
1311
+ self.logger.info("New bundle: \(latestVersionName) found. Current is: \(current.getVersionName()). \(messageUpdate)")
1312
+ var nextImpl = self.implementation.getBundleInfoByVersionName(version: latestVersionName)
1313
+ if nextImpl == nil || nextImpl?.isDeleted() == true {
1314
+ if nextImpl?.isDeleted() == true {
1315
+ self.logger.info("Latest bundle already exists and will be deleted, download will overwrite it.")
1316
+ let res = self.implementation.delete(id: nextImpl!.getId(), removeInfo: true)
1317
+ if res {
1318
+ self.logger.info("Failed bundle deleted: \(nextImpl!.toString())")
1319
+ } else {
1320
+ self.logger.error("Failed to delete failed bundle: \(nextImpl!.toString())")
1321
+ }
1322
+ }
1323
+ if res.manifest != nil {
1324
+ nextImpl = try self.implementation.downloadManifest(manifest: res.manifest!, version: latestVersionName, sessionKey: sessionKey)
1325
+ } else {
1326
+ nextImpl = try self.implementation.download(url: downloadUrl, version: latestVersionName, sessionKey: sessionKey)
1327
+ }
1328
+ }
1329
+ guard let next = nextImpl else {
1330
+ self.logger.error("Error downloading file")
1331
+ self.endBackGroundTaskWithNotif(msg: "Error downloading file", latestVersionName: latestVersionName, current: current)
1332
+ return
1333
+ }
1334
+ if next.isErrorStatus() {
1335
+ self.logger.error("Latest bundle already exists and is in error state. Aborting update.")
1336
+ self.endBackGroundTaskWithNotif(msg: "Latest version is in error state. Aborting update.", latestVersionName: latestVersionName, current: current)
1337
+ return
1338
+ }
1339
+ res.checksum = try CryptoCipherV2.decryptChecksum(checksum: res.checksum, publicKey: self.implementation.publicKey)
1340
+ if res.checksum != "" && next.getChecksum() != res.checksum && res.manifest == nil {
1341
+ self.logger.error("Error checksum \(next.getChecksum()) \(res.checksum)")
1342
+ self.implementation.sendStats(action: "checksum_fail", versionName: next.getVersionName())
1343
+ let id = next.getId()
1344
+ let resDel = self.implementation.delete(id: id)
1345
+ if !resDel {
1346
+ self.logger.error("Delete failed, id \(id) doesn't exist")
1347
+ }
1348
+ self.endBackGroundTaskWithNotif(msg: "Error checksum", latestVersionName: latestVersionName, current: current)
1349
+ return
1350
+ }
1351
+ let directUpdateAllowed = plannedDirectUpdate && !self.autoSplashscreenTimedOut
1352
+ if directUpdateAllowed {
1353
+ let delayUpdatePreferences = UserDefaults.standard.string(forKey: DelayUpdateUtils.DELAY_CONDITION_PREFERENCES) ?? "[]"
1354
+ let delayConditionList: [DelayCondition] = self.fromJsonArr(json: delayUpdatePreferences).map { obj -> DelayCondition in
1355
+ let kind: String = obj.value(forKey: "kind") as! String
1356
+ let value: String? = obj.value(forKey: "value") as? String
1357
+ return DelayCondition(kind: kind, value: value)
1358
+ }
1359
+ if !delayConditionList.isEmpty {
1360
+ self.logger.info("Update delayed until delay conditions met")
1361
+ self.endBackGroundTaskWithNotif(msg: "Update delayed until delay conditions met", latestVersionName: latestVersionName, current: next, error: false)
1362
+ return
1363
+ }
1364
+ _ = self.implementation.set(bundle: next)
1365
+ _ = self._reload()
1366
+ self.endBackGroundTaskWithNotif(msg: "update installed", latestVersionName: latestVersionName, current: next, error: false)
1367
+ } else {
1368
+ if plannedDirectUpdate && !directUpdateAllowed {
1369
+ self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will install on next app background.")
1370
+ }
1371
+ self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
1372
+ _ = self.implementation.setNextBundle(next: next.getId())
1373
+ self.endBackGroundTaskWithNotif(msg: "update downloaded, will install next background", latestVersionName: latestVersionName, current: current, error: false)
1374
+ }
1375
+ return
1376
+ } catch {
1377
+ self.logger.error("Error downloading file \(error.localizedDescription)")
1378
+ let current: BundleInfo = self.implementation.getCurrentBundle()
1379
+ self.endBackGroundTaskWithNotif(msg: "Error downloading file", latestVersionName: latestVersionName, current: current)
1380
+ return
1381
+ }
1382
+ } else {
1383
+ self.logger.info("No need to update, \(current.getId()) is the latest bundle.")
1384
+ self.endBackGroundTaskWithNotif(msg: "No need to update, \(current.getId()) is the latest bundle.", latestVersionName: latestVersionName, current: current, error: false)
1385
+ return
1386
+ }
1387
+ }
1388
+ }
1389
+
1390
+ private func installNext() {
1391
+ let delayUpdatePreferences = UserDefaults.standard.string(forKey: DelayUpdateUtils.DELAY_CONDITION_PREFERENCES) ?? "[]"
1392
+ let delayConditionList: [DelayCondition] = fromJsonArr(json: delayUpdatePreferences).map { obj -> DelayCondition in
1393
+ let kind: String = obj.value(forKey: "kind") as! String
1394
+ let value: String? = obj.value(forKey: "value") as? String
1395
+ return DelayCondition(kind: kind, value: value)
1396
+ }
1397
+ if !delayConditionList.isEmpty {
1398
+ logger.info("Update delayed until delay conditions met")
1399
+ return
1400
+ }
1401
+ let current: BundleInfo = self.implementation.getCurrentBundle()
1402
+ let next: BundleInfo? = self.implementation.getNextBundle()
1403
+
1404
+ if next != nil && !next!.isErrorStatus() && next!.getVersionName() != current.getVersionName() {
1405
+ logger.info("Next bundle is: \(next!.toString())")
1406
+ if self.implementation.set(bundle: next!) && self._reload() {
1407
+ logger.info("Updated to bundle: \(next!.toString())")
1408
+ _ = self.implementation.setNextBundle(next: Optional<String>.none)
1409
+ } else {
1410
+ logger.error("Update to bundle: \(next!.toString()) Failed!")
1411
+ }
1412
+ }
1413
+ }
1414
+
1415
+ @objc private func toJson(object: Any) -> String {
1416
+ guard let data = try? JSONSerialization.data(withJSONObject: object, options: []) else {
1417
+ return ""
1418
+ }
1419
+ return String(data: data, encoding: String.Encoding.utf8) ?? ""
1420
+ }
1421
+
1422
+ @objc private func fromJsonArr(json: String) -> [NSObject] {
1423
+ guard let jsonData = json.data(using: .utf8) else {
1424
+ return []
1425
+ }
1426
+ let object = try? JSONSerialization.jsonObject(
1427
+ with: jsonData,
1428
+ options: .mutableContainers
1429
+ ) as? [NSObject]
1430
+ return object ?? []
1431
+ }
1432
+
1433
+ @objc func appMovedToForeground() {
1434
+ let current: BundleInfo = self.implementation.getCurrentBundle()
1435
+ self.implementation.sendStats(action: "app_moved_to_foreground", versionName: current.getVersionName())
1436
+ self.delayUpdateUtils.checkCancelDelay(source: .foreground)
1437
+ self.delayUpdateUtils.unsetBackgroundTimestamp()
1438
+ if backgroundWork != nil && taskRunning {
1439
+ backgroundWork!.cancel()
1440
+ logger.info("Background Timer Task canceled, Activity resumed before timer completes")
1441
+ }
1442
+ if self._isAutoUpdateEnabled() {
1443
+ self.backgroundDownload()
1444
+ } else {
1445
+ logger.info("Auto update is disabled")
1446
+ self.sendReadyToJs(current: current, msg: "disabled")
1447
+ }
1448
+ self.checkAppReady()
1449
+ }
1450
+
1451
+ private var periodicUpdateTimer: Timer?
1452
+
1453
+ @objc func checkForUpdateAfterDelay() {
1454
+ if periodCheckDelay == 0 || !self._isAutoUpdateEnabled() {
1455
+ return
1456
+ }
1457
+ guard let url = URL(string: self.updateUrl) else {
1458
+ logger.error("Error no url or wrong format")
1459
+ return
1460
+ }
1461
+
1462
+ // Clean up any existing timer
1463
+ periodicUpdateTimer?.invalidate()
1464
+
1465
+ periodicUpdateTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(periodCheckDelay), repeats: true) { [weak self] timer in
1466
+ guard let self = self else {
1467
+ timer.invalidate()
1468
+ return
1469
+ }
1470
+ DispatchQueue.global(qos: .background).async {
1471
+ let res = self.implementation.getLatest(url: url, channel: nil)
1472
+ let current = self.implementation.getCurrentBundle()
1473
+
1474
+ if res.version != current.getVersionName() {
1475
+ self.logger.info("New version found: \(res.version)")
1476
+ self.backgroundDownload()
1477
+ }
1478
+ }
1479
+ }
1480
+ RunLoop.current.add(periodicUpdateTimer!, forMode: .default)
1481
+ }
1482
+
1483
+ @objc func appMovedToBackground() {
1484
+ let current: BundleInfo = self.implementation.getCurrentBundle()
1485
+ self.implementation.sendStats(action: "app_moved_to_background", versionName: current.getVersionName())
1486
+ logger.info("Check for pending update")
1487
+
1488
+ // Show splashscreen only if autoSplashscreen is enabled AND autoUpdate is enabled AND directUpdate would be used
1489
+ if self.autoSplashscreen {
1490
+ var canShowSplashscreen = true
1491
+
1492
+ if !self._isAutoUpdateEnabled() {
1493
+ logger.warn("autoSplashscreen is enabled but autoUpdate is disabled. Splashscreen will not be shown. Enable autoUpdate or disable autoSplashscreen.")
1494
+ canShowSplashscreen = false
1495
+ }
1496
+
1497
+ if !self.shouldUseDirectUpdate() {
1498
+ logger.warn("autoSplashscreen is enabled but directUpdate is not configured for immediate updates. Set directUpdate to 'always' or 'atInstall', or disable autoSplashscreen.")
1499
+ canShowSplashscreen = false
1500
+ }
1501
+
1502
+ if canShowSplashscreen {
1503
+ self.showSplashscreen()
1504
+ }
1505
+ }
1506
+
1507
+ // Set background timestamp
1508
+ let backgroundTimestamp = Int64(Date().timeIntervalSince1970 * 1000) // Convert to milliseconds
1509
+ self.delayUpdateUtils.setBackgroundTimestamp(backgroundTimestamp)
1510
+ self.delayUpdateUtils.checkCancelDelay(source: .background)
1511
+ self.installNext()
1512
+ }
1513
+
1514
+ @objc func getNextBundle(_ call: CAPPluginCall) {
1515
+ let bundle = self.implementation.getNextBundle()
1516
+ if bundle == nil || bundle?.isUnknown() == true {
1517
+ call.resolve()
1518
+ return
1519
+ }
1520
+
1521
+ call.resolve(bundle!.toJSON())
1522
+ }
1523
+
1524
+ @objc func getFailedUpdate(_ call: CAPPluginCall) {
1525
+ let bundle = self.readLastFailedBundle()
1526
+ if bundle == nil || bundle?.isUnknown() == true {
1527
+ call.resolve()
1528
+ return
1529
+ }
1530
+
1531
+ self.persistLastFailedBundle(nil)
1532
+ call.resolve([
1533
+ "bundle": bundle!.toJSON()
1534
+ ])
1535
+ }
1536
+
1537
+ @objc func setShakeMenu(_ call: CAPPluginCall) {
1538
+ guard let enabled = call.getBool("enabled") else {
1539
+ logger.error("setShakeMenu called without enabled parameter")
1540
+ call.reject("setShakeMenu called without enabled parameter")
1541
+ return
1542
+ }
1543
+
1544
+ self.shakeMenuEnabled = enabled
1545
+ logger.info("Shake menu \(enabled ? "enabled" : "disabled")")
1546
+ call.resolve()
1547
+ }
1548
+
1549
+ @objc func isShakeMenuEnabled(_ call: CAPPluginCall) {
1550
+ call.resolve([
1551
+ "enabled": self.shakeMenuEnabled
1552
+ ])
1553
+ }
1554
+
1555
+ @objc func getAppId(_ call: CAPPluginCall) {
1556
+ call.resolve([
1557
+ "appId": implementation.appId
1558
+ ])
1559
+ }
1560
+
1561
+ @objc func setAppId(_ call: CAPPluginCall) {
1562
+ if !getConfig().getBoolean("allowModifyAppId", false) {
1563
+ logger.error("setAppId called without allowModifyAppId")
1564
+ call.reject("setAppId called without allowModifyAppId set allowModifyAppId in your config to true to allow it")
1565
+ return
1566
+ }
1567
+ guard let appId = call.getString("appId") else {
1568
+ logger.error("setAppId called without appId")
1569
+ call.reject("setAppId called without appId")
1570
+ return
1571
+ }
1572
+ implementation.appId = appId
1573
+ call.resolve()
1574
+ }
1575
+ }