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