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)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 *)verifyManifestSdkVersion:(EXManifestsManifest *)maybeManifest 313{ 314 NSString *errorCode; 315 NSDictionary *metadata; 316 if (maybeManifest && maybeManifest.expoGoSDKVersion) { 317 if (![maybeManifest.expoGoSDKVersion isEqualToString:@"UNVERSIONED"]) { 318 NSInteger manifestSdkVersion = [maybeManifest.expoGoSDKVersion integerValue]; 319 if (manifestSdkVersion) { 320 NSInteger oldestSdkVersion = [[self _earliestSdkVersionSupported] integerValue]; 321 NSInteger newestSdkVersion = [[self _latestSdkVersionSupported] integerValue]; 322 if (manifestSdkVersion < oldestSdkVersion) { 323 errorCode = @"EXPERIENCE_SDK_VERSION_OUTDATED"; 324 // since we are spoofing this error, we put the SDK version of the project as the 325 // "available" SDK version -- it's the only one available from the server 326 metadata = @{@"availableSDKVersions": @[maybeManifest.expoGoSDKVersion]}; 327 } 328 if (manifestSdkVersion > newestSdkVersion) { 329 errorCode = @"EXPERIENCE_SDK_VERSION_TOO_NEW"; 330 } 331 332 if ([[EXVersions sharedInstance].temporarySdkVersion integerValue] == manifestSdkVersion) { 333 // It seems there is no matching versioned SDK, 334 // but version of the unversioned code matches the requested one. That's ok. 335 errorCode = nil; 336 } 337 } else { 338 errorCode = @"MALFORMED_SDK_VERSION"; 339 } 340 } 341 } else { 342 errorCode = @"NO_SDK_VERSION_SPECIFIED"; 343 } 344 if (errorCode) { 345 // will be handled by _validateErrorData: 346 return [self formatError:[NSError errorWithDomain:EXRuntimeErrorDomain code:0 userInfo:@{ 347 @"errorCode": errorCode, 348 @"metadata": metadata ?: @{}, 349 }]]; 350 } else { 351 return nil; 352 } 353} 354 355- (NSError *)_validateErrorData:(NSError *)error response:(NSURLResponse *)response 356{ 357 NSError *formattedError; 358 if ([response isKindOfClass:[NSHTTPURLResponse class]]) { 359 // we got back a response from the server, and we can use the info we got back to make a nice 360 // error message for the user 361 362 formattedError = [self formatError:error]; 363 } else { 364 // was a network error 365 NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo]; 366 userInfo[@"errorCode"] = @"NETWORK_ERROR"; 367 formattedError = [NSError errorWithDomain:EXNetworkErrorDomain code:error.code userInfo:userInfo]; 368 } 369 370 return [super _validateErrorData:formattedError response:response]; 371} 372 373- (NSString *)_earliestSdkVersionSupported 374{ 375 NSArray *clientSDKVersionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"]; 376 return [clientSDKVersionsAvailable firstObject]; // TODO: this is bad, we can't guarantee this array will always be ordered properly. 377} 378 379- (NSString *)_latestSdkVersionSupported 380{ 381 NSArray *clientSDKVersionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"]; 382 return [clientSDKVersionsAvailable lastObject]; // TODO: this is bad, we can't guarantee this array will always be ordered properly. 383} 384 385- (NSError *)formatError:(NSError *)error 386{ 387 NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo]; 388 NSString *errorCode = userInfo[@"errorCode"]; 389 NSString *rawMessage = [error localizedDescription]; 390 391 NSString *formattedMessage = [NSString stringWithFormat:@"Could not load %@.", self.originalUrl]; 392 if ([errorCode isEqualToString:@"EXPERIENCE_NOT_FOUND"] 393 || [errorCode isEqualToString:@"EXPERIENCE_NOT_PUBLISHED_ERROR"] 394 || [errorCode isEqualToString:@"EXPERIENCE_RELEASE_NOT_FOUND_ERROR"]) { 395 formattedMessage = [NSString stringWithFormat:@"No project found at %@.", self.originalUrl]; 396 } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_OUTDATED"]) { 397 NSDictionary *metadata = userInfo[@"metadata"]; 398 NSArray *availableSDKVersions = metadata[@"availableSDKVersions"]; 399 NSString *sdkVersionRequired = [availableSDKVersions firstObject]; 400 NSString *supportedSDKVersions = [[EXVersions sharedInstance].versions[@"sdkVersions"] componentsJoinedByString:@", "]; 401 402 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]; 403 } else if ([errorCode isEqualToString:@"NO_SDK_VERSION_SPECIFIED"]) { 404 NSString *supportedSDKVersions = [[EXVersions sharedInstance].versions[@"sdkVersions"] componentsJoinedByString:@", "]; 405 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]; 406 } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_TOO_NEW"]) { 407 formattedMessage = @"The project you requested requires a newer version of Expo Go. Please download the latest version from the App Store."; 408 } else if ([errorCode isEqualToString:@"NO_COMPATIBLE_EXPERIENCE_FOUND"]){ 409 formattedMessage = rawMessage; // No compatible experience found at ${originalUrl}. Only ${currentSdkVersions} are supported. 410 } else if ([errorCode isEqualToString:@"EXPERIENCE_NOT_VIEWABLE"]) { 411 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. 412 } else if ([errorCode isEqualToString:@"USER_SNACK_NOT_FOUND"] || [errorCode isEqualToString:@"SNACK_NOT_FOUND"]) { 413 formattedMessage = [NSString stringWithFormat:@"No snack found at %@.", self.originalUrl]; 414 } else if ([errorCode isEqualToString:@"SNACK_RUNTIME_NOT_RELEASE"]) { 415 formattedMessage = rawMessage; // From server: `The Snack runtime for corresponding sdk version of this Snack ("${sdkVersions[0]}") is not released.`, 416 } else if ([errorCode isEqualToString:@"SNACK_NOT_FOUND_FOR_SDK_VERSIONS"]) { 417 formattedMessage = rawMessage; // From server: `The snack "${fullName}" was found, but wasn't released for platform "${platform}" and sdk version "${sdkVersions[0]}".` 418 } 419 userInfo[NSLocalizedDescriptionKey] = formattedMessage; 420 421 return [NSError errorWithDomain:EXRuntimeErrorDomain code:error.code userInfo:userInfo]; 422} 423 424@end 425