1// Copyright 2015-present 650 Industries. All rights reserved. 2 3#import "EXManifestResource.h" 4#import "EXAnalytics.h" 5#import "EXApiUtil.h" 6#import "EXEnvironment.h" 7#import "EXFileDownloader.h" 8#import "EXKernelLinkingManager.h" 9#import "EXKernelUtil.h" 10#import "EXVersions.h" 11 12#import <React/RCTConvert.h> 13 14@import EXManifests; 15@import EXUpdates; 16 17NSString * const kEXPublicKeyUrl = @"https://exp.host/--/manifest-public-key"; 18NSString * const EXRuntimeErrorDomain = @"incompatible-runtime"; 19 20@interface EXManifestResource () 21 22@property (nonatomic, strong) NSURL * _Nullable originalUrl; 23@property (nonatomic, strong) NSData *data; 24@property (nonatomic, assign) BOOL canBeWrittenToCache; 25 26// cache this value so we only have to compute it once per instance 27@property (nonatomic, strong) NSNumber * _Nullable isUsingEmbeddedManifest; 28 29@end 30 31@implementation EXManifestResource 32 33- (instancetype)initWithManifestUrl:(NSURL *)url originalUrl:(NSURL * _Nullable)originalUrl 34{ 35 _originalUrl = originalUrl; 36 _canBeWrittenToCache = NO; 37 38 NSString *resourceName; 39 if ([EXEnvironment sharedEnvironment].isDetached && [originalUrl.absoluteString isEqual:[EXEnvironment sharedEnvironment].standaloneManifestUrl]) { 40 resourceName = kEXEmbeddedManifestResourceName; 41 if ([EXEnvironment sharedEnvironment].releaseChannel){ 42 self.releaseChannel = [EXEnvironment sharedEnvironment].releaseChannel; 43 } 44 NSLog(@"EXManifestResource: Standalone manifest remote url is %@ (%@)", url, originalUrl); 45 } else { 46 resourceName = [EXKernelLinkingManager linkingUriForExperienceUri:url useLegacy:YES]; 47 } 48 49 if (self = [super initWithResourceName:resourceName resourceType:@"json" remoteUrl:url cachePath:[[self class] cachePath]]) { 50 self.shouldVersionCache = NO; 51 } 52 return self; 53} 54 55- (NSMutableDictionary * _Nullable) _chooseJSONManifest:(NSArray *)jsonManifestObjArray error:(NSError **)error { 56 // Find supported sdk versions 57 if (jsonManifestObjArray) { 58 for (id providedManifestJSON in jsonManifestObjArray) { 59 if ([providedManifestJSON isKindOfClass:[NSDictionary class]]) { 60 EXManifestsManifest *providedManifest = [EXManifestsManifestFactory manifestForManifestJSON:providedManifestJSON]; 61 NSString *sdkVersion = providedManifest.expoGoSDKVersion; 62 if (sdkVersion && [[EXVersions sharedInstance] supportsVersion:sdkVersion]) { 63 return providedManifestJSON; 64 } 65 } 66 } 67 } 68 69 if (error) { 70 * error = [self formatError:[NSError errorWithDomain:EXRuntimeErrorDomain code:0 userInfo:@{ 71 @"errorCode": @"NO_COMPATIBLE_EXPERIENCE_FOUND", 72 NSLocalizedDescriptionKey: [NSString stringWithFormat:@"No compatible project found at %@. Only %@ are supported.", self.originalUrl, [[EXVersions sharedInstance].versions[@"sdkVersions"] componentsJoinedByString:@","]] 73 }]]; 74 } 75 return nil; 76} 77 78- (void)loadResourceWithBehavior:(EXCachedResourceBehavior)behavior 79 progressBlock:(EXCachedResourceProgressBlock)progressBlock 80 successBlock:(EXCachedResourceSuccessBlock)successBlock 81 errorBlock:(EXCachedResourceErrorBlock)errorBlock 82{ 83 [super loadResourceWithBehavior:behavior progressBlock:progressBlock successBlock:^(NSData * _Nonnull data) { 84 self->_data = data; 85 if (self->_canBeWrittenToCache) { 86 [self writeToCache]; 87 } 88 89 __block NSError *jsonError; 90 id manifestJSONObjOrJSONObjArray = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonError]; 91 if (jsonError) { 92 errorBlock(jsonError); 93 return; 94 } 95 96 id manifestObj; 97 // Check if server sent an array of manifests (multi-manifests) 98 if ([manifestJSONObjOrJSONObjArray isKindOfClass:[NSArray class]]) { 99 NSArray *manifestArray = (NSArray *)manifestJSONObjOrJSONObjArray; 100 __block NSError *manifestError; 101 manifestObj = [self _chooseJSONManifest:(NSArray *)manifestArray error:&manifestError]; 102 if (!manifestObj) { 103 errorBlock(manifestError); 104 return; 105 } 106 } else { 107 manifestObj = manifestJSONObjOrJSONObjArray; 108 } 109 110 NSString *innerManifestString = (NSString *)manifestObj[@"manifestString"]; 111 NSString *manifestSignature = (NSString *)manifestObj[@"signature"]; 112 113 NSMutableDictionary *innerManifestObj; 114 if (!innerManifestString) { 115 // this manifest is not signed 116 innerManifestObj = [manifestObj mutableCopy]; 117 } else { 118 @try { 119 innerManifestObj = [NSJSONSerialization JSONObjectWithData:[innerManifestString dataUsingEncoding:NSUTF8StringEncoding] 120 options:NSJSONReadingMutableContainers 121 error:&jsonError]; 122 } @catch (NSException *exception) { 123 errorBlock([NSError errorWithDomain:EXNetworkErrorDomain code:-1 userInfo:@{ NSLocalizedDescriptionKey: exception.reason }]); 124 } 125 if (jsonError) { 126 errorBlock(jsonError); 127 return; 128 } 129 } 130 131 EXManifestsManifest *manifest = [EXManifestsManifestFactory manifestForManifestJSON:innerManifestObj]; 132 133 NSError *sdkVersionError = [self verifyManifestSdkVersion:manifest]; 134 if (sdkVersionError) { 135 errorBlock(sdkVersionError); 136 return; 137 } 138 139 EXVerifySignatureSuccessBlock signatureSuccess = ^(BOOL isValid) { 140 [innerManifestObj setObject:@(isValid) forKey:@"isVerified"]; 141 successBlock([NSJSONSerialization dataWithJSONObject:innerManifestObj options:0 error:&jsonError]); 142 }; 143 144 if ([self _isManifestVerificationBypassed:manifestObj]) { 145 if ([self _isThirdPartyHosted] && ![EXEnvironment sharedEnvironment].isDetached){ 146 // the manifest id determines the namespace/experience id an app is sandboxed with 147 // if manifest is hosted by third parties, we sandbox it with the hostname to avoid clobbering exp.host namespaces 148 // for https urls, sandboxed id is of form quinlanj.github.io/myProj-myApp 149 // for http urls, sandboxed id is of form UNVERIFIED-quinlanj.github.io/myProj-myApp 150 NSString * securityPrefix = [self.remoteUrl.scheme isEqualToString:@"https"] ? @"" : @"UNVERIFIED-"; 151 NSString * slugSuffix = innerManifestObj[@"slug"] ? [@"-" stringByAppendingString:innerManifestObj[@"slug"]]: @""; 152 innerManifestObj[@"id"] = [NSString stringWithFormat:@"%@%@%@%@", securityPrefix, self.remoteUrl.host, self.remoteUrl.path?:@"", slugSuffix]; 153 } 154 signatureSuccess(YES); 155 } else { 156 NSURL *publicKeyUrl = [NSURL URLWithString:kEXPublicKeyUrl]; 157 [EXApiUtil verifySignatureWithPublicKeyUrl:publicKeyUrl 158 data:innerManifestString 159 signature:manifestSignature 160 successBlock:signatureSuccess 161 errorBlock:^(NSError *error) { 162 // ignore network errors in manifest validation, 163 // otherwise we can break offline loading for standalone apps when they have a valid manifest cache but no key. 164 if (error.domain == NSURLErrorDomain || error.domain == EXNetworkErrorDomain) { 165 DDLogWarn(@"EXManifestResource: Ignoring network error when validating manifest"); 166 signatureSuccess(YES); 167 } else { 168 errorBlock(error); 169 } 170 }]; 171 } 172 } errorBlock:errorBlock]; 173} 174 175- (void)writeToCache 176{ 177 if (_data) { 178 NSString *resourceCachePath = [self resourceCachePath]; 179 NSLog(@"EXManifestResource: Caching manifest to %@...", resourceCachePath); 180 [_data writeToFile:resourceCachePath atomically:YES]; 181 } else { 182 _canBeWrittenToCache = YES; 183 } 184} 185 186- (NSString *)resourceCachePath 187{ 188 NSString *resourceCacheFilename = [NSString stringWithFormat:@"%@-%lu", self.resourceName, (unsigned long)[_originalUrl hash]]; 189 NSString *versionedResourceFilename = [NSString stringWithFormat:@"%@.%@", resourceCacheFilename, @"json"]; 190 return [[[self class] cachePath] stringByAppendingPathComponent:versionedResourceFilename]; 191} 192 193- (BOOL)isUsingEmbeddedResource 194{ 195 // return cached value if we've already computed it once 196 if (_isUsingEmbeddedManifest != nil) { 197 return [_isUsingEmbeddedManifest boolValue]; 198 } 199 200 _isUsingEmbeddedManifest = @NO; 201 202 if ([super isUsingEmbeddedResource]) { 203 _isUsingEmbeddedManifest = @YES; 204 } else { 205 NSString *cachePath = [self resourceCachePath]; 206 NSString *bundlePath = [self resourceBundlePath]; 207 if (bundlePath) { 208 // we cannot assume the cached manifest is newer than the embedded one, so we need to read both 209 NSData *cachedData = [NSData dataWithContentsOfFile:cachePath]; 210 NSData *embeddedData = [NSData dataWithContentsOfFile:bundlePath]; 211 212 NSError *jsonErrorCached, *jsonErrorEmbedded; 213 id cachedManifest, embeddedManifest; 214 if (cachedData) { 215 cachedManifest = [NSJSONSerialization JSONObjectWithData:cachedData options:kNilOptions error:&jsonErrorCached]; 216 } 217 if (embeddedData) { 218 embeddedManifest = [NSJSONSerialization JSONObjectWithData:embeddedData options:kNilOptions error:&jsonErrorEmbedded]; 219 } 220 221 if (!jsonErrorCached && !jsonErrorEmbedded && [self _isUsingEmbeddedManifest:embeddedManifest withCachedManifest:cachedManifest]) { 222 _isUsingEmbeddedManifest = @YES; 223 } 224 } 225 } 226 return [_isUsingEmbeddedManifest boolValue]; 227} 228 229- (BOOL)_isUsingEmbeddedManifest:(id)embeddedManifest withCachedManifest:(id)cachedManifest 230{ 231 // if there's no cachedManifest at resourceCachePath, we definitely want to use the embedded manifest 232 if (embeddedManifest && !cachedManifest) { 233 return YES; 234 } 235 236 NSDate *embeddedPublishDate = [self _publishedDateFromManifest:embeddedManifest]; 237 NSDate *cachedPublishDate; 238 239 if (cachedManifest) { 240 // cached manifests are signed so we have to parse the inner manifest 241 NSString *cachedManifestString = cachedManifest[@"manifestString"]; 242 NSDictionary *innerCachedManifest; 243 if (!cachedManifestString) { 244 innerCachedManifest = cachedManifest; 245 } else { 246 NSError *jsonError; 247 innerCachedManifest = [NSJSONSerialization JSONObjectWithData:[cachedManifestString dataUsingEncoding:NSUTF8StringEncoding] 248 options:kNilOptions 249 error:&jsonError]; 250 if (jsonError) { 251 // just resolve with NO for now, we'll catch this error later on 252 return NO; 253 } 254 } 255 cachedPublishDate = [self _publishedDateFromManifest:innerCachedManifest]; 256 } 257 if (embeddedPublishDate && cachedPublishDate && [embeddedPublishDate compare:cachedPublishDate] == NSOrderedDescending) { 258 return YES; 259 } 260 return NO; 261} 262 263- (NSDate * _Nullable)_publishedDateFromManifest:(id)manifest 264{ 265 if (manifest) { 266 // use commitTime instead of publishTime as it is more accurate; 267 // however, fall back to publishedTime in case older cached manifests do not contain 268 // the commitTime key (we have not always served it) 269 NSString *commitDateString = manifest[@"commitTime"]; 270 if (commitDateString) { 271 return [RCTConvert NSDate:commitDateString]; 272 } else { 273 NSString *publishDateString = manifest[@"publishedTime"]; 274 if (publishDateString) { 275 return [RCTConvert NSDate:publishDateString]; 276 } 277 } 278 } 279 return nil; 280} 281 282+ (NSString *)cachePath 283{ 284 return [[self class] cachePathWithName:@"Manifests"]; 285} 286 287- (BOOL)_isThirdPartyHosted 288{ 289 return (self.remoteUrl && ![EXKernelLinkingManager isExpoHostedUrl:self.remoteUrl]); 290} 291 292- (BOOL)_isManifestVerificationBypassed: (id) manifestObj 293{ 294 bool shouldBypassVerification =( 295 // HACK: because `SecItemCopyMatching` doesn't work in older iOS (see EXApiUtil.m) 296 ([UIDevice currentDevice].systemVersion.floatValue < 10) || 297 298 // the developer disabled manifest verification 299 [EXEnvironment sharedEnvironment].isManifestVerificationBypassed || 300 301 // we're using a copy that came with the NSBundle and was therefore already codesigned 302 [self isUsingEmbeddedResource] || 303 304 // we sandbox third party hosted apps instead of verifying signature 305 [self _isThirdPartyHosted] 306 ); 307 308 return 309 // only consider bypassing if there is no signature provided 310 !((NSString *)manifestObj[@"signature"]) && shouldBypassVerification; 311} 312 313- (NSError *)_validateResponseData:(NSData *)data response:(NSURLResponse *)response 314{ 315 if (response && [response isKindOfClass:[NSHTTPURLResponse class]]) { 316 NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; 317 NSDictionary *headers = httpResponse.allHeaderFields; 318 319 // pass the Exponent-Server header to Amplitude if it exists. 320 // this is generated only from XDE and exp while serving local bundles. 321 NSString *serverHeaderJson = headers[@"Exponent-Server"]; 322 if (serverHeaderJson) { 323 NSError *jsonError; 324 NSDictionary *serverHeader = [NSJSONSerialization JSONObjectWithData:[serverHeaderJson dataUsingEncoding:NSUTF8StringEncoding] options:0 error:&jsonError]; 325 if (serverHeader && !jsonError) { 326 [[EXAnalytics sharedInstance] logEvent:@"LOAD_DEVELOPER_MANIFEST" manifestUrl:response.URL eventProperties:serverHeader]; 327 } 328 } 329 } 330 // indicate that the response is valid 331 return nil; 332} 333 334- (NSError *)verifyManifestSdkVersion:(EXManifestsManifest *)maybeManifest 335{ 336 NSString *errorCode; 337 NSDictionary *metadata; 338 if (maybeManifest && maybeManifest.expoGoSDKVersion) { 339 if (![maybeManifest.expoGoSDKVersion isEqualToString:@"UNVERSIONED"]) { 340 NSInteger manifestSdkVersion = [maybeManifest.expoGoSDKVersion integerValue]; 341 if (manifestSdkVersion) { 342 NSInteger oldestSdkVersion = [[self _earliestSdkVersionSupported] integerValue]; 343 NSInteger newestSdkVersion = [[self _latestSdkVersionSupported] integerValue]; 344 if (manifestSdkVersion < oldestSdkVersion) { 345 errorCode = @"EXPERIENCE_SDK_VERSION_OUTDATED"; 346 // since we are spoofing this error, we put the SDK version of the project as the 347 // "available" SDK version -- it's the only one available from the server 348 metadata = @{@"availableSDKVersions": @[maybeManifest.expoGoSDKVersion]}; 349 } 350 if (manifestSdkVersion > newestSdkVersion) { 351 errorCode = @"EXPERIENCE_SDK_VERSION_TOO_NEW"; 352 } 353 354 if ([[EXVersions sharedInstance].temporarySdkVersion integerValue] == manifestSdkVersion) { 355 // It seems there is no matching versioned SDK, 356 // but version of the unversioned code matches the requested one. That's ok. 357 errorCode = nil; 358 } 359 } else { 360 errorCode = @"MALFORMED_SDK_VERSION"; 361 } 362 } 363 } else { 364 errorCode = @"NO_SDK_VERSION_SPECIFIED"; 365 } 366 if (errorCode) { 367 // will be handled by _validateErrorData: 368 return [self formatError:[NSError errorWithDomain:EXRuntimeErrorDomain code:0 userInfo:@{ 369 @"errorCode": errorCode, 370 @"metadata": metadata ?: @{}, 371 }]]; 372 } else { 373 return nil; 374 } 375} 376 377- (NSError *)_validateErrorData:(NSError *)error response:(NSURLResponse *)response 378{ 379 NSError *formattedError; 380 if ([response isKindOfClass:[NSHTTPURLResponse class]]) { 381 // we got back a response from the server, and we can use the info we got back to make a nice 382 // error message for the user 383 384 formattedError = [self formatError:error]; 385 } else { 386 // was a network error 387 NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo]; 388 userInfo[@"errorCode"] = @"NETWORK_ERROR"; 389 formattedError = [NSError errorWithDomain:EXNetworkErrorDomain code:error.code userInfo:userInfo]; 390 } 391 392 return [super _validateErrorData:formattedError response:response]; 393} 394 395- (NSString *)_earliestSdkVersionSupported 396{ 397 NSArray *clientSDKVersionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"]; 398 return [clientSDKVersionsAvailable firstObject]; // TODO: this is bad, we can't guarantee this array will always be ordered properly. 399} 400 401- (NSString *)_latestSdkVersionSupported 402{ 403 NSArray *clientSDKVersionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"]; 404 return [clientSDKVersionsAvailable lastObject]; // TODO: this is bad, we can't guarantee this array will always be ordered properly. 405} 406 407- (NSError *)formatError:(NSError *)error 408{ 409 NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo]; 410 NSString *errorCode = userInfo[@"errorCode"]; 411 NSString *rawMessage = [error localizedDescription]; 412 413 NSString *formattedMessage = [NSString stringWithFormat:@"Could not load %@.", self.originalUrl]; 414 if ([errorCode isEqualToString:@"EXPERIENCE_NOT_FOUND"] 415 || [errorCode isEqualToString:@"EXPERIENCE_NOT_PUBLISHED_ERROR"] 416 || [errorCode isEqualToString:@"EXPERIENCE_RELEASE_NOT_FOUND_ERROR"]) { 417 formattedMessage = [NSString stringWithFormat:@"No project found at %@.", self.originalUrl]; 418 } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_OUTDATED"]) { 419 NSDictionary *metadata = userInfo[@"metadata"]; 420 NSArray *availableSDKVersions = metadata[@"availableSDKVersions"]; 421 NSString *sdkVersionRequired = [availableSDKVersions firstObject]; 422 NSString *supportedSDKVersions = [[EXVersions sharedInstance].versions[@"sdkVersions"] componentsJoinedByString:@", "]; 423 424 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]; 425 } else if ([errorCode isEqualToString:@"NO_SDK_VERSION_SPECIFIED"]) { 426 NSString *supportedSDKVersions = [[EXVersions sharedInstance].versions[@"sdkVersions"] componentsJoinedByString:@", "]; 427 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]; 428 } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_TOO_NEW"]) { 429 formattedMessage = @"The project you requested requires a newer version of Expo Go. Please download the latest version from the App Store."; 430 } else if ([errorCode isEqualToString:@"NO_COMPATIBLE_EXPERIENCE_FOUND"]){ 431 formattedMessage = rawMessage; // No compatible experience found at ${originalUrl}. Only ${currentSdkVersions} are supported. 432 } else if ([errorCode isEqualToString:@"EXPERIENCE_NOT_VIEWABLE"]) { 433 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. 434 } else if ([errorCode isEqualToString:@"USER_SNACK_NOT_FOUND"] || [errorCode isEqualToString:@"SNACK_NOT_FOUND"]) { 435 formattedMessage = [NSString stringWithFormat:@"No snack found at %@.", self.originalUrl]; 436 } else if ([errorCode isEqualToString:@"SNACK_RUNTIME_NOT_RELEASE"]) { 437 formattedMessage = rawMessage; // From server: `The Snack runtime for corresponding sdk version of this Snack ("${sdkVersions[0]}") is not released.`, 438 } else if ([errorCode isEqualToString:@"SNACK_NOT_FOUND_FOR_SDK_VERSIONS"]) { 439 formattedMessage = rawMessage; // From server: `The snack "${fullName}" was found, but wasn't released for platform "${platform}" and sdk version "${sdkVersions[0]}".` 440 } 441 userInfo[NSLocalizedDescriptionKey] = formattedMessage; 442 443 return [NSError errorWithDomain:EXRuntimeErrorDomain code:error.code userInfo:userInfo]; 444} 445 446@end 447