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