1// Copyright 2015-present 650 Industries. All rights reserved. 2 3#import "EXAppLoader.h" 4#import "EXEnvironment.h" 5#import "EXKernel.h" 6#import "EXKernelLinkingManager.h" 7#import "ExpoKit.h" 8#import "EXReactAppManager.h" 9 10#import <CocoaLumberjack/CocoaLumberjack.h> 11#import <React/RCTBridge+Private.h> 12 13NSString *kEXExpoDeepLinkSeparator = @"--/"; 14NSString *kEXExpoLegacyDeepLinkSeparator = @"+"; 15 16@interface EXKernelLinkingManager () 17 18@property (nonatomic, weak) EXReactAppManager *appManagerToRefresh; 19 20@end 21 22@implementation EXKernelLinkingManager 23 24- (void)openUrl:(NSString *)urlString isUniversalLink:(BOOL)isUniversalLink 25{ 26 NSURL *url = [NSURL URLWithString:urlString]; 27 if (!url) { 28 DDLogInfo(@"Tried to route invalid url: %@", urlString); 29 return; 30 } 31 EXKernelAppRegistry *appRegistry = [EXKernel sharedInstance].appRegistry; 32 EXKernelAppRecord *destinationApp = nil; 33 NSURL *urlToRoute = url; 34 35 if (isUniversalLink && [EXEnvironment sharedEnvironment].isDetached) { 36 destinationApp = [EXKernel sharedInstance].appRegistry.standaloneAppRecord; 37 } else { 38 urlToRoute = [[self class] uriTransformedForLinking:url isUniversalLink:isUniversalLink]; 39 40 if (appRegistry.standaloneAppRecord) { 41 destinationApp = appRegistry.standaloneAppRecord; 42 } else { 43 for (NSString *recordId in [appRegistry appEnumerator]) { 44 EXKernelAppRecord *appRecord = [appRegistry recordForId:recordId]; 45 if (!appRecord || appRecord.status != kEXKernelAppRecordStatusRunning) { 46 continue; 47 } 48 if (appRecord.appLoader.manifestUrl && [[self class] _isUrl:urlToRoute deepLinkIntoAppWithManifestUrl:appRecord.appLoader.manifestUrl]) { 49 // this is a link into a bridge we already have running. 50 // use this bridge as the link's destination instead of the kernel. 51 destinationApp = appRecord; 52 break; 53 } 54 } 55 } 56 } 57 58 if (destinationApp) { 59 [[EXKernel sharedInstance] sendUrl:urlToRoute.absoluteString toAppRecord:destinationApp]; 60 } else { 61 if (![EXEnvironment sharedEnvironment].isDetached 62 && [EXKernel sharedInstance].appRegistry.homeAppRecord 63 && [EXKernel sharedInstance].appRegistry.homeAppRecord.appManager.status == kEXReactAppManagerStatusRunning) { 64 // if Home is present and running, open a new app with this url. 65 // if home isn't running yet, we'll handle the LaunchOptions url after home finishes launching. 66 [[EXKernel sharedInstance] createNewAppWithUrl:urlToRoute initialProps:nil]; 67 } 68 } 69} 70 71#pragma mark - scoped module delegate 72 73- (void)linkingModule:(__unused id)linkingModule didOpenUrl:(NSString *)url 74{ 75 [self openUrl:url isUniversalLink:NO]; 76} 77 78- (BOOL)linkingModule:(__unused id)linkingModule shouldOpenExpoUrl:(NSURL *)url 79{ 80 // do not attempt to route internal exponent links at all if we're in a detached app. 81 if ([EXEnvironment sharedEnvironment].isDetached) { 82 return NO; 83 } 84 85 // we don't need to explicitly include a standalone app custom URL scheme here 86 // because the default iOS linking behavior will still hand those links back to Exponent. 87 NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:YES]; 88 if (components) { 89 return ([components.scheme isEqualToString:@"exp"] || 90 [components.scheme isEqualToString:@"exps"] || 91 [[self class] _isExpoHostedUrlComponents:components] 92 ); 93 } 94 return NO; 95} 96 97#pragma mark - internal 98 99#pragma mark - static link transforming logic 100 101+ (NSString *)linkingUriForExperienceUri:(NSURL *)uri useLegacy:(BOOL)useLegacy 102{ 103 uri = [self uriTransformedForLinking:uri isUniversalLink:NO]; 104 if (!uri) { 105 return nil; 106 } 107 NSURLComponents *components = [NSURLComponents componentsWithURL:uri resolvingAgainstBaseURL:YES]; 108 109 // if the provided uri is the standalone app manifest uri, 110 // this should have been transformed into customscheme://deep-link 111 // and then all we do here is strip off the deep-link part. 112 if ([EXEnvironment sharedEnvironment].isDetached && [[EXEnvironment sharedEnvironment] isStandaloneUrlScheme:components.scheme]) { 113 if (useLegacy) { 114 return [NSString stringWithFormat:@"%@://%@", components.scheme, kEXExpoLegacyDeepLinkSeparator]; 115 } else { 116 return [NSString stringWithFormat:@"%@://", components.scheme]; 117 } 118 } 119 120 NSMutableString* path = [NSMutableString stringWithString:components.path]; 121 122 // if the uri already contains a deep link, strip everything specific to that 123 path = [[self stringByRemovingDeepLink:path] mutableCopy]; 124 125 if (path.length == 0 || [path characterAtIndex:path.length - 1] != '/') { 126 [path appendString:@"/"]; 127 } 128 // since this is used in a few places we need to keep the legacy option around for compat 129 if (useLegacy) { 130 [path appendString:kEXExpoLegacyDeepLinkSeparator]; 131 } else if ([[self class] _isExpoHostedUrlComponents:components]) { 132 [path appendString:kEXExpoDeepLinkSeparator]; 133 } 134 components.path = path; 135 136 components.query = nil; 137 138 return [components string]; 139} 140 141+ (NSString *)stringByRemovingDeepLink:(NSString *)path 142{ 143 NSRange deepLinkRange = [path rangeOfString:kEXExpoDeepLinkSeparator]; 144 // deprecated but we still need to support these links 145 // TODO: remove this 146 NSRange deepLinkRangeLegacy = [path rangeOfString:kEXExpoLegacyDeepLinkSeparator]; 147 if (deepLinkRange.length > 0) { 148 path = [path substringToIndex:deepLinkRange.location]; 149 } else if (deepLinkRangeLegacy.length > 0) { 150 path = [path substringToIndex:deepLinkRangeLegacy.location]; 151 } 152 return path; 153} 154 155+ (NSURL *)uriTransformedForLinking:(NSURL *)uri isUniversalLink:(BOOL)isUniversalLink 156{ 157 if (!uri) { 158 return nil; 159 } 160 161 // If the initial uri is a universal link in a standalone app don't touch it. 162 if ([EXEnvironment sharedEnvironment].isDetached && isUniversalLink) { 163 return uri; 164 } 165 166 NSURL *normalizedUri = [self _uriNormalizedForLinking:uri]; 167 168 if ([EXEnvironment sharedEnvironment].isDetached && [EXEnvironment sharedEnvironment].hasUrlScheme) { 169 // if the provided uri is the standalone app manifest uri, 170 // transform this into customscheme://deep-link 171 if ([self _isStandaloneManifestUrl:normalizedUri]) { 172 NSString *uriString = normalizedUri.absoluteString; 173 NSRange deepLinkRange = [uriString rangeOfString:kEXExpoDeepLinkSeparator]; 174 // deprecated but we still need to support these links 175 // TODO: remove this 176 NSRange deepLinkRangeLegacy = [uriString rangeOfString:kEXExpoLegacyDeepLinkSeparator]; 177 NSString *deepLink = @""; 178 if (deepLinkRange.length > 0 && [[self class] isExpoHostedUrl:normalizedUri]) { 179 deepLink = [uriString substringFromIndex:deepLinkRange.location + kEXExpoDeepLinkSeparator.length]; 180 } else if (deepLinkRangeLegacy.length > 0) { 181 deepLink = [uriString substringFromIndex:deepLinkRangeLegacy.location + kEXExpoLegacyDeepLinkSeparator.length]; 182 } 183 NSString *result = [NSString stringWithFormat:@"%@://%@", [EXEnvironment sharedEnvironment].urlScheme, deepLink]; 184 return [NSURL URLWithString:result]; 185 } 186 } 187 return normalizedUri; 188} 189 190+ (NSURL *)initialUriWithManifestUrl:(NSURL *)manifestUrl 191{ 192 NSURL *urlToTransform = manifestUrl; 193 if ([EXEnvironment sharedEnvironment].isDetached) { 194 NSDictionary *launchOptions = [ExpoKit sharedInstance].launchOptions; 195 NSURL *launchOptionsUrl = [[self class] initialUrlFromLaunchOptions:launchOptions]; 196 if (launchOptionsUrl) { 197 urlToTransform = launchOptionsUrl; 198 } 199 } 200 return [[self class] uriTransformedForLinking:urlToTransform isUniversalLink:NO]; 201} 202 203+ (NSURL *)_uriNormalizedForLinking: (NSURL *)uri 204{ 205 NSURLComponents *components = [NSURLComponents componentsWithURL:uri resolvingAgainstBaseURL:YES]; 206 207 if ([EXEnvironment sharedEnvironment].isDetached && [[EXEnvironment sharedEnvironment] isStandaloneUrlScheme:components.scheme]) { 208 // if we're standalone and this uri had the standalone scheme, leave it alone. 209 } else { 210 if ([components.scheme isEqualToString:@"https"] || [components.scheme isEqualToString:@"exps"]) { 211 components.scheme = @"exps"; 212 } else { 213 components.scheme = @"exp"; 214 } 215 } 216 217 if ([components.scheme isEqualToString:@"exp"] && [components.port integerValue] == 80) { 218 components.port = nil; 219 } else if ([components.scheme isEqualToString:@"exps"] && [components.port integerValue] == 443) { 220 components.port = nil; 221 } 222 223 return [components URL]; 224} 225 226+ (BOOL)_isStandaloneManifestUrl: (NSURL *)normalizedUri 227{ 228 NSString *uriString = normalizedUri.absoluteString; 229 for (NSString *manifestUrl in [EXEnvironment sharedEnvironment].allManifestUrls) { 230 NSURL *normalizedManifestURL = [self _uriNormalizedForLinking:[NSURL URLWithString:manifestUrl]]; 231 if ([normalizedManifestURL.absoluteString isEqualToString:uriString]) { 232 return YES; 233 } 234 } 235 return NO; 236} 237 238+ (BOOL)isExpoHostedUrl: (NSURL *)url 239{ 240 return [[self class] _isExpoHostedUrlComponents:[NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:YES]]; 241} 242 243+ (BOOL)_isExpoHostedUrlComponents: (NSURLComponents *)components 244{ 245 if (components.host) { 246 return [components.host isEqualToString:@"exp.host"] || 247 [components.host isEqualToString:@"expo.io"] || 248 [components.host isEqualToString:@"exp.direct"] || 249 [components.host isEqualToString:@"expo.test"] || 250 [components.host hasSuffix:@".exp.host"] || 251 [components.host hasSuffix:@".exp.direct"] || 252 [components.host hasSuffix:@".expo.test"]; 253 } 254 return NO; 255} 256 257+ (BOOL)_isUrl:(NSURL *)urlToRoute deepLinkIntoAppWithManifestUrl:(NSURL *)manifestUrl 258{ 259 NSURLComponents *urlToRouteComponents = [NSURLComponents componentsWithURL:urlToRoute resolvingAgainstBaseURL:YES]; 260 NSURLComponents *manifestUrlComponents = [NSURLComponents componentsWithURL:[self uriTransformedForLinking:manifestUrl isUniversalLink:NO] resolvingAgainstBaseURL:YES]; 261 262 if (urlToRouteComponents.host && manifestUrlComponents.host && [urlToRouteComponents.host isEqualToString:manifestUrlComponents.host]) { 263 if ((!urlToRouteComponents.port && !manifestUrlComponents.port) || (urlToRouteComponents.port && [urlToRouteComponents.port isEqualToNumber:manifestUrlComponents.port])) { 264 NSString *urlToRouteBasePath = [[self class] _normalizePath:urlToRouteComponents.path]; 265 NSString *manifestUrlBasePath = [[self class] _normalizePath:manifestUrlComponents.path]; 266 267 if ([urlToRouteBasePath isEqualToString:manifestUrlBasePath]) { 268 // release-channel is a special query parameter that we treat as a separate app, so we need to check that here 269 NSString *manifestUrlReleaseChannel = [[self class] _releaseChannelWithUrlComponents:manifestUrlComponents]; 270 NSString *urlToRouteReleaseChannel = [[self class] _releaseChannelWithUrlComponents:urlToRouteComponents]; 271 if ([manifestUrlReleaseChannel isEqualToString:urlToRouteReleaseChannel]) { 272 return YES; 273 } 274 } 275 } 276 } 277 return NO; 278} 279 280+ (NSString *)_normalizePath:(NSString *)path 281{ 282 if (!path) { 283 return @"/"; 284 } 285 NSString *basePath = [[self class] stringByRemovingDeepLink:path]; 286 NSMutableString *mutablePath = [basePath mutableCopy]; 287 if (mutablePath.length == 0 || [mutablePath characterAtIndex:mutablePath.length - 1] != '/') { 288 [mutablePath appendString:@"/"]; 289 } 290 return mutablePath; 291} 292 293+ (NSString *)_releaseChannelWithUrlComponents:(NSURLComponents *)urlComponents 294{ 295 NSString *releaseChannel = @"default"; 296 NSArray<NSURLQueryItem *> *queryItems = urlComponents.queryItems; 297 if (queryItems) { 298 for (NSURLQueryItem *item in queryItems) { 299 if ([item.name isEqualToString:@"release-channel"]) { 300 releaseChannel = item.value; 301 } 302 } 303 } 304 return releaseChannel; 305} 306 307#pragma mark - UIApplication hooks 308 309+ (BOOL)application:(UIApplication *)application 310 openURL:(NSURL *)URL 311 sourceApplication:(NSString *)sourceApplication 312 annotation:(id)annotation 313{ 314 [[EXKernel sharedInstance].serviceRegistry.linkingManager openUrl:URL.absoluteString isUniversalLink:NO]; 315 return YES; 316} 317 318+ (BOOL)application:(UIApplication *)application 319continueUserActivity:(NSUserActivity *)userActivity 320 restorationHandler:(void (^)(NSArray *))restorationHandler 321{ 322 if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { 323 [[EXKernel sharedInstance].serviceRegistry.linkingManager openUrl:userActivity.webpageURL.absoluteString isUniversalLink:YES]; 324 325 } 326 return YES; 327} 328 329+ (NSURL *)initialUrlFromLaunchOptions:(NSDictionary *)launchOptions 330{ 331 NSURL *initialUrl; 332 333 if (launchOptions) { 334 if (launchOptions[UIApplicationLaunchOptionsURLKey]) { 335 initialUrl = launchOptions[UIApplicationLaunchOptionsURLKey]; 336 } else if (launchOptions[UIApplicationLaunchOptionsUserActivityDictionaryKey]) { 337 NSDictionary *userActivityDictionary = launchOptions[UIApplicationLaunchOptionsUserActivityDictionaryKey]; 338 339 if ([userActivityDictionary[UIApplicationLaunchOptionsUserActivityTypeKey] isEqual:NSUserActivityTypeBrowsingWeb]) { 340 initialUrl = ((NSUserActivity *)userActivityDictionary[@"UIApplicationLaunchOptionsUserActivityKey"]).webpageURL; 341 } 342 } 343 } 344 345 return initialUrl; 346} 347 348@end 349