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  // 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    @"EXUpdatesRequestHeaders": [self _requestHeaders]
318  }];
319
320  if (![EXEnvironment sharedEnvironment].areRemoteUpdatesEnabled) {
321    [self _launchWithNoDatabaseAndError:nil];
322    return;
323  }
324
325  EXUpdatesDatabaseManager *updatesDatabaseManager = [EXKernel sharedInstance].serviceRegistry.updatesDatabaseManager;
326
327  NSMutableArray *sdkVersions = [[EXVersions sharedInstance].versions[@"sdkVersions"] ?: @[[EXVersions sharedInstance].temporarySdkVersion] mutableCopy];
328  [sdkVersions addObject:@"UNVERSIONED"];
329  _selectionPolicy = [[EXUpdatesSelectionPolicyNewest alloc] initWithRuntimeVersions:sdkVersions];
330
331  EXUpdatesAppLoaderTask *loaderTask = [[EXUpdatesAppLoaderTask alloc] initWithConfig:_config
332                                                                             database:updatesDatabaseManager.database
333                                                                            directory:updatesDatabaseManager.updatesDirectory
334                                                                      selectionPolicy:_selectionPolicy
335                                                                        delegateQueue:_appLoaderQueue];
336  loaderTask.delegate = self;
337  [loaderTask start];
338}
339
340- (void)_launchWithNoDatabaseAndError:(nullable NSError *)error
341{
342  EXUpdatesAppLauncherNoDatabase *appLauncher = [[EXUpdatesAppLauncherNoDatabase alloc] init];
343  [appLauncher launchUpdateWithConfig:_config fatalError:error];
344
345  _confirmedManifest = [self _processManifest:appLauncher.launchedUpdate.rawManifest];
346  _optimisticManifest = _confirmedManifest;
347  _bundle = [NSData dataWithContentsOfURL:appLauncher.launchAssetUrl];
348  _appLauncher = appLauncher;
349  if (self.delegate) {
350    [self.delegate appLoader:self didLoadOptimisticManifest:_confirmedManifest];
351    [self.delegate appLoader:self didFinishLoadingManifest:_confirmedManifest bundle:_bundle];
352  }
353}
354
355- (void)_runReaper
356{
357  if (_appLauncher.launchedUpdate) {
358    EXUpdatesDatabaseManager *updatesDatabaseManager = [EXKernel sharedInstance].serviceRegistry.updatesDatabaseManager;
359    [EXUpdatesReaper reapUnusedUpdatesWithConfig:_config
360                                        database:updatesDatabaseManager.database
361                                       directory:updatesDatabaseManager.updatesDirectory
362                                 selectionPolicy:_selectionPolicy
363                                  launchedUpdate:_appLauncher.launchedUpdate];
364  }
365}
366
367- (void)_setOptimisticManifest:(NSDictionary *)manifest
368{
369  _optimisticManifest = manifest;
370  if (self.delegate) {
371    [self.delegate appLoader:self didLoadOptimisticManifest:_optimisticManifest];
372  }
373}
374
375- (void)_setShouldShowRemoteUpdateStatus:(NSDictionary *)manifest
376{
377  // we don't want to show the cached experience alert when Updates.reloadAsync() is called
378  if (_shouldUseCacheOnly) {
379    _shouldShowRemoteUpdateStatus = NO;
380    return;
381  }
382
383  if (manifest) {
384    NSDictionary *developmentClientSettings = manifest[@"developmentClient"];
385    if (developmentClientSettings && [developmentClientSettings isKindOfClass:[NSDictionary class]]) {
386      id silentLaunch = developmentClientSettings[@"silentLaunch"];
387      if (silentLaunch && [@(YES) isEqual:silentLaunch]) {
388        _shouldShowRemoteUpdateStatus = NO;
389        return;
390      }
391    }
392
393    // we want to avoid showing the status for older snack SDK versions, too
394    // we make our best guess based on the manifest fields
395    // TODO: remove this after SDK 38 is phased out
396    NSString *sdkVersion = manifest[@"sdkVersion"];
397    NSString *bundleUrl = manifest[@"bundleUrl"];
398    if (![@"UNVERSIONED" isEqual:sdkVersion] &&
399        sdkVersion.integerValue < 39 &&
400        [@"snack" isEqual:manifest[@"slug"]] &&
401        bundleUrl && [bundleUrl isKindOfClass:[NSString class]] &&
402        [bundleUrl hasPrefix:@"https://d1wp6m56sqw74a.cloudfront.net/%40exponent%2Fsnack"]) {
403      _shouldShowRemoteUpdateStatus = NO;
404      return;
405    }
406  }
407  _shouldShowRemoteUpdateStatus = YES;
408}
409
410- (void)_loadDevelopmentJavaScriptResource
411{
412  EXAppFetcher *appFetcher = [[EXAppFetcher alloc] initWithAppLoader:self];
413  [appFetcher fetchJSBundleWithManifest:self.optimisticManifest cacheBehavior:EXCachedResourceNoCache timeoutInterval:kEXJSBundleTimeout progress:^(EXLoadingProgress *progress) {
414    if (self.delegate) {
415      [self.delegate appLoader:self didLoadBundleWithProgress:progress];
416    }
417  } success:^(NSData *bundle) {
418    self.isUpToDate = YES;
419    self.bundle = bundle;
420    if (self.delegate) {
421      [self.delegate appLoader:self didFinishLoadingManifest:self.optimisticManifest bundle:self.bundle];
422    }
423  } error:^(NSError *error) {
424    self.error = error;
425    if (self.delegate) {
426      [self.delegate appLoader:self didFailWithError:error];
427    }
428  }];
429}
430
431# pragma mark - manifest processing
432
433- (NSDictionary *)_processManifest:(NSDictionary *)manifest
434{
435  NSMutableDictionary *mutableManifest = [manifest mutableCopy];
436  if (!mutableManifest[@"isVerified"] && ![EXKernelLinkingManager isExpoHostedUrl:_httpManifestUrl] && !EXEnvironment.sharedEnvironment.isDetached){
437    // the manifest id determines the namespace/experience id an app is sandboxed with
438    // if manifest is hosted by third parties, we sandbox it with the hostname to avoid clobbering exp.host namespaces
439    // for https urls, sandboxed id is of form quinlanj.github.io/myProj-myApp
440    // for http urls, sandboxed id is of form UNVERIFIED-quinlanj.github.io/myProj-myApp
441    NSString *securityPrefix = [_httpManifestUrl.scheme isEqualToString:@"https"] ? @"" : @"UNVERIFIED-";
442    NSString *slugSuffix = manifest[@"slug"] ? [@"-" stringByAppendingString:manifest[@"slug"]]: @"";
443    mutableManifest[@"id"] = [NSString stringWithFormat:@"%@%@%@%@", securityPrefix, _httpManifestUrl.host, _httpManifestUrl.path ?: @"", slugSuffix];
444    mutableManifest[@"isVerified"] = @(YES);
445  }
446  if (!mutableManifest[@"isVerified"]) {
447    mutableManifest[@"isVerified"] = @(NO);
448  }
449
450  if (![mutableManifest[@"isVerified"] boolValue] && (EXEnvironment.sharedEnvironment.isManifestVerificationBypassed || [self _isAnonymousExperience:manifest])) {
451    mutableManifest[@"isVerified"] = @(YES);
452  }
453
454  return [mutableManifest copy];
455}
456
457- (BOOL)_isAnonymousExperience:(NSDictionary *)manifest
458{
459  NSString *experienceId = manifest[@"id"];
460  return experienceId != nil && [experienceId hasPrefix:@"@anonymous/"];
461}
462
463+ (BOOL)areDevToolsEnabledWithManifest:(NSDictionary *)manifest
464{
465  NSDictionary *manifestDeveloperConfig = manifest[@"developer"];
466  BOOL isDeployedFromTool = (manifestDeveloperConfig && manifestDeveloperConfig[@"tool"] != nil);
467  return (isDeployedFromTool);
468}
469
470#pragma mark - headers
471
472- (NSDictionary *)_requestHeaders
473{
474  NSDictionary *requestHeaders = @{
475      @"Exponent-SDK-Version": [self _sdkVersions],
476      @"Exponent-Accept-Signature": @"true",
477      @"Exponent-Platform": @"ios",
478      @"Exponent-Version": [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"],
479      @"Expo-Client-Environment": [self _clientEnvironment],
480      @"Expo-Updates-Environment": [self _clientEnvironment],
481      @"User-Agent": [self _userAgentString],
482      @"Expo-Client-Release-Type": [EXClientReleaseType clientReleaseType]
483  };
484
485  NSString *sessionSecret = [[EXSession sharedInstance] sessionSecret];
486  if (sessionSecret) {
487    NSMutableDictionary *requestHeadersMutable = [requestHeaders mutableCopy];
488    requestHeadersMutable[@"Expo-Session"] = sessionSecret;
489    requestHeaders = requestHeadersMutable;
490  }
491
492  return requestHeaders;
493}
494
495- (NSString *)_userAgentString
496{
497  struct utsname systemInfo;
498  uname(&systemInfo);
499  NSString *deviceModel = [NSString stringWithCString:systemInfo.machine encoding:NSUTF8StringEncoding];
500  return [NSString stringWithFormat:@"Exponent/%@ (%@; %@ %@; Scale/%.2f; %@)",
501          [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"],
502          deviceModel,
503          [UIDevice currentDevice].systemName,
504          [UIDevice currentDevice].systemVersion,
505          [UIScreen mainScreen].scale,
506          [NSLocale autoupdatingCurrentLocale].localeIdentifier];
507}
508
509- (NSString *)_clientEnvironment
510{
511  if ([EXEnvironment sharedEnvironment].isDetached) {
512    return @"STANDALONE";
513  } else {
514    return @"EXPO_DEVICE";
515#if TARGET_IPHONE_SIMULATOR
516    return @"EXPO_SIMULATOR";
517#endif
518  }
519}
520
521- (NSString *)_sdkVersions
522{
523  NSArray *versionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"];
524  if (versionsAvailable) {
525    return [versionsAvailable componentsJoinedByString:@","];
526  } else {
527    return [EXVersions sharedInstance].temporarySdkVersion;
528  }
529}
530
531@end
532
533NS_ASSUME_NONNULL_END
534