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