1// Copyright 2015-present 650 Industries. All rights reserved.
2
3#import "EXManifestResource.h"
4#import "EXApiUtil.h"
5#import "EXEnvironment.h"
6#import "EXFileDownloader.h"
7#import "EXKernelLinkingManager.h"
8#import "EXKernelUtil.h"
9#import "EXVersions.h"
10
11#import <React/RCTConvert.h>
12
13@import EXManifests;
14@import EXUpdates;
15
16NSString * const kEXPublicKeyUrl = @"https://exp.host/--/manifest-public-key";
17NSString * const EXRuntimeErrorDomain = @"incompatible-runtime";
18
19@interface EXManifestResource ()
20
21@property (nonatomic, strong) NSURL * _Nullable originalUrl;
22@property (nonatomic, strong) NSData *data;
23@property (nonatomic, assign) BOOL canBeWrittenToCache;
24
25// cache this value so we only have to compute it once per instance
26@property (nonatomic, strong) NSNumber * _Nullable isUsingEmbeddedManifest;
27
28@end
29
30@implementation EXManifestResource
31
32- (instancetype)initWithManifestUrl:(NSURL *)url originalUrl:(NSURL * _Nullable)originalUrl
33{
34  _originalUrl = originalUrl;
35  _canBeWrittenToCache = NO;
36
37  NSString *resourceName;
38  if ([EXEnvironment sharedEnvironment].isDetached && [originalUrl.absoluteString isEqual:[EXEnvironment sharedEnvironment].standaloneManifestUrl]) {
39    resourceName = kEXEmbeddedManifestResourceName;
40    if ([EXEnvironment sharedEnvironment].releaseChannel){
41      self.releaseChannel = [EXEnvironment sharedEnvironment].releaseChannel;
42    }
43    NSLog(@"EXManifestResource: Standalone manifest remote url is %@ (%@)", url, originalUrl);
44  } else {
45    resourceName = [EXKernelLinkingManager linkingUriForExperienceUri:url useLegacy:YES];
46  }
47
48  if (self = [super initWithResourceName:resourceName resourceType:@"json" remoteUrl:url cachePath:[[self class] cachePath]]) {
49    self.shouldVersionCache = NO;
50  }
51  return self;
52}
53
54- (NSMutableDictionary * _Nullable) _chooseJSONManifest:(NSArray *)jsonManifestObjArray error:(NSError **)error {
55  // Find supported sdk versions
56  if (jsonManifestObjArray) {
57    for (id providedManifestJSON in jsonManifestObjArray) {
58      if ([providedManifestJSON isKindOfClass:[NSDictionary class]]) {
59        EXManifestsManifest *providedManifest = [EXManifestsManifestFactory manifestForManifestJSON:providedManifestJSON];
60        NSString *sdkVersion = providedManifest.expoGoSDKVersion;
61        if (sdkVersion && [[EXVersions sharedInstance] supportsVersion:sdkVersion]) {
62          return providedManifestJSON;
63        }
64      }
65    }
66  }
67
68  if (error) {
69    * error = [self formatError:[NSError errorWithDomain:EXRuntimeErrorDomain code:0 userInfo:@{
70      @"errorCode": @"NO_COMPATIBLE_EXPERIENCE_FOUND",
71      NSLocalizedDescriptionKey: [NSString stringWithFormat:@"No compatible project found at %@. Only %@ are supported.", self.originalUrl, [[EXVersions sharedInstance].versions[@"sdkVersions"] componentsJoinedByString:@","]]
72    }]];
73  }
74  return nil;
75}
76
77- (void)writeToCache
78{
79  if (_data) {
80    NSString *resourceCachePath = [self resourceCachePath];
81    NSLog(@"EXManifestResource: Caching manifest to %@...", resourceCachePath);
82    [_data writeToFile:resourceCachePath atomically:YES];
83  } else {
84    _canBeWrittenToCache = YES;
85  }
86}
87
88- (NSString *)resourceCachePath
89{
90  NSString *resourceCacheFilename = [NSString stringWithFormat:@"%@-%lu", self.resourceName, (unsigned long)[_originalUrl hash]];
91  NSString *versionedResourceFilename = [NSString stringWithFormat:@"%@.%@", resourceCacheFilename, @"json"];
92  return [[[self class] cachePath] stringByAppendingPathComponent:versionedResourceFilename];
93}
94
95- (BOOL)isUsingEmbeddedResource
96{
97  // return cached value if we've already computed it once
98  if (_isUsingEmbeddedManifest != nil) {
99    return [_isUsingEmbeddedManifest boolValue];
100  }
101
102  _isUsingEmbeddedManifest = @NO;
103
104  if ([super isUsingEmbeddedResource]) {
105    _isUsingEmbeddedManifest = @YES;
106  } else {
107    NSString *cachePath = [self resourceCachePath];
108    NSString *bundlePath = [self resourceBundlePath];
109    if (bundlePath) {
110      // we cannot assume the cached manifest is newer than the embedded one, so we need to read both
111      NSData *cachedData = [NSData dataWithContentsOfFile:cachePath];
112      NSData *embeddedData = [NSData dataWithContentsOfFile:bundlePath];
113
114      NSError *jsonErrorCached, *jsonErrorEmbedded;
115      id cachedManifest, embeddedManifest;
116      if (cachedData) {
117        cachedManifest = [NSJSONSerialization JSONObjectWithData:cachedData options:kNilOptions error:&jsonErrorCached];
118      }
119      if (embeddedData) {
120        embeddedManifest = [NSJSONSerialization JSONObjectWithData:embeddedData options:kNilOptions error:&jsonErrorEmbedded];
121      }
122
123      if (!jsonErrorCached && !jsonErrorEmbedded && [self _isUsingEmbeddedManifest:embeddedManifest withCachedManifest:cachedManifest]) {
124        _isUsingEmbeddedManifest = @YES;
125      }
126    }
127  }
128  return [_isUsingEmbeddedManifest boolValue];
129}
130
131- (BOOL)_isUsingEmbeddedManifest:(id)embeddedManifest withCachedManifest:(id)cachedManifest
132{
133  // if there's no cachedManifest at resourceCachePath, we definitely want to use the embedded manifest
134  if (embeddedManifest && !cachedManifest) {
135    return YES;
136  }
137
138  NSDate *embeddedPublishDate = [self _publishedDateFromManifest:embeddedManifest];
139  NSDate *cachedPublishDate;
140
141  if (cachedManifest) {
142    // cached manifests are signed so we have to parse the inner manifest
143    NSString *cachedManifestString = cachedManifest[@"manifestString"];
144    NSDictionary *innerCachedManifest;
145    if (!cachedManifestString) {
146      innerCachedManifest = cachedManifest;
147    } else {
148      NSError *jsonError;
149      innerCachedManifest = [NSJSONSerialization JSONObjectWithData:[cachedManifestString dataUsingEncoding:NSUTF8StringEncoding]
150                                                            options:kNilOptions
151                                                              error:&jsonError];
152      if (jsonError) {
153        // just resolve with NO for now, we'll catch this error later on
154        return NO;
155      }
156    }
157    cachedPublishDate = [self _publishedDateFromManifest:innerCachedManifest];
158  }
159  if (embeddedPublishDate && cachedPublishDate && [embeddedPublishDate compare:cachedPublishDate] == NSOrderedDescending) {
160    return YES;
161  }
162  return NO;
163}
164
165- (NSDate * _Nullable)_publishedDateFromManifest:(id)manifest
166{
167  if (manifest) {
168    // use commitTime instead of publishTime as it is more accurate;
169    // however, fall back to publishedTime in case older cached manifests do not contain
170    // the commitTime key (we have not always served it)
171    NSString *commitDateString = manifest[@"commitTime"];
172    if (commitDateString) {
173      return [RCTConvert NSDate:commitDateString];
174    } else {
175      NSString *publishDateString = manifest[@"publishedTime"];
176      if (publishDateString) {
177        return [RCTConvert NSDate:publishDateString];
178      }
179    }
180  }
181  return nil;
182}
183
184+ (NSString *)cachePath
185{
186  return [[self class] cachePathWithName:@"Manifests"];
187}
188
189- (BOOL)_isThirdPartyHosted
190{
191  return (self.remoteUrl && ![EXKernelLinkingManager isExpoHostedUrl:self.remoteUrl]);
192}
193
194- (BOOL)_isManifestVerificationBypassed: (id) manifestObj
195{
196  bool shouldBypassVerification =(
197                                  // HACK: because `SecItemCopyMatching` doesn't work in older iOS (see EXApiUtil.m)
198                                  ([UIDevice currentDevice].systemVersion.floatValue < 10) ||
199
200                                  // the developer disabled manifest verification
201                                  [EXEnvironment sharedEnvironment].isManifestVerificationBypassed ||
202
203                                  // we're using a copy that came with the NSBundle and was therefore already codesigned
204                                  [self isUsingEmbeddedResource] ||
205
206                                  // we sandbox third party hosted apps instead of verifying signature
207                                  [self _isThirdPartyHosted]
208                                  );
209
210  return
211  // only consider bypassing if there is no signature provided
212  !((NSString *)manifestObj[@"signature"]) && shouldBypassVerification;
213}
214
215- (NSError *)verifyManifestSdkVersion:(EXManifestsManifest *)maybeManifest
216{
217  NSString *errorCode;
218  NSDictionary *metadata;
219  if (maybeManifest && maybeManifest.expoGoSDKVersion) {
220    if (![maybeManifest.expoGoSDKVersion isEqualToString:@"UNVERSIONED"]) {
221      NSInteger manifestSdkVersion = [maybeManifest.expoGoSDKVersion integerValue];
222      if (manifestSdkVersion) {
223        NSInteger oldestSdkVersion = [[self _earliestSdkVersionSupported] integerValue];
224        NSInteger newestSdkVersion = [[self _latestSdkVersionSupported] integerValue];
225        if (manifestSdkVersion < oldestSdkVersion) {
226          errorCode = @"EXPERIENCE_SDK_VERSION_OUTDATED";
227          // since we are spoofing this error, we put the SDK version of the project as the
228          // "available" SDK version -- it's the only one available from the server
229          metadata = @{@"availableSDKVersions": @[maybeManifest.expoGoSDKVersion]};
230        }
231        if (manifestSdkVersion > newestSdkVersion) {
232          errorCode = @"EXPERIENCE_SDK_VERSION_TOO_NEW";
233        }
234
235        if ([[EXVersions sharedInstance].temporarySdkVersion integerValue] == manifestSdkVersion) {
236          // It seems there is no matching versioned SDK,
237          // but version of the unversioned code matches the requested one. That's ok.
238          errorCode = nil;
239        }
240      } else {
241        errorCode = @"MALFORMED_SDK_VERSION";
242      }
243    }
244  } else {
245    errorCode = @"NO_SDK_VERSION_SPECIFIED";
246  }
247  if (errorCode) {
248    // will be handled by _validateErrorData:
249    return [self formatError:[NSError errorWithDomain:EXRuntimeErrorDomain code:0 userInfo:@{
250      @"errorCode": errorCode,
251      @"metadata": metadata ?: @{},
252    }]];
253  } else {
254    return nil;
255  }
256}
257
258- (NSError *)_validateErrorData:(NSError *)error response:(NSURLResponse *)response
259{
260  NSError *formattedError;
261  if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
262    // we got back a response from the server, and we can use the info we got back to make a nice
263    // error message for the user
264
265    formattedError = [self formatError:error];
266  } else {
267    // was a network error
268    NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo];
269    userInfo[@"errorCode"] = @"NETWORK_ERROR";
270    formattedError = [NSError errorWithDomain:EXNetworkErrorDomain code:error.code userInfo:userInfo];
271  }
272
273  return [super _validateErrorData:formattedError response:response];
274}
275
276- (NSString *)_earliestSdkVersionSupported
277{
278  NSArray *clientSDKVersionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"];
279  return [clientSDKVersionsAvailable firstObject]; // TODO: this is bad, we can't guarantee this array will always be ordered properly.
280}
281
282- (NSString *)_latestSdkVersionSupported
283{
284  NSArray *clientSDKVersionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"];
285  return [clientSDKVersionsAvailable lastObject]; // TODO: this is bad, we can't guarantee this array will always be ordered properly.
286}
287
288- (NSError *)formatError:(NSError *)error
289{
290  NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo];
291  NSString *errorCode = userInfo[@"errorCode"];
292  NSString *rawMessage = [error localizedDescription];
293
294  NSString *formattedMessage = [NSString stringWithFormat:@"Could not load %@.", self.originalUrl];
295  if ([errorCode isEqualToString:@"EXPERIENCE_NOT_FOUND"]
296      || [errorCode isEqualToString:@"EXPERIENCE_NOT_PUBLISHED_ERROR"]
297      || [errorCode isEqualToString:@"EXPERIENCE_RELEASE_NOT_FOUND_ERROR"]) {
298    formattedMessage = [NSString stringWithFormat:@"No project found at %@.", self.originalUrl];
299  } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_OUTDATED"]) {
300    NSDictionary *metadata = userInfo[@"metadata"];
301    NSArray *availableSDKVersions = metadata[@"availableSDKVersions"];
302    NSString *sdkVersionRequired = [availableSDKVersions firstObject];
303    NSString *supportedSDKVersions = [[EXVersions sharedInstance].versions[@"sdkVersions"] componentsJoinedByString:@", "];
304
305    formattedMessage = [NSString stringWithFormat:@"This project uses SDK %@, but this version of Expo Go only supports the following SDKs: %@. To load the project, it must be updated to a supported SDK version or an older version of Expo Go must be used.", sdkVersionRequired, supportedSDKVersions];
306  } else if ([errorCode isEqualToString:@"NO_SDK_VERSION_SPECIFIED"]) {
307    NSString *supportedSDKVersions = [[EXVersions sharedInstance].versions[@"sdkVersions"] componentsJoinedByString:@", "];
308    formattedMessage = [NSString stringWithFormat:@"Incompatible SDK version or no SDK version specified. This version of Expo Go only supports the following SDKs (runtimes): %@. A development build must be used to load other runtimes.", supportedSDKVersions];
309  } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_TOO_NEW"]) {
310    formattedMessage = @"The project you requested requires a newer version of Expo Go. Please download the latest version from the App Store.";
311  } else if ([errorCode isEqualToString:@"NO_COMPATIBLE_EXPERIENCE_FOUND"]){
312    formattedMessage = rawMessage; // No compatible experience found at ${originalUrl}. Only ${currentSdkVersions} are supported.
313  } else if ([errorCode isEqualToString:@"EXPERIENCE_NOT_VIEWABLE"]) {
314    formattedMessage = rawMessage; // From server: The experience you requested is not viewable by you. You will need to log in or ask the owner to grant you access.
315  } else if ([errorCode isEqualToString:@"USER_SNACK_NOT_FOUND"] || [errorCode isEqualToString:@"SNACK_NOT_FOUND"]) {
316    formattedMessage = [NSString stringWithFormat:@"No snack found at %@.", self.originalUrl];
317  } else if ([errorCode isEqualToString:@"SNACK_RUNTIME_NOT_RELEASE"]) {
318    formattedMessage = rawMessage; // From server: `The Snack runtime for corresponding sdk version of this Snack ("${sdkVersions[0]}") is not released.`,
319  } else if ([errorCode isEqualToString:@"SNACK_NOT_FOUND_FOR_SDK_VERSIONS"]) {
320    formattedMessage = rawMessage; // From server: `The snack "${fullName}" was found, but wasn't released for platform "${platform}" and sdk version "${sdkVersions[0]}".`
321  }
322  userInfo[NSLocalizedDescriptionKey] = formattedMessage;
323
324  return [NSError errorWithDomain:EXRuntimeErrorDomain code:error.code userInfo:userInfo];
325}
326
327@end
328