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/EXUpdatesSelectionPolicyNewest.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  _remoteUpdateStatus = kEXAppLoaderRemoteUpdateStatusDownloading;
178  [self _setShouldShowRemoteUpdateStatus:update.rawManifest];
179  [self _setOptimisticManifest:[self _processManifest:update.rawManifest]];
180}
181
182- (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didFinishWithLauncher:(id<EXUpdatesAppLauncher>)launcher isUpToDate:(BOOL)isUpToDate
183{
184  if (!_optimisticManifest) {
185    [self _setOptimisticManifest:[self _processManifest:launcher.launchedUpdate.rawManifest]];
186  }
187  _isUpToDate = isUpToDate;
188  if ([[self class] areDevToolsEnabledWithManifest:launcher.launchedUpdate.rawManifest]) {
189    // in dev mode, we need to set an optimistic manifest but nothing else
190    return;
191  }
192  _confirmedManifest = [self _processManifest:launcher.launchedUpdate.rawManifest];
193  _bundle = [NSData dataWithContentsOfURL:launcher.launchAssetUrl];
194  _appLauncher = launcher;
195  if (self.delegate) {
196    [self.delegate appLoader:self didFinishLoadingManifest:_confirmedManifest bundle:_bundle];
197  }
198}
199
200- (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didFinishWithError:(NSError *)error
201{
202  if ([EXEnvironment sharedEnvironment].isDetached) {
203    _isEmergencyLaunch = YES;
204    [self _launchWithNoDatabaseAndError:error];
205  } else {
206    _error = error;
207
208    // if the error payload conforms to the error protocol, we can parse it and display
209    // a slightly nicer error message to the user
210    id errorJson = [NSJSONSerialization JSONObjectWithData:[error.localizedDescription dataUsingEncoding:NSUTF8StringEncoding] options:kNilOptions error:nil];
211    if (errorJson && [errorJson isKindOfClass:[NSDictionary class]]) {
212      EXManifestResource *manifestResource = [[EXManifestResource alloc] initWithManifestUrl:_httpManifestUrl originalUrl:_manifestUrl];
213      _error = [manifestResource formatError:[NSError errorWithDomain:EXNetworkErrorDomain code:error.code userInfo:errorJson]];
214    }
215
216    if (self.delegate) {
217      [self.delegate appLoader:self didFailWithError:_error];
218    }
219  }
220}
221
222- (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didFinishBackgroundUpdateWithStatus:(EXUpdatesBackgroundUpdateStatus)status update:(nullable EXUpdatesUpdate *)update error:(nullable NSError *)error
223{
224  if (self.delegate) {
225    [self.delegate appLoader:self didResolveUpdatedBundleWithManifest:update.rawManifest isFromCache:(status == EXUpdatesBackgroundUpdateStatusNoUpdateAvailable) error:error];
226  }
227}
228
229#pragma mark - internal
230
231+ (NSURL *)_httpUrlFromManifestUrl:(NSURL *)url
232{
233  NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:YES];
234  // if scheme is exps or https, use https. Else default to http
235  if (components.scheme && ([components.scheme isEqualToString:@"exps"] || [components.scheme isEqualToString:@"https"])){
236    components.scheme = @"https";
237  } else {
238    components.scheme = @"http";
239  }
240  NSMutableString *path = [((components.path) ? components.path : @"") mutableCopy];
241  path = [[EXKernelLinkingManager stringByRemovingDeepLink:path] mutableCopy];
242  components.path = path;
243  return [components URL];
244}
245
246- (BOOL)_initializeDatabase
247{
248  EXUpdatesDatabaseManager *updatesDatabaseManager = [EXKernel sharedInstance].serviceRegistry.updatesDatabaseManager;
249  BOOL success = updatesDatabaseManager.isDatabaseOpen;
250  if (!updatesDatabaseManager.isDatabaseOpen) {
251    success = [updatesDatabaseManager openDatabase];
252  }
253
254  if (!success) {
255    _error = updatesDatabaseManager.error;
256    if (self.delegate) {
257      [self.delegate appLoader:self didFailWithError:_error];
258    }
259    return NO;
260  } else {
261    return YES;
262  }
263}
264
265- (void)_beginRequest
266{
267  if (![self _initializeDatabase]) {
268    return;
269  }
270  [self _startLoaderTask];
271}
272
273- (void)_startLoaderTask
274{
275  if (![EXEnvironment sharedEnvironment].areRemoteUpdatesEnabled) {
276    [self _launchWithNoDatabaseAndError:nil];
277    return;
278  }
279
280  BOOL shouldCheckOnLaunch;
281  NSNumber *launchWaitMs;
282  if (_shouldUseCacheOnly) {
283    shouldCheckOnLaunch = NO;
284    launchWaitMs = @(0);
285  } else {
286    if ([EXEnvironment sharedEnvironment].isDetached) {
287      shouldCheckOnLaunch = [EXEnvironment sharedEnvironment].updatesCheckAutomatically;
288      launchWaitMs = [EXEnvironment sharedEnvironment].updatesFallbackToCacheTimeout;
289    } else {
290      shouldCheckOnLaunch = YES;
291      launchWaitMs = @(60000);
292    }
293  }
294
295  _config = [EXUpdatesConfig configWithDictionary:@{
296    @"EXUpdatesURL": [[self class] _httpUrlFromManifestUrl:_manifestUrl].absoluteString,
297    @"EXUpdatesSDKVersion": [self _sdkVersions],
298    @"EXUpdatesScopeKey": _manifestUrl.absoluteString,
299    @"EXUpdatesHasEmbeddedUpdate": @([EXEnvironment sharedEnvironment].isDetached),
300    @"EXUpdatesEnabled": @([EXEnvironment sharedEnvironment].areRemoteUpdatesEnabled),
301    @"EXUpdatesLaunchWaitMs": launchWaitMs,
302    @"EXUpdatesCheckOnLaunch": shouldCheckOnLaunch ? @"ALWAYS" : @"NEVER",
303    @"EXUpdatesRequestHeaders": [self _requestHeaders]
304  }];
305
306  EXUpdatesDatabaseManager *updatesDatabaseManager = [EXKernel sharedInstance].serviceRegistry.updatesDatabaseManager;
307
308  NSMutableArray *sdkVersions = [[EXVersions sharedInstance].versions[@"sdkVersions"] ?: @[[EXVersions sharedInstance].temporarySdkVersion] mutableCopy];
309  [sdkVersions addObject:@"UNVERSIONED"];
310  _selectionPolicy = [[EXUpdatesSelectionPolicyNewest alloc] initWithRuntimeVersions:sdkVersions];
311
312  EXUpdatesAppLoaderTask *loaderTask = [[EXUpdatesAppLoaderTask alloc] initWithConfig:_config
313                                                                             database:updatesDatabaseManager.database
314                                                                            directory:updatesDatabaseManager.updatesDirectory
315                                                                      selectionPolicy:_selectionPolicy
316                                                                        delegateQueue:_appLoaderQueue];
317  loaderTask.delegate = self;
318  [loaderTask start];
319}
320
321- (void)_launchWithNoDatabaseAndError:(nullable NSError *)error
322{
323  EXUpdatesAppLauncherNoDatabase *appLauncher = [[EXUpdatesAppLauncherNoDatabase alloc] init];
324  [appLauncher launchUpdateWithConfig:_config fatalError:error];
325
326  _confirmedManifest = [self _processManifest:appLauncher.launchedUpdate.rawManifest];
327  _optimisticManifest = _confirmedManifest;
328  _bundle = [NSData dataWithContentsOfURL:appLauncher.launchAssetUrl];
329  _appLauncher = appLauncher;
330  if (self.delegate) {
331    [self.delegate appLoader:self didLoadOptimisticManifest:_confirmedManifest];
332    [self.delegate appLoader:self didFinishLoadingManifest:_confirmedManifest bundle:_bundle];
333  }
334}
335
336- (void)_runReaper
337{
338  if (_appLauncher.launchedUpdate) {
339    EXUpdatesDatabaseManager *updatesDatabaseManager = [EXKernel sharedInstance].serviceRegistry.updatesDatabaseManager;
340    [EXUpdatesReaper reapUnusedUpdatesWithConfig:_config
341                                        database:updatesDatabaseManager.database
342                                       directory:updatesDatabaseManager.updatesDirectory
343                                 selectionPolicy:_selectionPolicy
344                                  launchedUpdate:_appLauncher.launchedUpdate];
345  }
346}
347
348- (void)_setOptimisticManifest:(NSDictionary *)manifest
349{
350  _optimisticManifest = manifest;
351  if (self.delegate) {
352    [self.delegate appLoader:self didLoadOptimisticManifest:_optimisticManifest];
353  }
354}
355
356- (void)_setShouldShowRemoteUpdateStatus:(NSDictionary *)manifest
357{
358  // we don't want to show the cached experience alert when Updates.reloadAsync() is called
359  if (_shouldUseCacheOnly) {
360    _shouldShowRemoteUpdateStatus = NO;
361    return;
362  }
363
364  if (manifest) {
365    NSDictionary *developmentClientSettings = manifest[@"developmentClient"];
366    if (developmentClientSettings && [developmentClientSettings isKindOfClass:[NSDictionary class]]) {
367      id silentLaunch = developmentClientSettings[@"silentLaunch"];
368      if (silentLaunch && [@(YES) isEqual:silentLaunch]) {
369        _shouldShowRemoteUpdateStatus = NO;
370        return;
371      }
372    }
373
374    // we want to avoid showing the status for older snack SDK versions, too
375    // we make our best guess based on the manifest fields
376    // TODO: remove this after SDK 38 is phased out
377    NSString *sdkVersion = manifest[@"sdkVersion"];
378    NSString *bundleUrl = manifest[@"bundleUrl"];
379    if (![@"UNVERSIONED" isEqual:sdkVersion] &&
380        sdkVersion.integerValue < 39 &&
381        [@"snack" isEqual:manifest[@"slug"]] &&
382        bundleUrl && [bundleUrl isKindOfClass:[NSString class]] &&
383        [bundleUrl hasPrefix:@"https://d1wp6m56sqw74a.cloudfront.net/%40exponent%2Fsnack"]) {
384      _shouldShowRemoteUpdateStatus = NO;
385      return;
386    }
387  }
388  _shouldShowRemoteUpdateStatus = YES;
389}
390
391- (void)_loadDevelopmentJavaScriptResource
392{
393  EXAppFetcher *appFetcher = [[EXAppFetcher alloc] initWithAppLoader:self];
394  [appFetcher fetchJSBundleWithManifest:self.optimisticManifest cacheBehavior:EXCachedResourceNoCache timeoutInterval:kEXJSBundleTimeout progress:^(EXLoadingProgress *progress) {
395    if (self.delegate) {
396      [self.delegate appLoader:self didLoadBundleWithProgress:progress];
397    }
398  } success:^(NSData *bundle) {
399    self.bundle = bundle;
400    if (self.delegate) {
401      [self.delegate appLoader:self didFinishLoadingManifest:self.optimisticManifest bundle:self.bundle];
402    }
403  } error:^(NSError *error) {
404    self.error = error;
405    if (self.delegate) {
406      [self.delegate appLoader:self didFailWithError:error];
407    }
408  }];
409}
410
411# pragma mark - manifest processing
412
413- (NSDictionary *)_processManifest:(NSDictionary *)manifest
414{
415  NSMutableDictionary *mutableManifest = [manifest mutableCopy];
416  if (!mutableManifest[@"isVerified"] && ![EXKernelLinkingManager isExpoHostedUrl:_httpManifestUrl] && !EXEnvironment.sharedEnvironment.isDetached){
417    // the manifest id determines the namespace/experience id an app is sandboxed with
418    // if manifest is hosted by third parties, we sandbox it with the hostname to avoid clobbering exp.host namespaces
419    // for https urls, sandboxed id is of form quinlanj.github.io/myProj-myApp
420    // for http urls, sandboxed id is of form UNVERIFIED-quinlanj.github.io/myProj-myApp
421    NSString *securityPrefix = [_httpManifestUrl.scheme isEqualToString:@"https"] ? @"" : @"UNVERIFIED-";
422    NSString *slugSuffix = manifest[@"slug"] ? [@"-" stringByAppendingString:manifest[@"slug"]]: @"";
423    mutableManifest[@"id"] = [NSString stringWithFormat:@"%@%@%@%@", securityPrefix, _httpManifestUrl.host, _httpManifestUrl.path ?: @"", slugSuffix];
424    mutableManifest[@"isVerified"] = @(YES);
425  }
426  if (!mutableManifest[@"isVerified"]) {
427    mutableManifest[@"isVerified"] = @(NO);
428  }
429
430  if (![mutableManifest[@"isVerified"] boolValue] && (EXEnvironment.sharedEnvironment.isManifestVerificationBypassed || [self _isAnonymousExperience:manifest])) {
431    mutableManifest[@"isVerified"] = @(YES);
432  }
433
434  return [mutableManifest copy];
435}
436
437- (BOOL)_isAnonymousExperience:(NSDictionary *)manifest
438{
439  NSString *experienceId = manifest[@"id"];
440  return experienceId != nil && [experienceId hasPrefix:@"@anonymous/"];
441}
442
443+ (BOOL)areDevToolsEnabledWithManifest:(NSDictionary *)manifest
444{
445  NSDictionary *manifestDeveloperConfig = manifest[@"developer"];
446  BOOL isDeployedFromTool = (manifestDeveloperConfig && manifestDeveloperConfig[@"tool"] != nil);
447  return (isDeployedFromTool);
448}
449
450#pragma mark - headers
451
452- (NSDictionary *)_requestHeaders
453{
454  NSDictionary *requestHeaders = @{
455      @"Exponent-SDK-Version": [self _sdkVersions],
456      @"Exponent-Accept-Signature": @"true",
457      @"Exponent-Platform": @"ios",
458      @"Exponent-Version": [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"],
459      @"Expo-Client-Environment": [self _clientEnvironment],
460      @"Expo-Updates-Environment": [self _clientEnvironment],
461      @"User-Agent": [self _userAgentString],
462      @"Expo-Client-Release-Type": [EXClientReleaseType clientReleaseType]
463  };
464
465  NSString *sessionSecret = [[EXSession sharedInstance] sessionSecret];
466  if (sessionSecret) {
467    NSMutableDictionary *requestHeadersMutable = [requestHeaders mutableCopy];
468    requestHeadersMutable[@"Expo-Session"] = sessionSecret;
469    requestHeaders = requestHeadersMutable;
470  }
471
472  return requestHeaders;
473}
474
475- (NSString *)_userAgentString
476{
477  struct utsname systemInfo;
478  uname(&systemInfo);
479  NSString *deviceModel = [NSString stringWithCString:systemInfo.machine encoding:NSUTF8StringEncoding];
480  return [NSString stringWithFormat:@"Exponent/%@ (%@; %@ %@; Scale/%.2f; %@)",
481          [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"],
482          deviceModel,
483          [UIDevice currentDevice].systemName,
484          [UIDevice currentDevice].systemVersion,
485          [UIScreen mainScreen].scale,
486          [NSLocale autoupdatingCurrentLocale].localeIdentifier];
487}
488
489- (NSString *)_clientEnvironment
490{
491  if ([EXEnvironment sharedEnvironment].isDetached) {
492    return @"STANDALONE";
493  } else {
494    return @"EXPO_DEVICE";
495#if TARGET_IPHONE_SIMULATOR
496    return @"EXPO_SIMULATOR";
497#endif
498  }
499}
500
501- (NSString *)_sdkVersions
502{
503  NSArray *versionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"];
504  if (versionsAvailable) {
505    return [versionsAvailable componentsJoinedByString:@","];
506  } else {
507    return [EXVersions sharedInstance].temporarySdkVersion;
508  }
509}
510
511@end
512
513NS_ASSUME_NONNULL_END
514