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 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 is RollBackToEmbeddedUpdateDirective: 362 self.isUpToDate = false 363 364 if let swiftDelegate = self.swiftDelegate { 365 self.delegateQueue.async { 366 swiftDelegate.appLoaderTask(self, didFinishCheckingForRemoteUpdateWithRemoteCheckResult: RemoteCheckResult.rollBackToEmbedded) 367 } 368 } 369 370 if let delegate = self.delegate { 371 self.delegateQueue.async { 372 delegate.appLoaderTask(self, didStartLoadingUpdate: nil) 373 } 374 } 375 return true 376 default: 377 NSException(name: .internalInconsistencyException, reason: "Unhandled update directive type").raise() 378 return false 379 } 380 } 381 382 guard let update = updateResponse.manifestUpdateResponsePart?.updateManifest else { 383 // No response, so no update available 384 self.isUpToDate = true 385 if let swiftDelegate = self.swiftDelegate { 386 self.delegateQueue.async { 387 swiftDelegate.appLoaderTask(self, didFinishCheckingForRemoteUpdateWithRemoteCheckResult: RemoteCheckResult.noUpdateAvailable) 388 } 389 } 390 return false 391 } 392 393 if self.selectionPolicy.shouldLoadNewUpdate( 394 update, 395 withLaunchedUpdate: self.candidateLauncher?.launchedUpdate, 396 filters: updateResponse.responseHeaderData?.manifestFilters 397 ) { 398 // got a response, and it is new so should be downloaded 399 self.isUpToDate = false 400 if let swiftDelegate = self.swiftDelegate { 401 self.delegateQueue.async { 402 swiftDelegate.appLoaderTask( 403 self, 404 didFinishCheckingForRemoteUpdateWithRemoteCheckResult: RemoteCheckResult.updateAvailable( 405 manifest: update.manifest.rawManifestJSON() 406 ) 407 ) 408 } 409 } 410 411 if let delegate = self.delegate { 412 self.delegateQueue.async { 413 delegate.appLoaderTask(self, didStartLoadingUpdate: update) 414 } 415 } 416 return true 417 } else { 418 // got a response, but we already have it 419 self.isUpToDate = true 420 if let swiftDelegate = self.swiftDelegate { 421 self.delegateQueue.async { 422 swiftDelegate.appLoaderTask(self, didFinishCheckingForRemoteUpdateWithRemoteCheckResult: RemoteCheckResult.noUpdateAvailable) 423 } 424 } 425 return false 426 } 427 } asset: { asset, successfulAssetCount, failedAssetCount, totalAssetCount in 428 if let swiftDelegate = self.swiftDelegate { 429 self.delegateQueue.async { 430 swiftDelegate.appLoaderTask( 431 self, 432 didLoadAsset: asset, 433 successfulAssetCount: successfulAssetCount, 434 failedAssetCount: failedAssetCount, 435 totalAssetCount: totalAssetCount 436 ) 437 } 438 } 439 } success: { updateResponse in 440 completion(nil, updateResponse) 441 } error: { error in 442 if let swiftDelegate = self.swiftDelegate { 443 self.delegateQueue.async { 444 swiftDelegate.appLoaderTask(self, didFinishCheckingForRemoteUpdateWithRemoteCheckResult: RemoteCheckResult.error(error: error)) 445 } 446 } 447 completion(error, nil) 448 } 449 } 450 451 private func handleRemoteUpdateResponseLoaded(_ updateResponse: UpdateResponse?, error: Error?) { 452 // If the app has not yet been launched (because the timer is still running), 453 // create a new launcher so that we can launch with the newly downloaded update. 454 // Otherwise, we've already launched. Send an event to the notify JS of the new update. 455 456 loaderTaskQueue.async { 457 self.stopTimer() 458 459 RemoteAppLoader.processSuccessLoaderResult( 460 config: self.config, 461 database: self.database, 462 selectionPolicy: self.selectionPolicy, 463 launchedUpdate: self.candidateLauncher?.launchedUpdate, 464 directory: self.directory, 465 loaderTaskQueue: self.loaderTaskQueue, 466 updateResponse: updateResponse, 467 priorError: error 468 ) { updateToLaunch, error, _ in 469 self.launchUpdate(updateToLaunch, error: error) 470 } 471 } 472 } 473 474 private func launchUpdate(_ updateBeingLaunched: Update?, error: Error?) { 475 if let updateBeingLaunched = updateBeingLaunched { 476 if !self.hasLaunched { 477 let newLauncher = AppLauncherWithDatabase( 478 config: self.config, 479 database: self.database, 480 directory: self.directory, 481 completionQueue: self.loaderTaskQueue 482 ) 483 newLauncher.launchUpdate(withSelectionPolicy: self.selectionPolicy) { error, success in 484 if success { 485 if !self.hasLaunched { 486 self.candidateLauncher = newLauncher 487 self.isReadyToLaunch = true 488 self.isUpToDate = true 489 self.finish(withError: nil) 490 } 491 } else { 492 self.finish(withError: error) 493 NSLog("Downloaded update but failed to relaunch: %@", error?.localizedDescription ?? "") 494 } 495 self.isRunning = false 496 self.runReaper() 497 } 498 } else { 499 self.didFinishBackgroundUpdate(withStatus: .updateAvailable, update: updateBeingLaunched, error: nil) 500 self.isRunning = false 501 self.runReaper() 502 } 503 } else { 504 // there's no update, so signal we're ready to launch 505 self.finish(withError: error) 506 if let error = error { 507 self.didFinishBackgroundUpdate(withStatus: .error, update: nil, error: error) 508 } else { 509 self.didFinishBackgroundUpdate(withStatus: .noUpdateAvailable, update: nil, error: nil) 510 } 511 self.isRunning = false 512 self.runReaper() 513 } 514 } 515 516 private func didFinishBackgroundUpdate(withStatus status: BackgroundUpdateStatus, update: Update?, error: Error?) { 517 delegate.let { it in 518 delegateQueue.async { 519 it.appLoaderTask(self, didFinishBackgroundUpdateWithStatus: status, update: update, error: error) 520 } 521 } 522 } 523 } 524 525 // swiftlint:enable closure_body_length 526 // swiftlint:enable force_unwrapping 527 // swiftlint:enable superfluous_else 528 // swiftlint:enable cyclomatic_complexity 529