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- (NSInteger)sdkVersionStringToInt:(nonnull NSString *)sdkVersion {
216  NSRange snackSdkVersionRange = [sdkVersion rangeOfString: @"."];
217  return [[sdkVersion substringToIndex: snackSdkVersionRange.location] intValue];
218}
219
220- (NSString *)supportedSdkVersionsConjunctionString:(nonnull NSString *)conjuction {
221  NSArray *supportedSDKVersions = [EXVersions sharedInstance].versions[@"sdkVersions"];
222  NSString *stringBeginning = [[supportedSDKVersions subarrayWithRange:NSMakeRange(0, supportedSDKVersions.count - 1)] componentsJoinedByString:@", "];
223  return [NSString stringWithFormat:@"%@ %@ %@", stringBeginning, conjuction, [supportedSDKVersions lastObject]];
224}
225
226- (NSError *)verifyManifestSdkVersion:(EXManifestsManifest *)maybeManifest
227{
228  NSString *errorCode;
229  NSDictionary *metadata;
230  if (maybeManifest && maybeManifest.expoGoSDKVersion) {
231    if (![maybeManifest.expoGoSDKVersion isEqualToString:@"UNVERSIONED"]) {
232      NSInteger manifestSdkVersion = [maybeManifest.expoGoSDKVersion integerValue];
233      if (manifestSdkVersion) {
234        NSInteger oldestSdkVersion = [[self _earliestSdkVersionSupported] integerValue];
235        NSInteger newestSdkVersion = [[self _latestSdkVersionSupported] integerValue];
236        if (manifestSdkVersion < oldestSdkVersion) {
237          errorCode = @"EXPERIENCE_SDK_VERSION_OUTDATED";
238          // since we are spoofing this error, we put the SDK version of the project as the
239          // "available" SDK version -- it's the only one available from the server
240          metadata = @{@"availableSDKVersions": @[maybeManifest.expoGoSDKVersion]};
241        }
242        if (manifestSdkVersion > newestSdkVersion) {
243          errorCode = @"EXPERIENCE_SDK_VERSION_TOO_NEW";
244        }
245
246        if ([[EXVersions sharedInstance].temporarySdkVersion integerValue] == manifestSdkVersion) {
247          // It seems there is no matching versioned SDK,
248          // but version of the unversioned code matches the requested one. That's ok.
249          errorCode = nil;
250        }
251      } else {
252        errorCode = @"MALFORMED_SDK_VERSION";
253      }
254    }
255  } else {
256    errorCode = @"NO_SDK_VERSION_SPECIFIED";
257  }
258  if (errorCode) {
259    // will be handled by _validateErrorData:
260    return [self formatError:[NSError errorWithDomain:EXRuntimeErrorDomain code:0 userInfo:@{
261      @"errorCode": errorCode,
262      @"metadata": metadata ?: @{},
263    }]];
264  } else {
265    return nil;
266  }
267}
268
269- (NSError *)_validateErrorData:(NSError *)error response:(NSURLResponse *)response
270{
271  NSError *formattedError;
272  if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
273    // we got back a response from the server, and we can use the info we got back to make a nice
274    // error message for the user
275
276    formattedError = [self formatError:error];
277  } else {
278    // was a network error
279    NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo];
280    userInfo[@"errorCode"] = @"NETWORK_ERROR";
281    formattedError = [NSError errorWithDomain:EXNetworkErrorDomain code:error.code userInfo:userInfo];
282  }
283
284  return [super _validateErrorData:formattedError response:response];
285}
286
287- (NSString *)_earliestSdkVersionSupported
288{
289  NSArray *clientSDKVersionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"];
290  return [clientSDKVersionsAvailable firstObject]; // TODO: this is bad, we can't guarantee this array will always be ordered properly.
291}
292
293- (NSString *)_latestSdkVersionSupported
294{
295  NSArray *clientSDKVersionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"];
296  return [clientSDKVersionsAvailable lastObject]; // TODO: this is bad, we can't guarantee this array will always be ordered properly.
297}
298
299- (NSError *)formatError:(NSError *)error
300{
301  NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo];
302  NSString *errorCode = userInfo[@"errorCode"];
303  NSString *rawMessage = [error localizedDescription];
304
305  NSString *formattedMessage = [NSString stringWithFormat:@"Could not load %@.", self.originalUrl];
306  if ([errorCode isEqualToString:@"EXPERIENCE_NOT_FOUND"]
307      || [errorCode isEqualToString:@"EXPERIENCE_NOT_PUBLISHED_ERROR"]
308      || [errorCode isEqualToString:@"EXPERIENCE_RELEASE_NOT_FOUND_ERROR"]) {
309    formattedMessage = [NSString stringWithFormat:@"No project found at %@.", self.originalUrl];
310  } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_OUTDATED"]) {
311    NSDictionary *metadata = userInfo[@"metadata"];
312    NSArray *availableSDKVersions = metadata[@"availableSDKVersions"];
313    NSString *sdkVersionRequired = [availableSDKVersions firstObject];
314    NSString *supportedSDKVersions = [[EXVersions sharedInstance].versions[@"sdkVersions"] componentsJoinedByString:@", "];
315
316    formattedMessage = [NSString stringWithFormat:@"This project uses SDK %@, but this version of Expo Go supports only SDKs %@. \n\n To open this project: \n • Update it to SDK %@. \n • Install an older version of Expo Go that supports the project's SDK version. \n\nIf you are unsure how to update the project or install a suitable version of Expo Go, refer to the https://docs.expo.dev/get-started/expo-go/#sdk-versions", sdkVersionRequired, [self supportedSdkVersionsConjunctionString:@"and"], [self supportedSdkVersionsConjunctionString:@"or"]];
317  } else if ([errorCode isEqualToString:@"NO_SDK_VERSION_SPECIFIED"]) {
318    NSString *supportedSDKVersions = [[EXVersions sharedInstance].versions[@"sdkVersions"] componentsJoinedByString:@", "];
319    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.\nhttps://docs.expo.dev/develop/development-builds/introduction/", supportedSDKVersions];
320  } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_TOO_NEW"]) {
321    formattedMessage = @"The project you requested requires a newer version of Expo Go. Please download the latest version from the App Store.";
322  } else if ([errorCode isEqualToString:@"NO_COMPATIBLE_EXPERIENCE_FOUND"]){
323    formattedMessage = rawMessage; // No compatible experience found at ${originalUrl}. Only ${currentSdkVersions} are supported.
324  } else if ([errorCode isEqualToString:@"EXPERIENCE_NOT_VIEWABLE"]) {
325    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.
326  } else if ([errorCode isEqualToString:@"USER_SNACK_NOT_FOUND"] || [errorCode isEqualToString:@"SNACK_NOT_FOUND"]) {
327    formattedMessage = [NSString stringWithFormat:@"No snack found at %@.", self.originalUrl];
328  } else if ([errorCode isEqualToString:@"SNACK_RUNTIME_NOT_RELEASE"]) {
329    formattedMessage = rawMessage; // From server: `The Snack runtime for corresponding sdk version of this Snack ("${sdkVersions[0]}") is not released.`,
330  } else if ([errorCode isEqualToString:@"SNACK_NOT_FOUND_FOR_SDK_VERSION"]) {
331    NSDictionary *metadata = userInfo[@"metadata"];
332    NSString *fullName = metadata[@"fullName"];
333    NSString *snackSdkVersion = metadata[@"sdkVersions"][0];
334    NSInteger snackSdkVersionValue = [self sdkVersionStringToInt: snackSdkVersion];
335    NSArray *supportedSdkVersions = [EXVersions sharedInstance].versions[@"sdkVersions"];
336    NSInteger latestSupportedSdkVersionValue = [self sdkVersionStringToInt: supportedSdkVersions[0]];
337
338    formattedMessage = [NSString stringWithFormat:@"The snack \"%@\" was found, but it is not compatible with your version of Expo Go. It was released for SDK %@, but your Expo Go supports only SDKs %@.", fullName, snackSdkVersion, [self supportedSdkVersionsConjunctionString:@"and"]];
339
340    if (snackSdkVersionValue > latestSupportedSdkVersionValue) {
341      formattedMessage = [NSString stringWithFormat:@"%@\n\nYou need to update your Expo Go app in order to run this snack.", formattedMessage];
342    } else {
343      formattedMessage = [NSString stringWithFormat:@"%@\n\nSnack needs to be upgraded to a current SDK version. To do it, open the project at https://snack.expo.dev. It will be automatically upgraded to a supported SDK version.", formattedMessage];
344    }
345    formattedMessage = [NSString stringWithFormat:@"%@\n\nLearn more about SDK versions and Expo Go in the https://docs.expo.dev/get-started/expo-go/#sdk-versions.", formattedMessage];
346  }
347  userInfo[NSLocalizedDescriptionKey] = formattedMessage;
348
349  return [NSError errorWithDomain:EXRuntimeErrorDomain code:error.code userInfo:userInfo];
350}
351
352+ (NSString * _Nonnull)formatHeader:(NSError * _Nonnull)error {
353  NSString *errorCode = error.userInfo[@"errorCode"];
354
355  if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_OUTDATED"]) {
356    return @"Project is incompatible with this version of Expo Go" ;
357  } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_TOO_NEW"]) {
358    return @"Project is incompatible with this version of Expo Go";
359  } else if ([errorCode isEqualToString:@"SNACK_NOT_FOUND_FOR_SDK_VERSION"]) {
360    return @"This Snack is incompatible with this version of Expo Go";
361  }
362  return nil;
363}
364
365+ (NSAttributedString * _Nonnull)addErrorStringHyperlinks:(NSString * _Nonnull)errorString {
366  NSDictionary *linkMappings = @{
367    @"https://docs.expo.dev/get-started/expo-go/#sdk-versions": @"SDK Versions Guide",
368    @"https://snack.expo.dev": @"Expo Snack website",
369    @"https://docs.expo.dev/develop/development-builds/introduction/": @"Learn more about development builds",
370  };
371  NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:errorString];
372
373  for (NSString *link in linkMappings) {
374    NSString *replacement = linkMappings[link];
375    NSRange linkRange = [errorString rangeOfString:link];
376    if (linkRange.location != NSNotFound) {
377      [attributedString replaceCharactersInRange:linkRange withString:replacement];
378      [attributedString addAttribute:NSLinkAttributeName value:link range:NSMakeRange(linkRange.location, replacement.length)];
379    }
380  }
381  return attributedString;
382}
383
384@end
385