1// Copyright 2020-present 650 Industries. All rights reserved. 2 3#import "EXAppFetcher.h" 4#import "EXAppLoaderExpoUpdates.h" 5#import "EXClientReleaseType.h" 6#import "EXEnvironment.h" 7#import "EXErrorRecoveryManager.h" 8#import "EXFileDownloader.h" 9#import "EXKernel.h" 10#import "EXKernelLinkingManager.h" 11#import "EXManifestResource.h" 12#import "EXSession.h" 13#import "EXUpdatesDatabaseManager.h" 14#import "EXVersions.h" 15 16#import <EXUpdates/EXUpdatesAppLauncherNoDatabase.h> 17#import <EXUpdates/EXUpdatesAppLoaderTask.h> 18#import <EXUpdates/EXUpdatesConfig.h> 19#import <EXUpdates/EXUpdatesDatabase.h> 20#import <EXUpdates/EXUpdatesFileDownloader.h> 21#import <EXUpdates/EXUpdatesReaper.h> 22#import <EXUpdates/EXUpdatesSelectionPolicyFilterAware.h> 23#import <EXUpdates/EXUpdatesUtils.h> 24#import <React/RCTUtils.h> 25#import <sys/utsname.h> 26 27NS_ASSUME_NONNULL_BEGIN 28 29@interface EXAppLoaderExpoUpdates () 30 31@property (nonatomic, strong, nullable) NSURL *manifestUrl; 32@property (nonatomic, strong, nullable) NSURL *httpManifestUrl; 33 34@property (nonatomic, strong, nullable) NSDictionary *confirmedManifest; 35@property (nonatomic, strong, nullable) NSDictionary *optimisticManifest; 36@property (nonatomic, strong, nullable) NSData *bundle; 37@property (nonatomic, assign) EXAppLoaderRemoteUpdateStatus remoteUpdateStatus; 38@property (nonatomic, assign) BOOL shouldShowRemoteUpdateStatus; 39@property (nonatomic, assign) BOOL isUpToDate; 40 41@property (nonatomic, strong, nullable) NSError *error; 42 43@property (nonatomic, assign) BOOL shouldUseCacheOnly; 44 45@property (nonatomic, strong) dispatch_queue_t appLoaderQueue; 46 47@property (nonatomic, nullable) EXUpdatesConfig *config; 48@property (nonatomic, nullable) id<EXUpdatesSelectionPolicy> selectionPolicy; 49@property (nonatomic, nullable) id<EXUpdatesAppLauncher> appLauncher; 50@property (nonatomic, assign) BOOL isEmergencyLaunch; 51 52@end 53 54@implementation EXAppLoaderExpoUpdates 55 56@synthesize manifestUrl = _manifestUrl; 57@synthesize bundle = _bundle; 58@synthesize remoteUpdateStatus = _remoteUpdateStatus; 59@synthesize shouldShowRemoteUpdateStatus = _shouldShowRemoteUpdateStatus; 60@synthesize config = _config; 61@synthesize selectionPolicy = _selectionPolicy; 62@synthesize appLauncher = _appLauncher; 63@synthesize isEmergencyLaunch = _isEmergencyLaunch; 64@synthesize isUpToDate = _isUpToDate; 65 66- (instancetype)initWithManifestUrl:(NSURL *)url 67{ 68 if (self = [super init]) { 69 _manifestUrl = url; 70 _httpManifestUrl = [EXAppLoaderExpoUpdates _httpUrlFromManifestUrl:_manifestUrl]; 71 _appLoaderQueue = dispatch_queue_create("host.exp.exponent.LoaderQueue", DISPATCH_QUEUE_SERIAL); 72 } 73 return self; 74} 75 76#pragma mark - getters and lifecycle 77 78- (void)_reset 79{ 80 _confirmedManifest = nil; 81 _optimisticManifest = nil; 82 _bundle = nil; 83 _config = nil; 84 _selectionPolicy = nil; 85 _appLauncher = nil; 86 _error = nil; 87 _shouldUseCacheOnly = NO; 88 _isEmergencyLaunch = NO; 89 _remoteUpdateStatus = kEXAppLoaderRemoteUpdateStatusChecking; 90 _shouldShowRemoteUpdateStatus = YES; 91 _isUpToDate = NO; 92} 93 94- (EXAppLoaderStatus)status 95{ 96 if (_error) { 97 return kEXAppLoaderStatusError; 98 } else if (_bundle) { 99 return kEXAppLoaderStatusHasManifestAndBundle; 100 } else if (_optimisticManifest) { 101 return kEXAppLoaderStatusHasManifest; 102 } 103 return kEXAppLoaderStatusNew; 104} 105 106- (nullable NSDictionary *)manifest 107{ 108 if (_confirmedManifest) { 109 return _confirmedManifest; 110 } 111 if (_optimisticManifest) { 112 return _optimisticManifest; 113 } 114 return nil; 115} 116 117- (nullable NSData *)bundle 118{ 119 if (_bundle) { 120 return _bundle; 121 } 122 return nil; 123} 124 125- (void)forceBundleReload 126{ 127 if (self.status == kEXAppLoaderStatusNew) { 128 @throw [NSException exceptionWithName:NSInternalInconsistencyException 129 reason:@"Tried to load a bundle from an AppLoader with no manifest." 130 userInfo:@{}]; 131 } 132 NSAssert([self supportsBundleReload], @"Tried to force a bundle reload on a non-development bundle"); 133 [self _loadDevelopmentJavaScriptResource]; 134} 135 136- (BOOL)supportsBundleReload 137{ 138 if (_optimisticManifest) { 139 return [[self class] areDevToolsEnabledWithManifest:_optimisticManifest]; 140 } 141 return NO; 142} 143 144#pragma mark - public 145 146- (void)request 147{ 148 [self _reset]; 149 if (_manifestUrl) { 150 [self _beginRequest]; 151 } 152} 153 154- (void)requestFromCache 155{ 156 [self _reset]; 157 _shouldUseCacheOnly = YES; 158 if (_manifestUrl) { 159 [self _beginRequest]; 160 } 161} 162 163#pragma mark - EXUpdatesAppLoaderTaskDelegate 164 165- (BOOL)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didLoadCachedUpdate:(EXUpdatesUpdate *)update 166{ 167 [self _setShouldShowRemoteUpdateStatus:update.rawManifest]; 168 // if cached manifest was dev mode, or a previous run of this app failed due to a loading error, we want to make sure to check for remote updates 169 if ([[self class] areDevToolsEnabledWithManifest:update.rawManifest] || [[EXKernel sharedInstance].serviceRegistry.errorRecoveryManager experienceIdIsRecoveringFromError:[EXAppFetcher experienceIdWithManifest:update.rawManifest]]) { 170 return NO; 171 } 172 return YES; 173} 174 175- (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didStartLoadingUpdate:(EXUpdatesUpdate *)update 176{ 177 // expo-cli does not always respect our SDK version headers and respond with a compatible update or an error 178 // so we need to check the compatibility here 179 EXManifestResource *manifestResource = [[EXManifestResource alloc] initWithManifestUrl:_httpManifestUrl originalUrl:_manifestUrl]; 180 NSError *manifestCompatibilityError = [manifestResource verifyManifestSdkVersion:update.rawManifest]; 181 if (manifestCompatibilityError) { 182 _error = manifestCompatibilityError; 183 if (self.delegate) { 184 [self.delegate appLoader:self didFailWithError:_error]; 185 return; 186 } 187 } 188 189 _remoteUpdateStatus = kEXAppLoaderRemoteUpdateStatusDownloading; 190 [self _setShouldShowRemoteUpdateStatus:update.rawManifest]; 191 [self _setOptimisticManifest:[self _processManifest:update.rawManifest]]; 192} 193 194- (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didFinishWithLauncher:(id<EXUpdatesAppLauncher>)launcher isUpToDate:(BOOL)isUpToDate 195{ 196 if (_error) { 197 return; 198 } 199 200 if (!_optimisticManifest) { 201 [self _setOptimisticManifest:[self _processManifest:launcher.launchedUpdate.rawManifest]]; 202 } 203 _isUpToDate = isUpToDate; 204 if ([[self class] areDevToolsEnabledWithManifest:launcher.launchedUpdate.rawManifest]) { 205 // in dev mode, we need to set an optimistic manifest but nothing else 206 return; 207 } 208 _confirmedManifest = [self _processManifest:launcher.launchedUpdate.rawManifest]; 209 _bundle = [NSData dataWithContentsOfURL:launcher.launchAssetUrl]; 210 _appLauncher = launcher; 211 if (self.delegate) { 212 [self.delegate appLoader:self didFinishLoadingManifest:_confirmedManifest bundle:_bundle]; 213 } 214} 215 216- (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didFinishWithError:(NSError *)error 217{ 218 if ([EXEnvironment sharedEnvironment].isDetached) { 219 _isEmergencyLaunch = YES; 220 [self _launchWithNoDatabaseAndError:error]; 221 } else if (!_error) { 222 _error = error; 223 224 // if the error payload conforms to the error protocol, we can parse it and display 225 // a slightly nicer error message to the user 226 id errorJson = [NSJSONSerialization JSONObjectWithData:[error.localizedDescription dataUsingEncoding:NSUTF8StringEncoding] options:kNilOptions error:nil]; 227 if (errorJson && [errorJson isKindOfClass:[NSDictionary class]]) { 228 EXManifestResource *manifestResource = [[EXManifestResource alloc] initWithManifestUrl:_httpManifestUrl originalUrl:_manifestUrl]; 229 _error = [manifestResource formatError:[NSError errorWithDomain:EXNetworkErrorDomain code:error.code userInfo:errorJson]]; 230 } 231 232 if (self.delegate) { 233 [self.delegate appLoader:self didFailWithError:_error]; 234 } 235 } 236} 237 238- (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didFinishBackgroundUpdateWithStatus:(EXUpdatesBackgroundUpdateStatus)status update:(nullable EXUpdatesUpdate *)update error:(nullable NSError *)error 239{ 240 if (self.delegate) { 241 [self.delegate appLoader:self didResolveUpdatedBundleWithManifest:update.rawManifest isFromCache:(status == EXUpdatesBackgroundUpdateStatusNoUpdateAvailable) error:error]; 242 } 243} 244 245#pragma mark - internal 246 247+ (NSURL *)_httpUrlFromManifestUrl:(NSURL *)url 248{ 249 NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:YES]; 250 // if scheme is exps or https, use https. Else default to http 251 if (components.scheme && ([components.scheme isEqualToString:@"exps"] || [components.scheme isEqualToString:@"https"])){ 252 components.scheme = @"https"; 253 } else { 254 components.scheme = @"http"; 255 } 256 NSMutableString *path = [((components.path) ? components.path : @"") mutableCopy]; 257 path = [[EXKernelLinkingManager stringByRemovingDeepLink:path] mutableCopy]; 258 components.path = path; 259 return [components URL]; 260} 261 262- (BOOL)_initializeDatabase 263{ 264 EXUpdatesDatabaseManager *updatesDatabaseManager = [EXKernel sharedInstance].serviceRegistry.updatesDatabaseManager; 265 BOOL success = updatesDatabaseManager.isDatabaseOpen; 266 if (!updatesDatabaseManager.isDatabaseOpen) { 267 success = [updatesDatabaseManager openDatabase]; 268 } 269 270 if (!success) { 271 _error = updatesDatabaseManager.error; 272 if (self.delegate) { 273 [self.delegate appLoader:self didFailWithError:_error]; 274 } 275 return NO; 276 } else { 277 return YES; 278 } 279} 280 281- (void)_beginRequest 282{ 283 if (![self _initializeDatabase]) { 284 return; 285 } 286 [self _startLoaderTask]; 287} 288 289- (void)_startLoaderTask 290{ 291 BOOL shouldCheckOnLaunch; 292 NSNumber *launchWaitMs; 293 if (_shouldUseCacheOnly) { 294 shouldCheckOnLaunch = NO; 295 launchWaitMs = @(0); 296 } else { 297 if ([EXEnvironment sharedEnvironment].isDetached) { 298 shouldCheckOnLaunch = [EXEnvironment sharedEnvironment].updatesCheckAutomatically; 299 launchWaitMs = [EXEnvironment sharedEnvironment].updatesFallbackToCacheTimeout; 300 } else { 301 shouldCheckOnLaunch = YES; 302 launchWaitMs = @(60000); 303 } 304 } 305 306 NSURL *httpManifestUrl = [[self class] _httpUrlFromManifestUrl:_manifestUrl]; 307 308 _config = [EXUpdatesConfig configWithDictionary:@{ 309 @"EXUpdatesURL": httpManifestUrl.absoluteString, 310 @"EXUpdatesSDKVersion": [self _sdkVersions], 311 @"EXUpdatesScopeKey": httpManifestUrl.absoluteString, 312 @"EXUpdatesReleaseChannel": [EXEnvironment sharedEnvironment].releaseChannel, 313 @"EXUpdatesHasEmbeddedUpdate": @([EXEnvironment sharedEnvironment].isDetached), 314 @"EXUpdatesEnabled": @([EXEnvironment sharedEnvironment].areRemoteUpdatesEnabled), 315 @"EXUpdatesLaunchWaitMs": launchWaitMs, 316 @"EXUpdatesCheckOnLaunch": shouldCheckOnLaunch ? @"ALWAYS" : @"NEVER", 317 @"EXUpdatesExpectsSignedManifest": @YES, 318 @"EXUpdatesRequestHeaders": [self _requestHeaders] 319 }]; 320 321 if (![EXEnvironment sharedEnvironment].areRemoteUpdatesEnabled) { 322 [self _launchWithNoDatabaseAndError:nil]; 323 return; 324 } 325 326 EXUpdatesDatabaseManager *updatesDatabaseManager = [EXKernel sharedInstance].serviceRegistry.updatesDatabaseManager; 327 328 NSMutableArray *sdkVersions = [[EXVersions sharedInstance].versions[@"sdkVersions"] ?: @[[EXVersions sharedInstance].temporarySdkVersion] mutableCopy]; 329 [sdkVersions addObject:@"UNVERSIONED"]; 330 _selectionPolicy = [[EXUpdatesSelectionPolicyFilterAware alloc] initWithRuntimeVersions:sdkVersions]; 331 332 EXUpdatesAppLoaderTask *loaderTask = [[EXUpdatesAppLoaderTask alloc] initWithConfig:_config 333 database:updatesDatabaseManager.database 334 directory:updatesDatabaseManager.updatesDirectory 335 selectionPolicy:_selectionPolicy 336 delegateQueue:_appLoaderQueue]; 337 loaderTask.delegate = self; 338 [loaderTask start]; 339} 340 341- (void)_launchWithNoDatabaseAndError:(nullable NSError *)error 342{ 343 EXUpdatesAppLauncherNoDatabase *appLauncher = [[EXUpdatesAppLauncherNoDatabase alloc] init]; 344 [appLauncher launchUpdateWithConfig:_config fatalError:error]; 345 346 _confirmedManifest = [self _processManifest:appLauncher.launchedUpdate.rawManifest]; 347 _optimisticManifest = _confirmedManifest; 348 _bundle = [NSData dataWithContentsOfURL:appLauncher.launchAssetUrl]; 349 _appLauncher = appLauncher; 350 if (self.delegate) { 351 [self.delegate appLoader:self didLoadOptimisticManifest:_confirmedManifest]; 352 [self.delegate appLoader:self didFinishLoadingManifest:_confirmedManifest bundle:_bundle]; 353 } 354} 355 356- (void)_runReaper 357{ 358 if (_appLauncher.launchedUpdate) { 359 EXUpdatesDatabaseManager *updatesDatabaseManager = [EXKernel sharedInstance].serviceRegistry.updatesDatabaseManager; 360 [EXUpdatesReaper reapUnusedUpdatesWithConfig:_config 361 database:updatesDatabaseManager.database 362 directory:updatesDatabaseManager.updatesDirectory 363 selectionPolicy:_selectionPolicy 364 launchedUpdate:_appLauncher.launchedUpdate]; 365 } 366} 367 368- (void)_setOptimisticManifest:(NSDictionary *)manifest 369{ 370 _optimisticManifest = manifest; 371 if (self.delegate) { 372 [self.delegate appLoader:self didLoadOptimisticManifest:_optimisticManifest]; 373 } 374} 375 376- (void)_setShouldShowRemoteUpdateStatus:(NSDictionary *)manifest 377{ 378 // we don't want to show the cached experience alert when Updates.reloadAsync() is called 379 if (_shouldUseCacheOnly) { 380 _shouldShowRemoteUpdateStatus = NO; 381 return; 382 } 383 384 if (manifest) { 385 NSDictionary *developmentClientSettings = manifest[@"developmentClient"]; 386 if (developmentClientSettings && [developmentClientSettings isKindOfClass:[NSDictionary class]]) { 387 id silentLaunch = developmentClientSettings[@"silentLaunch"]; 388 if (silentLaunch && [@(YES) isEqual:silentLaunch]) { 389 _shouldShowRemoteUpdateStatus = NO; 390 return; 391 } 392 } 393 394 // we want to avoid showing the status for older snack SDK versions, too 395 // we make our best guess based on the manifest fields 396 // TODO: remove this after SDK 38 is phased out 397 NSString *sdkVersion = manifest[@"sdkVersion"]; 398 NSString *bundleUrl = manifest[@"bundleUrl"]; 399 if (![@"UNVERSIONED" isEqual:sdkVersion] && 400 sdkVersion.integerValue < 39 && 401 [@"snack" isEqual:manifest[@"slug"]] && 402 bundleUrl && [bundleUrl isKindOfClass:[NSString class]] && 403 [bundleUrl hasPrefix:@"https://d1wp6m56sqw74a.cloudfront.net/%40exponent%2Fsnack"]) { 404 _shouldShowRemoteUpdateStatus = NO; 405 return; 406 } 407 } 408 _shouldShowRemoteUpdateStatus = YES; 409} 410 411- (void)_loadDevelopmentJavaScriptResource 412{ 413 EXAppFetcher *appFetcher = [[EXAppFetcher alloc] initWithAppLoader:self]; 414 [appFetcher fetchJSBundleWithManifest:self.optimisticManifest cacheBehavior:EXCachedResourceNoCache timeoutInterval:kEXJSBundleTimeout progress:^(EXLoadingProgress *progress) { 415 if (self.delegate) { 416 [self.delegate appLoader:self didLoadBundleWithProgress:progress]; 417 } 418 } success:^(NSData *bundle) { 419 self.isUpToDate = YES; 420 self.bundle = bundle; 421 if (self.delegate) { 422 [self.delegate appLoader:self didFinishLoadingManifest:self.optimisticManifest bundle:self.bundle]; 423 } 424 } error:^(NSError *error) { 425 self.error = error; 426 if (self.delegate) { 427 [self.delegate appLoader:self didFailWithError:error]; 428 } 429 }]; 430} 431 432# pragma mark - manifest processing 433 434- (NSDictionary *)_processManifest:(NSDictionary *)manifest 435{ 436 NSMutableDictionary *mutableManifest = [manifest mutableCopy]; 437 if (!mutableManifest[@"isVerified"] && ![EXKernelLinkingManager isExpoHostedUrl:_httpManifestUrl] && !EXEnvironment.sharedEnvironment.isDetached){ 438 // the manifest id determines the namespace/experience id an app is sandboxed with 439 // if manifest is hosted by third parties, we sandbox it with the hostname to avoid clobbering exp.host namespaces 440 // for https urls, sandboxed id is of form quinlanj.github.io/myProj-myApp 441 // for http urls, sandboxed id is of form UNVERIFIED-quinlanj.github.io/myProj-myApp 442 NSString *securityPrefix = [_httpManifestUrl.scheme isEqualToString:@"https"] ? @"" : @"UNVERIFIED-"; 443 NSString *slugSuffix = manifest[@"slug"] ? [@"-" stringByAppendingString:manifest[@"slug"]]: @""; 444 mutableManifest[@"id"] = [NSString stringWithFormat:@"%@%@%@%@", securityPrefix, _httpManifestUrl.host, _httpManifestUrl.path ?: @"", slugSuffix]; 445 mutableManifest[@"isVerified"] = @(YES); 446 } 447 if (!mutableManifest[@"isVerified"]) { 448 mutableManifest[@"isVerified"] = @(NO); 449 } 450 451 if (![mutableManifest[@"isVerified"] boolValue] && (EXEnvironment.sharedEnvironment.isManifestVerificationBypassed || [self _isAnonymousExperience:manifest])) { 452 mutableManifest[@"isVerified"] = @(YES); 453 } 454 455 return [mutableManifest copy]; 456} 457 458- (BOOL)_isAnonymousExperience:(NSDictionary *)manifest 459{ 460 NSString *experienceId = manifest[@"id"]; 461 return experienceId != nil && [experienceId hasPrefix:@"@anonymous/"]; 462} 463 464+ (BOOL)areDevToolsEnabledWithManifest:(NSDictionary *)manifest 465{ 466 NSDictionary *manifestDeveloperConfig = manifest[@"developer"]; 467 BOOL isDeployedFromTool = (manifestDeveloperConfig && manifestDeveloperConfig[@"tool"] != nil); 468 return (isDeployedFromTool); 469} 470 471#pragma mark - headers 472 473- (NSDictionary *)_requestHeaders 474{ 475 NSDictionary *requestHeaders = @{ 476 @"Exponent-SDK-Version": [self _sdkVersions], 477 @"Exponent-Accept-Signature": @"true", 478 @"Exponent-Platform": @"ios", 479 @"Exponent-Version": [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"], 480 @"Expo-Client-Environment": [self _clientEnvironment], 481 @"Expo-Updates-Environment": [self _clientEnvironment], 482 @"User-Agent": [self _userAgentString], 483 @"Expo-Client-Release-Type": [EXClientReleaseType clientReleaseType] 484 }; 485 486 NSString *sessionSecret = [[EXSession sharedInstance] sessionSecret]; 487 if (sessionSecret) { 488 NSMutableDictionary *requestHeadersMutable = [requestHeaders mutableCopy]; 489 requestHeadersMutable[@"Expo-Session"] = sessionSecret; 490 requestHeaders = requestHeadersMutable; 491 } 492 493 return requestHeaders; 494} 495 496- (NSString *)_userAgentString 497{ 498 struct utsname systemInfo; 499 uname(&systemInfo); 500 NSString *deviceModel = [NSString stringWithCString:systemInfo.machine encoding:NSUTF8StringEncoding]; 501 return [NSString stringWithFormat:@"Exponent/%@ (%@; %@ %@; Scale/%.2f; %@)", 502 [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"], 503 deviceModel, 504 [UIDevice currentDevice].systemName, 505 [UIDevice currentDevice].systemVersion, 506 [UIScreen mainScreen].scale, 507 [NSLocale autoupdatingCurrentLocale].localeIdentifier]; 508} 509 510- (NSString *)_clientEnvironment 511{ 512 if ([EXEnvironment sharedEnvironment].isDetached) { 513 return @"STANDALONE"; 514 } else { 515 return @"EXPO_DEVICE"; 516#if TARGET_IPHONE_SIMULATOR 517 return @"EXPO_SIMULATOR"; 518#endif 519 } 520} 521 522- (NSString *)_sdkVersions 523{ 524 NSArray *versionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"]; 525 if (versionsAvailable) { 526 return [versionsAvailable componentsJoinedByString:@","]; 527 } else { 528 return [EXVersions sharedInstance].temporarySdkVersion; 529 } 530} 531 532@end 533 534NS_ASSUME_NONNULL_END 535