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