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 14NSString * const kEXPublicKeyUrl = @"https://exp.host/--/manifest-public-key"; 15 16@interface EXManifestResource () 17 18@property (nonatomic, strong) NSURL * _Nullable originalUrl; 19 20// cache this value so we only have to compute it once per instance 21@property (nonatomic, strong) NSNumber * _Nullable isUsingEmbeddedManifest; 22 23@end 24 25@implementation EXManifestResource 26 27- (instancetype)initWithManifestUrl:(NSURL *)url originalUrl:(NSURL * _Nullable)originalUrl 28{ 29 _originalUrl = originalUrl; 30 31 NSString *resourceName; 32 if ([EXEnvironment sharedEnvironment].isDetached && [originalUrl.absoluteString isEqual:[EXEnvironment sharedEnvironment].standaloneManifestUrl]) { 33 resourceName = kEXEmbeddedManifestResourceName; 34 if ([EXEnvironment sharedEnvironment].releaseChannel){ 35 self.releaseChannel = [EXEnvironment sharedEnvironment].releaseChannel; 36 } 37 NSLog(@"EXManifestResource: Standalone manifest remote url is %@ (%@)", url, originalUrl); 38 } else { 39 resourceName = [EXKernelLinkingManager linkingUriForExperienceUri:url useLegacy:YES]; 40 } 41 42 if (self = [super initWithResourceName:resourceName resourceType:@"json" remoteUrl:url cachePath:[[self class] cachePath]]) { 43 self.shouldVersionCache = NO; 44 } 45 return self; 46} 47 48- (void)loadResourceWithBehavior:(EXCachedResourceBehavior)behavior 49 progressBlock:(EXCachedResourceProgressBlock)progressBlock 50 successBlock:(EXCachedResourceSuccessBlock)successBlock 51 errorBlock:(EXCachedResourceErrorBlock)errorBlock 52{ 53 [super loadResourceWithBehavior:behavior progressBlock:progressBlock successBlock:^(NSData * _Nonnull data) { 54 __block NSError *jsonError; 55 id manifestObj = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonError]; 56 if (jsonError) { 57 errorBlock(jsonError); 58 return; 59 } 60 61 NSString *innerManifestString = (NSString *)manifestObj[@"manifestString"]; 62 NSString *manifestSignature = (NSString *)manifestObj[@"signature"]; 63 64 NSMutableDictionary *innerManifestObj; 65 if (!innerManifestString && [self isUsingEmbeddedResource]) { 66 // locally bundled manifests are not signed 67 innerManifestObj = [manifestObj mutableCopy]; 68 } else { 69 @try { 70 innerManifestObj = [NSJSONSerialization JSONObjectWithData:[innerManifestString dataUsingEncoding:NSUTF8StringEncoding] 71 options:NSJSONReadingMutableContainers 72 error:&jsonError]; 73 } @catch (NSException *exception) { 74 errorBlock([NSError errorWithDomain:EXNetworkErrorDomain code:-1 userInfo:@{ NSLocalizedDescriptionKey: exception.reason }]); 75 } 76 if (jsonError) { 77 errorBlock(jsonError); 78 return; 79 } 80 } 81 82 NSError *sdkVersionError = [self _verifyManifestSdkVersion:innerManifestObj]; 83 if (sdkVersionError) { 84 errorBlock(sdkVersionError); 85 return; 86 } 87 88 EXVerifySignatureSuccessBlock signatureSuccess = ^(BOOL isValid) { 89 [innerManifestObj setObject:@(isValid) forKey:@"isVerified"]; 90 successBlock([NSJSONSerialization dataWithJSONObject:innerManifestObj options:0 error:&jsonError]); 91 }; 92 93 if ([self _isManifestVerificationBypassed]) { 94 signatureSuccess(YES); 95 } else { 96 NSURL *publicKeyUrl = [NSURL URLWithString:kEXPublicKeyUrl]; 97 [EXApiUtil verifySignatureWithPublicKeyUrl:publicKeyUrl 98 data:innerManifestString 99 signature:manifestSignature 100 successBlock:signatureSuccess 101 errorBlock:^(NSError *error) { 102 // ignore network errors in manifest validation, 103 // otherwise we can break offline loading for standalone apps when they have a valid manifest cache but no key. 104 if (error.domain == NSURLErrorDomain || error.domain == EXNetworkErrorDomain) { 105 DDLogWarn(@"EXManifestResource: Ignoring network error when validating manifest"); 106 signatureSuccess(YES); 107 } else { 108 errorBlock(error); 109 } 110 }]; 111 } 112 } errorBlock:errorBlock]; 113} 114 115- (BOOL)isUsingEmbeddedResource 116{ 117 // return cached value if we've already computed it once 118 if (_isUsingEmbeddedManifest != nil) { 119 return [_isUsingEmbeddedManifest boolValue]; 120 } 121 122 _isUsingEmbeddedManifest = @NO; 123 124 if ([super isUsingEmbeddedResource]) { 125 _isUsingEmbeddedManifest = @YES; 126 } else { 127 NSString *cachePath = [self resourceCachePath]; 128 NSString *bundlePath = [self resourceBundlePath]; 129 if (bundlePath) { 130 // we cannot assume the cached manifest is newer than the embedded one, so we need to read both 131 NSData *cachedData = [NSData dataWithContentsOfFile:cachePath]; 132 NSData *embeddedData = [NSData dataWithContentsOfFile:bundlePath]; 133 134 NSError *jsonErrorCached, *jsonErrorEmbedded; 135 id cachedManifest, embeddedManifest; 136 if (cachedData) { 137 cachedManifest = [NSJSONSerialization JSONObjectWithData:cachedData options:kNilOptions error:&jsonErrorCached]; 138 } 139 if (embeddedData) { 140 embeddedManifest = [NSJSONSerialization JSONObjectWithData:embeddedData options:kNilOptions error:&jsonErrorEmbedded]; 141 } 142 143 if (!jsonErrorCached && !jsonErrorEmbedded && [self _isUsingEmbeddedManifest:embeddedManifest withCachedManifest:cachedManifest]) { 144 _isUsingEmbeddedManifest = @YES; 145 } 146 } 147 } 148 return [_isUsingEmbeddedManifest boolValue]; 149} 150 151- (BOOL)_isUsingEmbeddedManifest:(id)embeddedManifest withCachedManifest:(id)cachedManifest 152{ 153 NSDate *embeddedPublishDate = [self _publishedDateFromManifest:embeddedManifest]; 154 NSDate *cachedPublishDate; 155 156 if (cachedManifest) { 157 // cached manifests are signed so we have to parse the inner manifest 158 NSString *cachedManifestString = cachedManifest[@"manifestString"]; 159 NSDictionary *innerCachedManifest; 160 if (!cachedManifestString) { 161 innerCachedManifest = cachedManifest; 162 } else { 163 NSError *jsonError; 164 innerCachedManifest = [NSJSONSerialization JSONObjectWithData:[cachedManifestString dataUsingEncoding:kCFStringEncodingUTF8] 165 options:kNilOptions 166 error:&jsonError]; 167 if (jsonError) { 168 // just resolve with NO for now, we'll catch this error later on 169 return NO; 170 } 171 } 172 cachedPublishDate = [self _publishedDateFromManifest:innerCachedManifest]; 173 } 174 if (embeddedPublishDate && cachedPublishDate && [embeddedPublishDate compare:cachedPublishDate] == NSOrderedDescending) { 175 return YES; 176 } 177 return NO; 178} 179 180- (NSDate * _Nullable)_publishedDateFromManifest:(id)manifest 181{ 182 if (manifest) { 183 NSString *publishDateString = manifest[@"publishedTime"]; 184 if (publishDateString) { 185 return [RCTConvert NSDate:publishDateString]; 186 } 187 } 188 return nil; 189} 190 191+ (NSString *)cachePath 192{ 193 NSString *cachesDirectory = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject; 194 NSString *sourceDirectory = [cachesDirectory stringByAppendingPathComponent:@"Manifests"]; 195 196 BOOL cacheDirectoryExists = [[NSFileManager defaultManager] fileExistsAtPath:sourceDirectory isDirectory:nil]; 197 if (!cacheDirectoryExists) { 198 NSError *error; 199 BOOL created = [[NSFileManager defaultManager] createDirectoryAtPath:sourceDirectory 200 withIntermediateDirectories:YES 201 attributes:nil 202 error:&error]; 203 if (created) { 204 cacheDirectoryExists = YES; 205 } else { 206 DDLogError(@"Could not create source cache directory: %@", error.localizedDescription); 207 } 208 } 209 210 return (cacheDirectoryExists) ? sourceDirectory : nil; 211} 212 213- (BOOL)_isManifestVerificationBypassed 214{ 215 return ( 216 // HACK: because `SecItemCopyMatching` doesn't work in older iOS (see EXApiUtil.m) 217 ([UIDevice currentDevice].systemVersion.floatValue < 10) || 218 219 // the developer disabled manifest verification 220 [EXEnvironment sharedEnvironment].isManifestVerificationBypassed || 221 222 // we're using a copy that came with the NSBundle and was therefore already codesigned 223 [self isUsingEmbeddedResource] 224 ); 225} 226 227- (NSError *)_validateResponseData:(NSData *)data response:(NSURLResponse *)response 228{ 229 if (response && [response isKindOfClass:[NSHTTPURLResponse class]]) { 230 NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; 231 NSDictionary *headers = httpResponse.allHeaderFields; 232 233 // pass the Exponent-Server header to Amplitude if it exists. 234 // this is generated only from XDE and exp while serving local bundles. 235 NSString *serverHeaderJson = headers[@"Exponent-Server"]; 236 if (serverHeaderJson) { 237 NSError *jsonError; 238 NSDictionary *serverHeader = [NSJSONSerialization JSONObjectWithData:[serverHeaderJson dataUsingEncoding:NSUTF8StringEncoding] options:0 error:&jsonError]; 239 if (serverHeader && !jsonError) { 240 [[EXAnalytics sharedInstance] logEvent:@"LOAD_DEVELOPER_MANIFEST" manifestUrl:response.URL eventProperties:serverHeader]; 241 } 242 } 243 } 244 // indicate that the response is valid 245 return nil; 246} 247 248- (NSError *)_verifyManifestSdkVersion:(NSDictionary *)maybeManifest 249{ 250 NSString *errorCode; 251 if (maybeManifest && maybeManifest[@"sdkVersion"]) { 252 if (![maybeManifest[@"sdkVersion"] isEqualToString:@"UNVERSIONED"]) { 253 NSInteger manifestSdkVersion = [maybeManifest[@"sdkVersion"] integerValue]; 254 if (manifestSdkVersion) { 255 NSInteger oldestSdkVersion = [[self _earliestSdkVersionSupported] integerValue]; 256 NSInteger newestSdkVersion = [[self _latestSdkVersionSupported] integerValue]; 257 if (manifestSdkVersion < oldestSdkVersion) { 258 errorCode = @"EXPERIENCE_SDK_VERSION_OUTDATED"; 259 } 260 if (manifestSdkVersion > newestSdkVersion) { 261 errorCode = @"EXPERIENCE_SDK_VERSION_TOO_NEW"; 262 } 263 } else { 264 errorCode = @"MALFORMED_SDK_VERSION"; 265 } 266 } 267 } else { 268 errorCode = @"NO_SDK_VERSION_SPECIFIED"; 269 } 270 if (errorCode) { 271 // will be handled by _validateErrorData: 272 return [self _formatError:[NSError errorWithDomain:EXNetworkErrorDomain code:0 userInfo:@{ 273 @"errorCode": errorCode, 274 }]]; 275 } else { 276 return nil; 277 } 278} 279 280- (NSError *)_validateErrorData:(NSError *)error response:(NSURLResponse *)response 281{ 282 NSError *formattedError; 283 if ([response isKindOfClass:[NSHTTPURLResponse class]]) { 284 // we got back a response from the server, and we can use the info we got back to make a nice 285 // error message for the user 286 287 formattedError = [self _formatError:error]; 288 } else { 289 // was a network error 290 NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo]; 291 userInfo[@"errorCode"] = @"NETWORK_ERROR"; 292 formattedError = [NSError errorWithDomain:EXNetworkErrorDomain code:error.code userInfo:userInfo]; 293 } 294 295 return [super _validateErrorData:formattedError response:response]; 296} 297 298- (NSString *)_earliestSdkVersionSupported 299{ 300 NSArray *clientSDKVersionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"]; 301 return [clientSDKVersionsAvailable firstObject]; // TODO: this is bad, we can't guarantee this array will always be ordered properly. 302} 303 304- (NSString *)_latestSdkVersionSupported 305{ 306 NSArray *clientSDKVersionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"]; 307 return [clientSDKVersionsAvailable lastObject]; // TODO: this is bad, we can't guarantee this array will always be ordered properly. 308} 309 310- (NSError *)_formatError:(NSError *)error 311{ 312 NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo]; 313 NSString *errorCode = userInfo[@"errorCode"]; 314 NSString *rawMessage = [error localizedDescription]; 315 316 NSString *formattedMessage = [NSString stringWithFormat:@"Could not load %@.", self.originalUrl]; 317 if ([errorCode isEqualToString:@"EXPERIENCE_NOT_FOUND"] 318 || [errorCode isEqualToString:@"EXPERIENCE_NOT_PUBLISHED_ERROR"] 319 || [errorCode isEqualToString:@"EXPERIENCE_RELEASE_NOT_FOUND_ERROR"]) { 320 formattedMessage = [NSString stringWithFormat:@"No experience found at %@.", self.originalUrl]; 321 } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_OUTDATED"]) { 322 NSDictionary *metadata = userInfo[@"metadata"]; 323 NSArray *availableSDKVersions = metadata[@"availableSDKVersions"]; 324 NSString *sdkVersionRequired = [availableSDKVersions firstObject]; 325 326 NSString *earliestSDKVersion = [self _earliestSdkVersionSupported]; 327 formattedMessage = [NSString stringWithFormat:@"The experience you requested uses Expo SDK v%@, but this copy of Expo Client " 328 "requires at least v%@. The author should update their experience to a newer Expo SDK version.", sdkVersionRequired, earliestSDKVersion]; 329 } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_TOO_NEW"]) { 330 formattedMessage = @"The experience you requested requires a newer version of the Expo Client app. Please download the latest version from the App Store."; 331 } else if ([errorCode isEqualToString:@"USER_SNACK_NOT_FOUND"] || [errorCode isEqualToString:@"SNACK_NOT_FOUND"]) { 332 formattedMessage = [NSString stringWithFormat:@"No snack found at %@.", self.originalUrl]; 333 } else if ([errorCode isEqualToString:@"SNACK_RUNTIME_NOT_RELEASE"]) { 334 formattedMessage = rawMessage; // From server: `The Snack runtime for corresponding sdk version of this Snack ("${sdkVersions[0]}") is not released.`, 335 } else if ([errorCode isEqualToString:@"SNACK_NOT_FOUND_FOR_SDK_VERSIONS"]) { 336 formattedMessage = rawMessage; // From server: `The snack "${fullName}" was found, but wasn't released for platform "${platform}" and sdk version "${sdkVersions[0]}".` 337 } 338 userInfo[NSLocalizedDescriptionKey] = formattedMessage; 339 340 return [NSError errorWithDomain:EXNetworkErrorDomain code:error.code userInfo:userInfo]; 341} 342 343@end 344