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