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