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