// Copyright © 2020 650 Industries. All rights reserved. #import #import #import #import #import #import NS_ASSUME_NONNULL_BEGIN static NSString * const EXUpdatesAppLoaderTaskErrorDomain = @"EXUpdatesAppLoaderTask"; @interface EXUpdatesAppLoaderTask () @property (nonatomic, strong) EXUpdatesConfig *config; @property (nonatomic, strong) EXUpdatesDatabase *database; @property (nonatomic, strong) NSURL *directory; @property (nonatomic, strong) id selectionPolicy; @property (nonatomic, strong) dispatch_queue_t delegateQueue; @property (nonatomic, strong) id candidateLauncher; @property (nonatomic, strong) id finalizedLauncher; @property (nonatomic, strong) EXUpdatesEmbeddedAppLoader *embeddedAppLoader; @property (nonatomic, strong) EXUpdatesRemoteAppLoader *remoteAppLoader; @property (nonatomic, strong) NSTimer *timer; @property (nonatomic, assign) BOOL isReadyToLaunch; @property (nonatomic, assign) BOOL isTimerFinished; @property (nonatomic, assign) BOOL hasLaunched; @property (nonatomic, assign) BOOL isUpToDate; @property (nonatomic, strong) dispatch_queue_t loaderTaskQueue; @end @implementation EXUpdatesAppLoaderTask - (instancetype)initWithConfig:(EXUpdatesConfig *)config database:(EXUpdatesDatabase *)database directory:(NSURL *)directory selectionPolicy:(id)selectionPolicy delegateQueue:(dispatch_queue_t)delegateQueue { if (self = [super init]) { _config = config; _database = database; _directory = directory; _selectionPolicy = selectionPolicy; _isUpToDate = NO; _delegateQueue = delegateQueue; _loaderTaskQueue = dispatch_queue_create("expo.loader.LoaderTaskQueue", DISPATCH_QUEUE_SERIAL); } return self; } - (void)start { if (!_config.isEnabled) { dispatch_async(_delegateQueue, ^{ [self->_delegate appLoaderTask:self didFinishWithError:[NSError errorWithDomain:EXUpdatesAppLoaderTaskErrorDomain code:1030 userInfo:@{ NSLocalizedDescriptionKey: @"EXUpdatesAppLoaderTask was passed a configuration object with updates disabled. You should load updates from an embedded source rather than calling EXUpdatesAppLoaderTask, or enable updates in the configuration." }]]; }); return; } if (!_config.updateUrl) { dispatch_async(_delegateQueue, ^{ [self->_delegate appLoaderTask:self didFinishWithError:[NSError errorWithDomain:EXUpdatesAppLoaderTaskErrorDomain code:1030 userInfo:@{ NSLocalizedDescriptionKey: @"EXUpdatesAppLoaderTask was passed a configuration object with a null URL. You must pass a nonnull URL in order to use EXUpdatesAppLoaderTask to load updates." }]]; }); return; } if (!_directory) { dispatch_async(_delegateQueue, ^{ [self->_delegate appLoaderTask:self didFinishWithError:[NSError errorWithDomain:EXUpdatesAppLoaderTaskErrorDomain code:1030 userInfo:@{ NSLocalizedDescriptionKey: @"EXUpdatesAppLoaderTask directory must be nonnull." }]]; }); return; } __block BOOL shouldCheckForUpdate = [EXUpdatesUtils shouldCheckForUpdateWithConfig:_config]; NSNumber *launchWaitMs = _config.launchWaitMs; if ([launchWaitMs isEqualToNumber:@(0)] || !shouldCheckForUpdate) { self->_isTimerFinished = YES; } else { NSDate *fireDate = [NSDate dateWithTimeIntervalSinceNow:[launchWaitMs doubleValue] / 1000]; self->_timer = [[NSTimer alloc] initWithFireDate:fireDate interval:0 target:self selector:@selector(_timerDidFire) userInfo:nil repeats:NO]; [[NSRunLoop mainRunLoop] addTimer:self->_timer forMode:NSDefaultRunLoopMode]; } [self _loadEmbeddedUpdateWithCompletion:^{ [self _launchWithCompletion:^(NSError * _Nullable error, BOOL success) { if (!success) { if (!shouldCheckForUpdate){ [self _finishWithError:error]; } NSLog(@"Failed to launch embedded or launchable update: %@", error.localizedDescription); } else { if (self->_delegate && ![self->_delegate appLoaderTask:self didLoadCachedUpdate:self->_candidateLauncher.launchedUpdate]) { // ignore timer and other settings and force launch a remote update. self->_candidateLauncher = nil; [self _stopTimer]; shouldCheckForUpdate = YES; } else { self->_isReadyToLaunch = YES; [self _maybeFinish]; } } if (shouldCheckForUpdate) { [self _loadRemoteUpdateWithCompletion:^(NSError * _Nullable error, EXUpdatesUpdate * _Nullable update) { [self _handleRemoteUpdateLoaded:update error:error]; }]; } else { [self _runReaper]; } }]; }]; } - (void)_finishWithError:(nullable NSError *)error { dispatch_assert_queue(_loaderTaskQueue); if (_hasLaunched) { // we've already fired once, don't do it again return; } _hasLaunched = YES; _finalizedLauncher = _candidateLauncher; if (_delegate) { dispatch_async(_delegateQueue, ^{ if (self->_isReadyToLaunch && (self->_finalizedLauncher.launchAssetUrl || self->_finalizedLauncher.launchedUpdate.status == EXUpdatesUpdateStatusDevelopment)) { [self->_delegate appLoaderTask:self didFinishWithLauncher:self->_finalizedLauncher isUpToDate:self->_isUpToDate]; } else { [self->_delegate appLoaderTask:self didFinishWithError:error ?: [NSError errorWithDomain:EXUpdatesAppLoaderTaskErrorDomain code:1031 userInfo:@{ NSLocalizedDescriptionKey: @"EXUpdatesAppLoaderTask encountered an unexpected error and could not launch an update." }]]; } }); } [self _stopTimer]; } - (void)_maybeFinish { if (!_isTimerFinished || !_isReadyToLaunch) { // too early, bail out return; } [self _finishWithError:nil]; } - (void)_timerDidFire { dispatch_async(_loaderTaskQueue, ^{ self->_isTimerFinished = YES; [self _maybeFinish]; }); } - (void)_stopTimer { if (_timer) { [_timer invalidate]; _timer = nil; } _isTimerFinished = YES; } - (void)_runReaper { if (_finalizedLauncher.launchedUpdate) { [EXUpdatesReaper reapUnusedUpdatesWithConfig:_config database:_database directory:_directory selectionPolicy:_selectionPolicy launchedUpdate:_finalizedLauncher.launchedUpdate]; } } - (void)_loadEmbeddedUpdateWithCompletion:(void (^)(void))completion { [EXUpdatesAppLauncherWithDatabase launchableUpdateWithConfig:_config database:_database selectionPolicy:_selectionPolicy completion:^(NSError * _Nullable error, EXUpdatesUpdate * _Nullable launchableUpdate) { if (self->_config.hasEmbeddedUpdate && [self->_selectionPolicy shouldLoadNewUpdate:[EXUpdatesEmbeddedAppLoader embeddedManifestWithConfig:self->_config database:self->_database] withLaunchedUpdate:launchableUpdate]) { self->_embeddedAppLoader = [[EXUpdatesEmbeddedAppLoader alloc] initWithConfig:self->_config database:self->_database directory:self->_directory completionQueue:self->_loaderTaskQueue]; [self->_embeddedAppLoader loadUpdateFromEmbeddedManifestWithCallback:^BOOL(EXUpdatesUpdate * _Nonnull update) { // we already checked using selection policy, so we don't need to check again return YES; } success:^(EXUpdatesUpdate * _Nullable update) { completion(); } error:^(NSError * _Nonnull error) { completion(); }]; } else { completion(); } } completionQueue:_loaderTaskQueue]; } - (void)_launchWithCompletion:(void (^)(NSError * _Nullable error, BOOL success))completion { EXUpdatesAppLauncherWithDatabase *launcher = [[EXUpdatesAppLauncherWithDatabase alloc] initWithConfig:_config database:_database directory:_directory completionQueue:_loaderTaskQueue]; _candidateLauncher = launcher; [launcher launchUpdateWithSelectionPolicy:_selectionPolicy completion:completion]; } - (void)_loadRemoteUpdateWithCompletion:(void (^)(NSError * _Nullable error, EXUpdatesUpdate * _Nullable update))completion { _remoteAppLoader = [[EXUpdatesRemoteAppLoader alloc] initWithConfig:_config database:_database directory:_directory completionQueue:_loaderTaskQueue]; [_remoteAppLoader loadUpdateFromUrl:_config.updateUrl onManifest:^BOOL(EXUpdatesUpdate * _Nonnull update) { if ([self->_selectionPolicy shouldLoadNewUpdate:update withLaunchedUpdate:self->_candidateLauncher.launchedUpdate]) { self->_isUpToDate = NO; if (self->_delegate) { dispatch_async(self->_delegateQueue, ^{ [self->_delegate appLoaderTask:self didStartLoadingUpdate:update]; }); } return YES; } else { self->_isUpToDate = YES; return NO; } } success:^(EXUpdatesUpdate * _Nullable update) { completion(nil, update); } error:^(NSError *error) { completion(error, nil); }]; } - (void)_handleRemoteUpdateLoaded:(nullable EXUpdatesUpdate *)update error:(nullable NSError *)error { // If the app has not yet been launched (because the timer is still running), // create a new launcher so that we can launch with the newly downloaded update. // Otherwise, we've already launched. Send an event to the notify JS of the new update. dispatch_async(_loaderTaskQueue, ^{ [self _stopTimer]; if (update) { if (!self->_hasLaunched) { EXUpdatesAppLauncherWithDatabase *newLauncher = [[EXUpdatesAppLauncherWithDatabase alloc] initWithConfig:self->_config database:self->_database directory:self->_directory completionQueue:self->_loaderTaskQueue]; [newLauncher launchUpdateWithSelectionPolicy:self->_selectionPolicy completion:^(NSError * _Nullable error, BOOL success) { if (success) { if (!self->_hasLaunched) { self->_candidateLauncher = newLauncher; self->_isReadyToLaunch = YES; self->_isUpToDate = YES; [self _finishWithError:nil]; } } else { [self _finishWithError:error]; NSLog(@"Downloaded update but failed to relaunch: %@", error.localizedDescription); } [self _runReaper]; }]; } else { [self _didFinishBackgroundUpdateWithStatus:EXUpdatesBackgroundUpdateStatusUpdateAvailable manifest:update error:nil]; [self _runReaper]; } } else { // there's no update, so signal we're ready to launch [self _finishWithError:error]; if (error) { [self _didFinishBackgroundUpdateWithStatus:EXUpdatesBackgroundUpdateStatusError manifest:nil error:error]; } else { [self _didFinishBackgroundUpdateWithStatus:EXUpdatesBackgroundUpdateStatusNoUpdateAvailable manifest:nil error:nil]; } [self _runReaper]; } }); } - (void)_didFinishBackgroundUpdateWithStatus:(EXUpdatesBackgroundUpdateStatus)status manifest:(nullable EXUpdatesUpdate *)manifest error:(nullable NSError *)error { if (_delegate) { dispatch_async(_delegateQueue, ^{ [self->_delegate appLoaderTask:self didFinishBackgroundUpdateWithStatus:status update:manifest error:error]; }); } } @end NS_ASSUME_NONNULL_END