1 // Copyright © 2019 650 Industries. All rights reserved. 2 3 // swiftlint:disable closure_body_length 4 // swiftlint:disable type_body_length 5 // swiftlint:disable file_length 6 7 // this class uses a ton of implicit non-null properties based on method call order. not worth changing to appease lint 8 // swiftlint:disable force_unwrapping 9 10 import Foundation 11 12 @objc(EXUpdatesAppLoaderTaskDelegate) 13 public protocol AppLoaderTaskDelegate: AnyObject { 14 /** 15 * This method gives the delegate a backdoor option to ignore the cached update and force 16 * a remote load if it decides the cached update is not runnable. Returning NO from this 17 * callback will force a remote load, overriding the timeout and configuration settings for 18 * whether or not to check for a remote update. Returning YES from this callback will make 19 * AppLoaderTask proceed as usual. 20 */ 21 func appLoaderTask(_: AppLoaderTask, didLoadCachedUpdate update: Update) -> Bool 22 func appLoaderTask(_: AppLoaderTask, didStartLoadingUpdate update: Update) 23 func appLoaderTask(_: AppLoaderTask, didFinishWithLauncher launcher: AppLauncher, isUpToDate: Bool) 24 func appLoaderTask(_: AppLoaderTask, didFinishWithError error: Error) 25 func appLoaderTask( 26 _: AppLoaderTask, 27 didFinishBackgroundUpdateWithStatus status: BackgroundUpdateStatus, 28 update: Update?, 29 error: Error? 30 ) 31 } 32 33 @objc(EXUpdatesBackgroundUpdateStatus) 34 public enum BackgroundUpdateStatus: Int { 35 case error = 0 36 case noUpdateAvailable = 1 37 case updateAvailable = 2 38 } 39 40 /** 41 * Controlling class that handles the complex logic that needs to happen each time the app is cold 42 * booted. From a high level, this class does the following: 43 * 44 * - Immediately starts an instance of EmbeddedAppLoader to load the embedded update into 45 * SQLite. This does nothing if SQLite already has the embedded update or a newer one, but we have 46 * to do this on each cold boot, as we have no way of knowing if a new build was just installed 47 * (which could have a new embedded update). 48 * - If the app is configured for automatic update downloads (most apps), starts a timer based on 49 * the `launchWaitMs` value in UpdatesConfig. 50 * - Again if the app is configured for automatic update downloads, starts an instance of 51 * RemoteAppLoader to check for and download a new update if there is one. 52 * - Once the download succeeds, fails, or the timer runs out (whichever happens first), creates an 53 * instance of AppLauncherWithDatabase and signals that the app is ready to be launched 54 * with the newest update available locally at that time (which may not be the newest update if 55 * the download is still in progress). 56 * - If the download succeeds or fails after this point, fires a callback which causes an event to 57 * be sent to JS. 58 */ 59 @objc(EXUpdatesAppLoaderTask) 60 @objcMembers 61 public final class AppLoaderTask: NSObject { 62 private static let ErrorDomain = "EXUpdatesAppLoaderTask" 63 64 public weak var delegate: AppLoaderTaskDelegate? 65 66 private let config: UpdatesConfig 67 private let database: UpdatesDatabase 68 private let directory: URL 69 private let selectionPolicy: SelectionPolicy 70 private let delegateQueue: DispatchQueue 71 72 private var candidateLauncher: AppLauncher? 73 private var finalizedLauncher: AppLauncher? 74 private var embeddedAppLoader: EmbeddedAppLoader? 75 private var remoteAppLoader: RemoteAppLoader? 76 private let logger: UpdatesLogger 77 78 private var timer: Timer? 79 public private(set) var isRunning: Bool 80 private var isReadyToLaunch: Bool 81 private var isTimerFinished: Bool 82 private var hasLaunched: Bool 83 private var isUpToDate: Bool 84 private let loaderTaskQueue: DispatchQueue 85 86 public required init( 87 withConfig config: UpdatesConfig, 88 database: UpdatesDatabase, 89 directory: URL, 90 selectionPolicy: SelectionPolicy, 91 delegateQueue: DispatchQueue 92 ) { 93 self.config = config 94 self.database = database 95 self.directory = directory 96 self.selectionPolicy = selectionPolicy 97 self.isRunning = false 98 self.isReadyToLaunch = false 99 self.isTimerFinished = false 100 self.hasLaunched = false 101 self.isUpToDate = false 102 self.delegateQueue = delegateQueue 103 self.loaderTaskQueue = DispatchQueue(label: "expo.loader.LoaderTaskQueue") 104 self.logger = UpdatesLogger() 105 } 106 107 public func start() { 108 guard config.isEnabled else { 109 // swiftlint:disable:next line_length 110 let errorMessage = "AppLoaderTask was passed a configuration object with updates disabled. You should load updates from an embedded source rather than calling AppLoaderTask, or enable updates in the configuration." 111 logger.error(message: errorMessage, code: .updateFailedToLoad) 112 delegateQueue.async { 113 self.delegate?.appLoaderTask( 114 self, 115 didFinishWithError: NSError( 116 domain: AppLoaderTask.ErrorDomain, 117 code: 1030, 118 userInfo: [NSLocalizedDescriptionKey: errorMessage] 119 ) 120 ) 121 } 122 return 123 } 124 125 guard config.updateUrl != nil else { 126 // swiftlint:disable:next line_length 127 let errorMessage = "AppLoaderTask was passed a configuration object with a null URL. You must pass a nonnull URL in order to use AppLoaderTask to load updates." 128 logger.error(message: errorMessage, code: .updateFailedToLoad) 129 delegateQueue.async { 130 self.delegate?.appLoaderTask( 131 self, 132 didFinishWithError: NSError( 133 domain: AppLoaderTask.ErrorDomain, 134 code: 1030, 135 userInfo: [NSLocalizedDescriptionKey: errorMessage] 136 ) 137 ) 138 } 139 return 140 } 141 142 isRunning = true 143 144 var shouldCheckForUpdate = UpdatesUtils.shouldCheckForUpdate(withConfig: config) 145 let launchWaitMs = config.launchWaitMs 146 if launchWaitMs == 0 || !shouldCheckForUpdate { 147 isTimerFinished = true 148 } else { 149 let fireDate = Date(timeIntervalSinceNow: Double(launchWaitMs) / 1000) 150 timer = Timer(fireAt: fireDate, interval: 0, target: self, selector: #selector(timerDidFire), userInfo: nil, repeats: false) 151 RunLoop.main.add(timer!, forMode: .default) 152 } 153 154 loadEmbeddedUpdate { 155 self.launch { error, success in 156 if !success { 157 if !shouldCheckForUpdate { 158 self.finish(withError: error) 159 } 160 self.logger.error( 161 message: "Failed to launch embedded or launchable update: \(error?.localizedDescription ?? "")", 162 code: .updateFailedToLoad 163 ) 164 } else { 165 if let delegate = self.delegate, 166 !delegate.appLoaderTask(self, didLoadCachedUpdate: self.candidateLauncher!.launchedUpdate!) { 167 // ignore timer and other settings and force launch a remote update. 168 self.candidateLauncher = nil 169 self.stopTimer() 170 shouldCheckForUpdate = true 171 } else { 172 self.isReadyToLaunch = true 173 self.maybeFinish() 174 } 175 } 176 177 if shouldCheckForUpdate { 178 self.loadRemoteUpdate { remoteError, remoteUpdate in 179 self.handleRemoteUpdateResponseLoaded(remoteUpdate, error: remoteError) 180 } 181 } else { 182 self.isRunning = false 183 self.runReaper() 184 } 185 } 186 } 187 } 188 189 private func finish(withError error: Error?) { 190 dispatchPrecondition(condition: .onQueue(loaderTaskQueue)) 191 192 if hasLaunched { 193 // we've already fired once, don't do it again 194 return 195 } 196 197 hasLaunched = true 198 finalizedLauncher = candidateLauncher 199 200 if let delegate = delegate { 201 delegateQueue.async { 202 if self.isReadyToLaunch && 203 (self.finalizedLauncher!.launchAssetUrl != nil || self.finalizedLauncher!.launchedUpdate!.status == .StatusDevelopment) { 204 delegate.appLoaderTask(self, didFinishWithLauncher: self.finalizedLauncher!, isUpToDate: self.isUpToDate) 205 } else { 206 delegate.appLoaderTask( 207 self, 208 didFinishWithError: error ?? NSError( 209 domain: AppLoaderTask.ErrorDomain, 210 code: 1031, 211 userInfo: [ 212 NSLocalizedDescriptionKey: "AppLoaderTask encountered an unexpected error and could not launch an update." 213 ] 214 ) 215 ) 216 } 217 } 218 } 219 220 stopTimer() 221 } 222 223 private func maybeFinish() { 224 guard isTimerFinished && isReadyToLaunch else { 225 // too early, bail out 226 return 227 } 228 finish(withError: nil) 229 } 230 231 func timerDidFire() { 232 loaderTaskQueue.async { 233 self.isTimerFinished = true 234 self.maybeFinish() 235 } 236 } 237 238 private func stopTimer() { 239 timer.let { it in 240 it.invalidate() 241 timer = nil 242 } 243 isTimerFinished = true 244 } 245 246 private func runReaper() { 247 if let launchedUpdate = finalizedLauncher?.launchedUpdate { 248 UpdatesReaper.reapUnusedUpdates( 249 withConfig: config, 250 database: database, 251 directory: directory, 252 selectionPolicy: selectionPolicy, 253 launchedUpdate: launchedUpdate 254 ) 255 } 256 } 257 258 private func loadEmbeddedUpdate(withCompletion completion: @escaping () -> Void) { 259 AppLauncherWithDatabase.launchableUpdate( 260 withConfig: config, 261 database: database, 262 selectionPolicy: selectionPolicy, 263 completionQueue: loaderTaskQueue 264 ) { error, launchableUpdate in 265 self.database.databaseQueue.async { 266 var manifestFiltersError: Error? 267 var manifestFilters: [String: Any]? 268 do { 269 manifestFilters = try self.database.manifestFilters(withScopeKey: self.config.scopeKey!) 270 } catch { 271 manifestFiltersError = error 272 } 273 274 self.loaderTaskQueue.async { 275 if manifestFiltersError != nil { 276 completion() 277 return 278 } 279 280 if self.config.hasEmbeddedUpdate && self.selectionPolicy.shouldLoadNewUpdate( 281 EmbeddedAppLoader.embeddedManifest(withConfig: self.config, database: self.database), 282 withLaunchedUpdate: launchableUpdate, 283 filters: manifestFilters 284 ) { 285 // launchedUpdate is nil because we don't yet have one, and it doesn't matter as we won't 286 // be sending an HTTP request from EmbeddedAppLoader 287 self.embeddedAppLoader = EmbeddedAppLoader( 288 config: self.config, 289 database: self.database, 290 directory: self.directory, 291 launchedUpdate: nil, 292 completionQueue: self.loaderTaskQueue 293 ) 294 self.embeddedAppLoader!.loadUpdateResponseFromEmbeddedManifest( 295 withCallback: { _ in 296 // we already checked using selection policy, so we don't need to check again 297 return true 298 }, asset: { _, _, _, _ in 299 // do nothing for now 300 }, success: { _ in 301 completion() 302 }, error: { _ in 303 completion() 304 } 305 ) 306 } else { 307 completion() 308 } 309 } 310 } 311 } 312 } 313 314 private func launch(withCompletion completion: @escaping (_ error: Error?, _ success: Bool) -> Void) { 315 let launcher = AppLauncherWithDatabase(config: config, database: database, directory: directory, completionQueue: loaderTaskQueue) 316 candidateLauncher = launcher 317 launcher.launchUpdate(withSelectionPolicy: selectionPolicy, completion: completion) 318 } 319 320 private func loadRemoteUpdate(withCompletion completion: @escaping (_ remoteError: Error?, _ updateResponse: UpdateResponse?) -> Void) { 321 remoteAppLoader = RemoteAppLoader( 322 config: config, 323 database: database, 324 directory: directory, 325 launchedUpdate: candidateLauncher?.launchedUpdate, 326 completionQueue: loaderTaskQueue 327 ) 328 remoteAppLoader!.loadUpdate( 329 fromURL: config.updateUrl! 330 ) { updateResponse in 331 if let updateDirective = updateResponse.directiveUpdateResponsePart?.updateDirective { 332 switch updateDirective { 333 case is NoUpdateAvailableUpdateDirective: 334 self.isUpToDate = true 335 return false 336 case is RollBackToEmbeddedUpdateDirective: 337 self.isUpToDate = true 338 return true 339 default: 340 NSException(name: .internalInconsistencyException, reason: "Unhandled update directive type").raise() 341 return false 342 } 343 } 344 345 guard let update = updateResponse.manifestUpdateResponsePart?.updateManifest else { 346 self.isUpToDate = true 347 return false 348 } 349 350 if self.selectionPolicy.shouldLoadNewUpdate( 351 update, 352 withLaunchedUpdate: self.candidateLauncher?.launchedUpdate, 353 filters: updateResponse.responseHeaderData?.manifestFilters 354 ) { 355 self.isUpToDate = false 356 if let delegate = self.delegate { 357 self.delegateQueue.async { 358 delegate.appLoaderTask(self, didStartLoadingUpdate: update) 359 } 360 } 361 return true 362 } else { 363 self.isUpToDate = true 364 return false 365 } 366 } asset: { _, _, _, _ in 367 // do nothing for now 368 } success: { updateResponse in 369 completion(nil, updateResponse) 370 } error: { error in 371 completion(error, nil) 372 } 373 } 374 375 private func handleRemoteUpdateResponseLoaded(_ updateResponse: UpdateResponse?, error: Error?) { 376 // If the app has not yet been launched (because the timer is still running), 377 // create a new launcher so that we can launch with the newly downloaded update. 378 // Otherwise, we've already launched. Send an event to the notify JS of the new update. 379 380 loaderTaskQueue.async { 381 self.stopTimer() 382 383 let updateBeingLaunched = updateResponse?.manifestUpdateResponsePart?.updateManifest 384 385 // If directive is to roll-back to the embedded update and there is an embedded update, 386 // we need to update embedded update in the DB with the newer commitTime from the message so that 387 // the selection policy will choose it. That way future updates can continue to be applied 388 // over this roll back, but older ones won't. 389 // The embedded update is guaranteed to be in the DB from the earlier [EmbeddedAppLoader] call in this task. 390 if let rollBackDirective = updateResponse?.directiveUpdateResponsePart?.updateDirective as? RollBackToEmbeddedUpdateDirective { 391 self.processRollBackToEmbeddedDirective(rollBackDirective, manifestFilters: updateResponse?.responseHeaderData?.manifestFilters, error: error) 392 } else { 393 self.launchUpdate(updateBeingLaunched, error: error) 394 } 395 } 396 } 397 398 private func processRollBackToEmbeddedDirective(_ updateDirective: RollBackToEmbeddedUpdateDirective, manifestFilters: [String: Any]?, error: Error?) { 399 if !self.config.hasEmbeddedUpdate { 400 launchUpdate(nil, error: error) 401 return 402 } 403 404 guard let embeddedManifest = EmbeddedAppLoader.embeddedManifest(withConfig: self.config, database: self.database) else { 405 launchUpdate(nil, error: error) 406 return 407 } 408 409 if !self.selectionPolicy.shouldLoadRollBackToEmbeddedDirective( 410 updateDirective, 411 withEmbeddedUpdate: embeddedManifest, 412 launchedUpdate: self.candidateLauncher?.launchedUpdate, 413 filters: manifestFilters 414 ) { 415 launchUpdate(nil, error: error) 416 return 417 } 418 419 // update the embedded update commit time in the in-memory embedded update since it is a singleton 420 embeddedManifest.commitTime = updateDirective.commitTime 421 422 self.embeddedAppLoader = EmbeddedAppLoader( 423 config: self.config, 424 database: self.database, 425 directory: self.directory, 426 launchedUpdate: nil, 427 completionQueue: self.loaderTaskQueue 428 ) 429 self.embeddedAppLoader!.loadUpdateResponseFromEmbeddedManifest( 430 withCallback: { _ in 431 return true 432 }, asset: { _, _, _, _ in 433 }, success: { updateResponse in 434 do { 435 let update = updateResponse?.manifestUpdateResponsePart?.updateManifest 436 // do this synchronously as it is needed to launch, and we're already on a background dispatch queue so no UI will be blocked 437 try self.database.databaseQueue.sync { 438 try self.database.setUpdateCommitTime(updateDirective.commitTime, onUpdate: update!) 439 } 440 self.launchUpdate(update, error: error) 441 } catch { 442 self.launchUpdate(nil, error: error) 443 } 444 }, error: { embeddedLoaderError in 445 self.launchUpdate(nil, error: embeddedLoaderError) 446 } 447 ) 448 } 449 450 private func launchUpdate(_ updateBeingLaunched: Update?, error: Error?) { 451 if let updateBeingLaunched = updateBeingLaunched { 452 if !self.hasLaunched { 453 let newLauncher = AppLauncherWithDatabase( 454 config: self.config, 455 database: self.database, 456 directory: self.directory, 457 completionQueue: self.loaderTaskQueue 458 ) 459 newLauncher.launchUpdate(withSelectionPolicy: self.selectionPolicy) { error, success in 460 if success { 461 if !self.hasLaunched { 462 self.candidateLauncher = newLauncher 463 self.isReadyToLaunch = true 464 self.isUpToDate = true 465 self.finish(withError: nil) 466 } 467 } else { 468 self.finish(withError: error) 469 NSLog("Downloaded update but failed to relaunch: %@", error?.localizedDescription ?? "") 470 } 471 self.isRunning = false 472 self.runReaper() 473 } 474 } else { 475 self.didFinishBackgroundUpdate(withStatus: .updateAvailable, update: updateBeingLaunched, error: nil) 476 self.isRunning = false 477 self.runReaper() 478 } 479 } else { 480 // there's no update, so signal we're ready to launch 481 self.finish(withError: error) 482 if let error = error { 483 self.didFinishBackgroundUpdate(withStatus: .error, update: nil, error: error) 484 } else { 485 self.didFinishBackgroundUpdate(withStatus: .noUpdateAvailable, update: nil, error: nil) 486 } 487 self.isRunning = false 488 self.runReaper() 489 } 490 } 491 492 private func didFinishBackgroundUpdate(withStatus status: BackgroundUpdateStatus, update: Update?, error: Error?) { 493 delegate.let { it in 494 delegateQueue.async { 495 it.appLoaderTask(self, didFinishBackgroundUpdateWithStatus: status, update: update, error: error) 496 } 497 } 498 } 499 } 500