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