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