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