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- (NSError *)verifyManifestSdkVersion:(EXManifestsManifest *)maybeManifest 216{ 217 NSString *errorCode; 218 NSDictionary *metadata; 219 if (maybeManifest && maybeManifest.expoGoSDKVersion) { 220 if (![maybeManifest.expoGoSDKVersion isEqualToString:@"UNVERSIONED"]) { 221 NSInteger manifestSdkVersion = [maybeManifest.expoGoSDKVersion integerValue]; 222 if (manifestSdkVersion) { 223 NSInteger oldestSdkVersion = [[self _earliestSdkVersionSupported] integerValue]; 224 NSInteger newestSdkVersion = [[self _latestSdkVersionSupported] integerValue]; 225 if (manifestSdkVersion < oldestSdkVersion) { 226 errorCode = @"EXPERIENCE_SDK_VERSION_OUTDATED"; 227 // since we are spoofing this error, we put the SDK version of the project as the 228 // "available" SDK version -- it's the only one available from the server 229 metadata = @{@"availableSDKVersions": @[maybeManifest.expoGoSDKVersion]}; 230 } 231 if (manifestSdkVersion > newestSdkVersion) { 232 errorCode = @"EXPERIENCE_SDK_VERSION_TOO_NEW"; 233 } 234 235 if ([[EXVersions sharedInstance].temporarySdkVersion integerValue] == manifestSdkVersion) { 236 // It seems there is no matching versioned SDK, 237 // but version of the unversioned code matches the requested one. That's ok. 238 errorCode = nil; 239 } 240 } else { 241 errorCode = @"MALFORMED_SDK_VERSION"; 242 } 243 } 244 } else { 245 errorCode = @"NO_SDK_VERSION_SPECIFIED"; 246 } 247 if (errorCode) { 248 // will be handled by _validateErrorData: 249 return [self formatError:[NSError errorWithDomain:EXRuntimeErrorDomain code:0 userInfo:@{ 250 @"errorCode": errorCode, 251 @"metadata": metadata ?: @{}, 252 }]]; 253 } else { 254 return nil; 255 } 256} 257 258- (NSError *)_validateErrorData:(NSError *)error response:(NSURLResponse *)response 259{ 260 NSError *formattedError; 261 if ([response isKindOfClass:[NSHTTPURLResponse class]]) { 262 // we got back a response from the server, and we can use the info we got back to make a nice 263 // error message for the user 264 265 formattedError = [self formatError:error]; 266 } else { 267 // was a network error 268 NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo]; 269 userInfo[@"errorCode"] = @"NETWORK_ERROR"; 270 formattedError = [NSError errorWithDomain:EXNetworkErrorDomain code:error.code userInfo:userInfo]; 271 } 272 273 return [super _validateErrorData:formattedError response:response]; 274} 275 276- (NSString *)_earliestSdkVersionSupported 277{ 278 NSArray *clientSDKVersionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"]; 279 return [clientSDKVersionsAvailable firstObject]; // TODO: this is bad, we can't guarantee this array will always be ordered properly. 280} 281 282- (NSString *)_latestSdkVersionSupported 283{ 284 NSArray *clientSDKVersionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"]; 285 return [clientSDKVersionsAvailable lastObject]; // TODO: this is bad, we can't guarantee this array will always be ordered properly. 286} 287 288- (NSError *)formatError:(NSError *)error 289{ 290 NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo]; 291 NSString *errorCode = userInfo[@"errorCode"]; 292 NSString *rawMessage = [error localizedDescription]; 293 294 NSString *formattedMessage = [NSString stringWithFormat:@"Could not load %@.", self.originalUrl]; 295 if ([errorCode isEqualToString:@"EXPERIENCE_NOT_FOUND"] 296 || [errorCode isEqualToString:@"EXPERIENCE_NOT_PUBLISHED_ERROR"] 297 || [errorCode isEqualToString:@"EXPERIENCE_RELEASE_NOT_FOUND_ERROR"]) { 298 formattedMessage = [NSString stringWithFormat:@"No project found at %@.", self.originalUrl]; 299 } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_OUTDATED"]) { 300 NSDictionary *metadata = userInfo[@"metadata"]; 301 NSArray *availableSDKVersions = metadata[@"availableSDKVersions"]; 302 NSString *sdkVersionRequired = [availableSDKVersions firstObject]; 303 NSString *supportedSDKVersions = [[EXVersions sharedInstance].versions[@"sdkVersions"] componentsJoinedByString:@", "]; 304 305 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]; 306 } else if ([errorCode isEqualToString:@"NO_SDK_VERSION_SPECIFIED"]) { 307 NSString *supportedSDKVersions = [[EXVersions sharedInstance].versions[@"sdkVersions"] componentsJoinedByString:@", "]; 308 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]; 309 } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_TOO_NEW"]) { 310 formattedMessage = @"The project you requested requires a newer version of Expo Go. Please download the latest version from the App Store."; 311 } else if ([errorCode isEqualToString:@"NO_COMPATIBLE_EXPERIENCE_FOUND"]){ 312 formattedMessage = rawMessage; // No compatible experience found at ${originalUrl}. Only ${currentSdkVersions} are supported. 313 } else if ([errorCode isEqualToString:@"EXPERIENCE_NOT_VIEWABLE"]) { 314 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. 315 } else if ([errorCode isEqualToString:@"USER_SNACK_NOT_FOUND"] || [errorCode isEqualToString:@"SNACK_NOT_FOUND"]) { 316 formattedMessage = [NSString stringWithFormat:@"No snack found at %@.", self.originalUrl]; 317 } else if ([errorCode isEqualToString:@"SNACK_RUNTIME_NOT_RELEASE"]) { 318 formattedMessage = rawMessage; // From server: `The Snack runtime for corresponding sdk version of this Snack ("${sdkVersions[0]}") is not released.`, 319 } else if ([errorCode isEqualToString:@"SNACK_NOT_FOUND_FOR_SDK_VERSIONS"]) { 320 formattedMessage = rawMessage; // From server: `The snack "${fullName}" was found, but wasn't released for platform "${platform}" and sdk version "${sdkVersions[0]}".` 321 } 322 userInfo[NSLocalizedDescriptionKey] = formattedMessage; 323 324 return [NSError errorWithDomain:EXRuntimeErrorDomain code:error.code userInfo:userInfo]; 325} 326 327@end 328