// Copyright 2020-present 650 Industries. All rights reserved. #import "EXAppFetcher.h" #import "EXAppLoaderExpoUpdates.h" #import "EXClientReleaseType.h" #import "EXEnvironment.h" #import "EXErrorRecoveryManager.h" #import "EXFileDownloader.h" #import "EXKernel.h" #import "EXKernelLinkingManager.h" #import "EXManifestResource.h" #import "EXSession.h" #import "EXUpdatesDatabaseManager.h" #import "EXVersions.h" #if defined(EX_DETACHED) #import "ExpoKit-Swift.h" #else #import "Expo_Go-Swift.h" #endif // defined(EX_DETACHED) #import #import @import EXManifests; @import EXUpdates; NS_ASSUME_NONNULL_BEGIN @interface EXAppLoaderExpoUpdates () @property (nonatomic, strong, nullable) NSURL *manifestUrl; @property (nonatomic, strong, nullable) NSURL *httpManifestUrl; @property (nonatomic, strong, nullable) EXManifestsManifest *confirmedManifest; @property (nonatomic, strong, nullable) EXManifestsManifest *optimisticManifest; @property (nonatomic, strong, nullable) NSData *bundle; @property (nonatomic, assign) EXAppLoaderRemoteUpdateStatus remoteUpdateStatus; @property (nonatomic, assign) BOOL shouldShowRemoteUpdateStatus; @property (nonatomic, assign) BOOL isUpToDate; /** * Stateful variable to let us prevent multiple simultaneous fetches from the development server. * This can happen when reloading a bundle with remote debugging enabled; * RN requests the bundle multiple times for some reason. */ @property (nonatomic, assign) BOOL isLoadingDevelopmentJavaScriptResource; @property (nonatomic, strong, nullable) NSError *error; @property (nonatomic, assign) BOOL shouldUseCacheOnly; @property (nonatomic, strong) dispatch_queue_t appLoaderQueue; @property (nonatomic, nullable) EXUpdatesConfig *config; @property (nonatomic, nullable) EXUpdatesSelectionPolicy *selectionPolicy; @property (nonatomic, nullable) id appLauncher; @property (nonatomic, assign) BOOL isEmergencyLaunch; @end /** * Entry point to expo-updates in Expo Go and legacy standalone builds. Fulfills many of the * purposes of EXUpdatesAppController along with serving as an interface to the rest of the ExpoKit * kernel. * * Dynamically generates a configuration object with the correct scope key, and then, like * EXUpdatesAppController, delegates to an instance of EXUpdatesAppLoaderTask to start the process * of loading and launching an update, and responds appropriately depending on the callbacks that * are invoked. * * Multiple instances of EXAppLoaderExpoUpdates can exist at a time; instances are retained by * EXKernelAppRegistry (through EXKernelAppRecord). */ @implementation EXAppLoaderExpoUpdates @synthesize manifestUrl = _manifestUrl; @synthesize bundle = _bundle; @synthesize remoteUpdateStatus = _remoteUpdateStatus; @synthesize shouldShowRemoteUpdateStatus = _shouldShowRemoteUpdateStatus; @synthesize config = _config; @synthesize selectionPolicy = _selectionPolicy; @synthesize appLauncher = _appLauncher; @synthesize isEmergencyLaunch = _isEmergencyLaunch; @synthesize isUpToDate = _isUpToDate; - (instancetype)initWithManifestUrl:(NSURL *)url { if (self = [super init]) { _manifestUrl = url; _httpManifestUrl = [EXAppLoaderExpoUpdates _httpUrlFromManifestUrl:_manifestUrl]; _appLoaderQueue = dispatch_queue_create("host.exp.exponent.LoaderQueue", DISPATCH_QUEUE_SERIAL); } return self; } #pragma mark - getters and lifecycle - (void)_reset { _confirmedManifest = nil; _optimisticManifest = nil; _bundle = nil; _config = nil; _selectionPolicy = nil; _appLauncher = nil; _error = nil; _shouldUseCacheOnly = NO; _isEmergencyLaunch = NO; _remoteUpdateStatus = kEXAppLoaderRemoteUpdateStatusChecking; _shouldShowRemoteUpdateStatus = YES; _isUpToDate = NO; _isLoadingDevelopmentJavaScriptResource = NO; } - (EXAppLoaderStatus)status { if (_error) { return kEXAppLoaderStatusError; } else if (_bundle) { return kEXAppLoaderStatusHasManifestAndBundle; } else if (_optimisticManifest) { return kEXAppLoaderStatusHasManifest; } return kEXAppLoaderStatusNew; } - (nullable EXManifestsManifest *)manifest { if (_confirmedManifest) { return _confirmedManifest; } if (_optimisticManifest) { return _optimisticManifest; } return nil; } - (nullable NSData *)bundle { if (_bundle) { return _bundle; } return nil; } - (void)forceBundleReload { if (self.status == kEXAppLoaderStatusNew) { @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Tried to load a bundle from an AppLoader with no manifest." userInfo:@{}]; } NSAssert([self supportsBundleReload], @"Tried to force a bundle reload on a non-development bundle"); if (self.isLoadingDevelopmentJavaScriptResource) { // prevent multiple simultaneous fetches from the development server. // this can happen when reloading a bundle with remote debugging enabled; // RN requests the bundle multiple times for some reason. // TODO: fix inside of RN return; } [self _loadDevelopmentJavaScriptResource]; } - (BOOL)supportsBundleReload { if (_optimisticManifest) { return _optimisticManifest.isUsingDeveloperTool; } return NO; } #pragma mark - public - (void)request { [self _reset]; if (_manifestUrl) { [self _beginRequest]; } } - (void)requestFromCache { [self _reset]; _shouldUseCacheOnly = YES; if (_manifestUrl) { [self _beginRequest]; } } #pragma mark - EXUpdatesAppLoaderTaskDelegate - (BOOL)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didLoadCachedUpdate:(EXUpdatesUpdate *)update { [self _setShouldShowRemoteUpdateStatus:update.manifest]; // 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 if (update.manifest.isUsingDeveloperTool || [[EXKernel sharedInstance].serviceRegistry.errorRecoveryManager scopeKeyIsRecoveringFromError:update.manifest.scopeKey]) { return NO; } return YES; } - (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didStartLoadingUpdate:(nullable EXUpdatesUpdate *)update { // expo-cli does not always respect our SDK version headers and respond with a compatible update or an error // so we need to check the compatibility here EXManifestResource *manifestResource = [[EXManifestResource alloc] initWithManifestUrl:_httpManifestUrl originalUrl:_manifestUrl]; NSError *manifestCompatibilityError = [manifestResource verifyManifestSdkVersion:update.manifest]; if (manifestCompatibilityError) { _error = manifestCompatibilityError; if (self.delegate) { [self.delegate appLoader:self didFailWithError:_error]; return; } } _remoteUpdateStatus = kEXAppLoaderRemoteUpdateStatusDownloading; [self _setShouldShowRemoteUpdateStatus:update.manifest]; EXManifestsManifest *processedManifest = [self _processManifest:update.manifest]; if (processedManifest == nil) { return; } [self _setOptimisticManifest:processedManifest]; } - (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didFinishWithLauncher:(id)launcher isUpToDate:(BOOL)isUpToDate { if (_error) { return; } if (!_optimisticManifest) { EXManifestsManifest *processedManifest = [self _processManifest:launcher.launchedUpdate.manifest]; if (processedManifest == nil) { return; } [self _setOptimisticManifest:processedManifest]; } _isUpToDate = isUpToDate; if (launcher.launchedUpdate.manifest.isUsingDeveloperTool) { // in dev mode, we need to set an optimistic manifest but nothing else return; } _confirmedManifest = [self _processManifest:launcher.launchedUpdate.manifest]; if (_confirmedManifest == nil) { return; } _bundle = [NSData dataWithContentsOfURL:launcher.launchAssetUrl]; _appLauncher = launcher; if (self.delegate) { [self.delegate appLoader:self didFinishLoadingManifest:_confirmedManifest bundle:_bundle]; } } - (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didFinishWithError:(NSError *)error { if ([EXEnvironment sharedEnvironment].isDetached) { _isEmergencyLaunch = YES; [self _launchWithNoDatabaseAndError:error]; } else if (!_error) { _error = error; // if the error payload conforms to the error protocol, we can parse it and display // a slightly nicer error message to the user id errorJson = [NSJSONSerialization JSONObjectWithData:[error.localizedDescription dataUsingEncoding:NSUTF8StringEncoding] options:kNilOptions error:nil]; if (errorJson && [errorJson isKindOfClass:[NSDictionary class]]) { EXManifestResource *manifestResource = [[EXManifestResource alloc] initWithManifestUrl:_httpManifestUrl originalUrl:_manifestUrl]; _error = [manifestResource formatError:[NSError errorWithDomain:EXNetworkErrorDomain code:error.code userInfo:errorJson]]; } if (self.delegate) { [self.delegate appLoader:self didFailWithError:_error]; } } } - (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didFinishBackgroundUpdateWithStatus:(EXUpdatesBackgroundUpdateStatus)status update:(nullable EXUpdatesUpdate *)update error:(nullable NSError *)error { if (self.delegate) { [self.delegate appLoader:self didResolveUpdatedBundleWithManifest:update.manifest isFromCache:(status == EXUpdatesBackgroundUpdateStatusNoUpdateAvailable) error:error]; } } #pragma mark - internal + (NSURL *)_httpUrlFromManifestUrl:(NSURL *)url { NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:YES]; // if scheme is exps or https, use https. Else default to http if (components.scheme && ([components.scheme isEqualToString:@"exps"] || [components.scheme isEqualToString:@"https"])){ components.scheme = @"https"; } else { components.scheme = @"http"; } NSMutableString *path = [((components.path) ? components.path : @"") mutableCopy]; path = [[EXKernelLinkingManager stringByRemovingDeepLink:path] mutableCopy]; components.path = path; return [components URL]; } - (BOOL)_initializeDatabase { EXUpdatesDatabaseManager *updatesDatabaseManager = [EXKernel sharedInstance].serviceRegistry.updatesDatabaseManager; BOOL success = updatesDatabaseManager.isDatabaseOpen; if (!updatesDatabaseManager.isDatabaseOpen) { success = [updatesDatabaseManager openDatabase]; } if (!success) { _error = updatesDatabaseManager.error; if (self.delegate) { [self.delegate appLoader:self didFailWithError:_error]; } return NO; } else { return YES; } } - (void)_beginRequest { if (![self _initializeDatabase]) { return; } [self _startLoaderTask]; } - (void)_startLoaderTask { BOOL shouldCheckOnLaunch; NSNumber *launchWaitMs; if (_shouldUseCacheOnly) { shouldCheckOnLaunch = NO; launchWaitMs = @(0); } else { if ([EXEnvironment sharedEnvironment].isDetached) { shouldCheckOnLaunch = [EXEnvironment sharedEnvironment].updatesCheckAutomatically; launchWaitMs = [EXEnvironment sharedEnvironment].updatesFallbackToCacheTimeout; } else { shouldCheckOnLaunch = YES; launchWaitMs = @(60000); } } NSURL *httpManifestUrl = [[self class] _httpUrlFromManifestUrl:_manifestUrl]; NSString *releaseChannel = [EXEnvironment sharedEnvironment].releaseChannel; if (![EXEnvironment sharedEnvironment].isDetached) { // in Expo Go, the release channel can change at runtime depending on the URL we load NSURLComponents *manifestUrlComponents = [NSURLComponents componentsWithURL:httpManifestUrl resolvingAgainstBaseURL:YES]; releaseChannel = [EXKernelLinkingManager releaseChannelWithUrlComponents:manifestUrlComponents]; } NSMutableDictionary *updatesConfig = [[NSMutableDictionary alloc] initWithDictionary:@{ EXUpdatesConfig.EXUpdatesConfigUpdateUrlKey: httpManifestUrl.absoluteString, EXUpdatesConfig.EXUpdatesConfigSDKVersionKey: [self _sdkVersions], EXUpdatesConfig.EXUpdatesConfigScopeKeyKey: httpManifestUrl.absoluteString, EXUpdatesConfig.EXUpdatesConfigReleaseChannelKey: releaseChannel, EXUpdatesConfig.EXUpdatesConfigHasEmbeddedUpdateKey: @([EXEnvironment sharedEnvironment].isDetached), EXUpdatesConfig.EXUpdatesConfigEnabledKey: @([EXEnvironment sharedEnvironment].areRemoteUpdatesEnabled), EXUpdatesConfig.EXUpdatesConfigLaunchWaitMsKey: launchWaitMs, EXUpdatesConfig.EXUpdatesConfigCheckOnLaunchKey: shouldCheckOnLaunch ? EXUpdatesConfig.EXUpdatesConfigCheckOnLaunchValueAlways : EXUpdatesConfig.EXUpdatesConfigCheckOnLaunchValueNever, EXUpdatesConfig.EXUpdatesConfigExpectsSignedManifestKey: @YES, EXUpdatesConfig.EXUpdatesConfigRequestHeadersKey: [self _requestHeaders] }]; if (!EXEnvironment.sharedEnvironment.isDetached) { // in Expo Go, embed the Expo Root Certificate and get the Expo Go intermediate certificate and development certificates // from the multipart manifest response part NSString *expoRootCertPath = [[NSBundle mainBundle] pathForResource:@"expo-root" ofType:@"pem"]; if (!expoRootCertPath) { @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"No expo-root certificate found in bundle" userInfo:@{}]; } NSError *error; NSString *expoRootCert = [NSString stringWithContentsOfFile:expoRootCertPath encoding:NSUTF8StringEncoding error:&error]; if (error) { expoRootCert = nil; } if (!expoRootCert) { @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Error reading expo-root certificate from bundle" userInfo:@{ @"underlyingError": error.localizedDescription }]; } updatesConfig[EXUpdatesConfig.EXUpdatesConfigCodeSigningCertificateKey] = expoRootCert; updatesConfig[EXUpdatesConfig.EXUpdatesConfigCodeSigningMetadataKey] = @{ @"keyid": @"expo-root", @"alg": @"rsa-v1_5-sha256", }; updatesConfig[EXUpdatesConfig.EXUpdatesConfigCodeSigningIncludeManifestResponseCertificateChainKey] = @YES; updatesConfig[EXUpdatesConfig.EXUpdatesConfigCodeSigningAllowUnsignedManifestsKey] = @YES; // in Expo Go, ignore directives in manifest responses and require a manifest. the current directives // (no update available, roll back) don't have any practical use outside of standalone apps updatesConfig[EXUpdatesConfig.EXUpdatesConfigEnableExpoUpdatesProtocolV0CompatibilityModeKey] = @YES; } _config = [EXUpdatesConfig configFromDictionary:updatesConfig]; if (![EXEnvironment sharedEnvironment].areRemoteUpdatesEnabled) { [self _launchWithNoDatabaseAndError:nil]; return; } EXUpdatesDatabaseManager *updatesDatabaseManager = [EXKernel sharedInstance].serviceRegistry.updatesDatabaseManager; NSMutableArray *sdkVersions = [[EXVersions sharedInstance].versions[@"sdkVersions"] ?: @[[EXVersions sharedInstance].temporarySdkVersion] mutableCopy]; [sdkVersions addObject:@"UNVERSIONED"]; NSMutableArray *sdkVersionRuntimeVersions = [[NSMutableArray alloc] initWithCapacity:sdkVersions.count]; for (NSString *sdkVersion in sdkVersions) { [sdkVersionRuntimeVersions addObject:[NSString stringWithFormat:@"exposdk:%@", sdkVersion]]; } [sdkVersionRuntimeVersions addObject:@"exposdk:UNVERSIONED"]; [sdkVersions addObjectsFromArray:sdkVersionRuntimeVersions]; _selectionPolicy = [[EXUpdatesSelectionPolicy alloc] initWithLauncherSelectionPolicy:[[EXExpoGoLauncherSelectionPolicyFilterAware alloc] initWithSdkVersions:sdkVersions] loaderSelectionPolicy:[EXUpdatesLoaderSelectionPolicyFilterAware new] reaperSelectionPolicy:[EXUpdatesReaperSelectionPolicyDevelopmentClient new]]; EXUpdatesAppLoaderTask *loaderTask = [[EXUpdatesAppLoaderTask alloc] initWithConfig:_config database:updatesDatabaseManager.database directory:updatesDatabaseManager.updatesDirectory selectionPolicy:_selectionPolicy delegateQueue:_appLoaderQueue]; loaderTask.delegate = self; [loaderTask start]; } - (void)_launchWithNoDatabaseAndError:(nullable NSError *)error { EXUpdatesAppLauncherNoDatabase *appLauncher = [[EXUpdatesAppLauncherNoDatabase alloc] init]; [appLauncher launchUpdateWithConfig:_config]; _confirmedManifest = [self _processManifest:appLauncher.launchedUpdate.manifest]; if (_confirmedManifest == nil) { return; } _optimisticManifest = _confirmedManifest; _bundle = [NSData dataWithContentsOfURL:appLauncher.launchAssetUrl]; _appLauncher = appLauncher; if (self.delegate) { [self.delegate appLoader:self didLoadOptimisticManifest:_confirmedManifest]; [self.delegate appLoader:self didFinishLoadingManifest:_confirmedManifest bundle:_bundle]; } [[EXUpdatesErrorRecovery new] writeErrorOrExceptionToLog:error]; } - (void)_runReaper { if (_appLauncher.launchedUpdate) { EXUpdatesDatabaseManager *updatesDatabaseManager = [EXKernel sharedInstance].serviceRegistry.updatesDatabaseManager; [EXUpdatesReaper reapUnusedUpdatesWithConfig:_config database:updatesDatabaseManager.database directory:updatesDatabaseManager.updatesDirectory selectionPolicy:_selectionPolicy launchedUpdate:_appLauncher.launchedUpdate]; } } - (void)_setOptimisticManifest:(EXManifestsManifest *)manifest { _optimisticManifest = manifest; if (self.delegate) { [self.delegate appLoader:self didLoadOptimisticManifest:_optimisticManifest]; } } - (void)_setShouldShowRemoteUpdateStatus:(EXManifestsManifest *)manifest { // we don't want to show the cached experience alert when Updates.reloadAsync() is called if (_shouldUseCacheOnly) { _shouldShowRemoteUpdateStatus = NO; return; } if (manifest) { if (manifest.isDevelopmentSilentLaunch) { _shouldShowRemoteUpdateStatus = NO; return; } } _shouldShowRemoteUpdateStatus = YES; } - (void)_loadDevelopmentJavaScriptResource { _isLoadingDevelopmentJavaScriptResource = YES; EXAppFetcher *appFetcher = [[EXAppFetcher alloc] initWithAppLoader:self]; [appFetcher fetchJSBundleWithManifest:self.optimisticManifest cacheBehavior:EXCachedResourceNoCache timeoutInterval:kEXJSBundleTimeout progress:^(EXLoadingProgress *progress) { if (self.delegate) { [self.delegate appLoader:self didLoadBundleWithProgress:progress]; } } success:^(NSData *bundle) { self.isUpToDate = YES; self.bundle = bundle; self.isLoadingDevelopmentJavaScriptResource = NO; if (self.delegate) { [self.delegate appLoader:self didFinishLoadingManifest:self.optimisticManifest bundle:self.bundle]; } } error:^(NSError *error) { self.error = error; self.isLoadingDevelopmentJavaScriptResource = NO; if (self.delegate) { [self.delegate appLoader:self didFailWithError:error]; } }]; } # pragma mark - manifest processing - (nullable EXManifestsManifest *)_processManifest:(EXManifestsManifest *)manifest { @try { NSMutableDictionary *mutableManifest = [manifest.rawManifestJSON mutableCopy]; // If legacy manifest is not yet verified, served by a third party, not standalone, and not an anonymous experience // then scope it locally by using the manifest URL as a scopeKey (id) and consider it verified. if (!mutableManifest[@"isVerified"] && !EXEnvironment.sharedEnvironment.isDetached && ![EXKernelLinkingManager isExpoHostedUrl:_httpManifestUrl] && ![EXAppLoaderExpoUpdates _isAnonymousExperience:manifest] && [manifest isKindOfClass:[EXManifestsLegacyManifest class]]) { // the manifest id in a legacy manifest determines the namespace/experience id an app is sandboxed with // if manifest is hosted by third parties, we sandbox it with the hostname to avoid clobbering exp.host namespaces // for https urls, sandboxed id is of form quinlanj.github.io/myProj-myApp // for http urls, sandboxed id is of form UNVERIFIED-quinlanj.github.io/myProj-myApp NSString *securityPrefix = [_httpManifestUrl.scheme isEqualToString:@"https"] ? @"" : @"UNVERIFIED-"; NSString *slugSuffix = manifest.slug ? [@"-" stringByAppendingString:manifest.slug]: @""; mutableManifest[@"id"] = [NSString stringWithFormat:@"%@%@%@%@", securityPrefix, _httpManifestUrl.host, _httpManifestUrl.path ?: @"", slugSuffix]; mutableManifest[@"isVerified"] = @(YES); } // set verified to false by default if (!mutableManifest[@"isVerified"]) { mutableManifest[@"isVerified"] = @(NO); } // if the app bypassed verification or the manifest is scoped to a random anonymous // scope key, automatically verify it if (![mutableManifest[@"isVerified"] boolValue] && (EXEnvironment.sharedEnvironment.isManifestVerificationBypassed || [EXAppLoaderExpoUpdates _isAnonymousExperience:manifest])) { mutableManifest[@"isVerified"] = @(YES); } // when the manifest is not verified at this point, make the scope key a salted and hashed version of the claimed scope key if (![mutableManifest[@"isVerified"] boolValue]) { NSString *currentScopeKeyAndSaltToHash = [NSString stringWithFormat:@"unverified-%@", manifest.scopeKey]; NSString *currentScopeKeyHash = [currentScopeKeyAndSaltToHash hexEncodedSHA256]; NSString *newScopeKey = [NSString stringWithFormat:@"%@-%@", currentScopeKeyAndSaltToHash, currentScopeKeyHash]; if ([manifest isKindOfClass:EXManifestsNewManifest.class]) { NSDictionary *extra = mutableManifest[@"extra"] ?: @{}; NSMutableDictionary *mutableExtra = [extra mutableCopy]; mutableExtra[@"scopeKey"] = newScopeKey; mutableManifest[@"extra"] = mutableExtra; } else { mutableManifest[@"scopeKey"] = newScopeKey; mutableManifest[@"id"] = newScopeKey; } } return [EXManifestsManifestFactory manifestForManifestJSON:[mutableManifest copy]]; } @catch (NSException *exception) { // Catch parsing errors related to invalid or unexpected manifest properties. For example, if a manifest // is missing the `id` property, it'll raise an exception which we want to forward to the user so they // can adjust their manifest JSON accordingly. _error = [NSError errorWithDomain:@"ExpoParsingManifest" code:1025 userInfo:@{NSLocalizedDescriptionKey: [@"Failed to parse manifest JSON: " stringByAppendingString:exception.reason] }]; if (self.delegate) { [self.delegate appLoader:self didFailWithError:_error]; } } return nil; } + (BOOL)_isAnonymousExperience:(EXManifestsManifest *)manifest { return [manifest.scopeKey hasPrefix:@"@anonymous/"]; } #pragma mark - headers - (NSDictionary *)_requestHeaders { NSDictionary *requestHeaders = @{ @"Exponent-SDK-Version": [self _sdkVersions], @"Exponent-Accept-Signature": @"true", @"Exponent-Platform": @"ios", @"Exponent-Version": [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"], @"Expo-Client-Environment": [self _clientEnvironment], @"Expo-Updates-Environment": [self _clientEnvironment], @"User-Agent": [self _userAgentString], @"Expo-Client-Release-Type": [EXClientReleaseType clientReleaseType] }; NSString *sessionSecret = [[EXSession sharedInstance] sessionSecret]; if (sessionSecret) { NSMutableDictionary *requestHeadersMutable = [requestHeaders mutableCopy]; requestHeadersMutable[@"Expo-Session"] = sessionSecret; requestHeaders = requestHeadersMutable; } return requestHeaders; } - (NSString *)_userAgentString { struct utsname systemInfo; uname(&systemInfo); NSString *deviceModel = [NSString stringWithCString:systemInfo.machine encoding:NSUTF8StringEncoding]; return [NSString stringWithFormat:@"Exponent/%@ (%@; %@ %@; Scale/%.2f; %@)", [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"], deviceModel, [UIDevice currentDevice].systemName, [UIDevice currentDevice].systemVersion, [UIScreen mainScreen].scale, [NSLocale autoupdatingCurrentLocale].localeIdentifier]; } - (NSString *)_clientEnvironment { if ([EXEnvironment sharedEnvironment].isDetached) { return @"STANDALONE"; } else { return @"EXPO_DEVICE"; #if TARGET_IPHONE_SIMULATOR return @"EXPO_SIMULATOR"; #endif } } - (NSString *)_sdkVersions { NSArray *versionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"]; if (versionsAvailable) { return [versionsAvailable componentsJoinedByString:@","]; } else { return [EXVersions sharedInstance].temporarySdkVersion; } } @end NS_ASSUME_NONNULL_END