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