1// Copyright 2015-present 650 Industries. All rights reserved. 2 3#import "EXAbstractLoader.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 if (@available(iOS 14, *)) { 69 // Try to detect if we're trying to open a local network URL so we can preemptively show the 70 // Local Network permission prompt -- otherwise the network request will fail before the user 71 // has time to accept or reject the permission. 72 NSString *host = urlToRoute.host; 73 if ([host hasPrefix:@"192.168."] || [host hasPrefix:@"172."] || [host hasPrefix:@"10."]) { 74 // We want to trigger the local network permission dialog. However, the iOS API doesn't expose a way to do it. 75 // But we can use system functionality that needs this permission to trigger prompt. 76 // See https://stackoverflow.com/questions/63940427/ios-14-how-to-trigger-local-network-dialog-and-check-user-answer 77 static dispatch_once_t once; 78 dispatch_once(&once, ^{ 79 [[NSProcessInfo processInfo] hostName]; 80 }); 81 } 82 } 83 84 // Since this method might have been called on any thread, 85 // let's make sure we create a new app on the main queue. 86 RCTExecuteOnMainQueue(^{ 87 [[EXKernel sharedInstance] createNewAppWithUrl:urlToRoute initialProps:nil]; 88 }); 89 } 90 } 91} 92 93#pragma mark - scoped module delegate 94 95- (void)linkingModule:(__unused id)linkingModule didOpenUrl:(NSString *)url 96{ 97 [self openUrl:url isUniversalLink:NO]; 98} 99 100- (BOOL)linkingModule:(__unused id)linkingModule shouldOpenExpoUrl:(NSURL *)url 101{ 102 // do not attempt to route internal exponent links at all if we're in a detached app. 103 if ([EXEnvironment sharedEnvironment].isDetached) { 104 return NO; 105 } 106 107 // we don't need to explicitly include a standalone app custom URL scheme here 108 // because the default iOS linking behavior will still hand those links back to Exponent. 109 NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:YES]; 110 if (components) { 111 return ([components.scheme isEqualToString:@"exp"] || 112 [components.scheme isEqualToString:@"exps"] || 113 [[self class] _isExpoHostedUrlComponents:components] 114 ); 115 } 116 return NO; 117} 118 119#pragma mark - internal 120 121#pragma mark - static link transforming logic 122 123+ (NSString *)linkingUriForExperienceUri:(NSURL *)uri useLegacy:(BOOL)useLegacy 124{ 125 uri = [self uriTransformedForLinking:uri isUniversalLink:NO]; 126 if (!uri) { 127 return nil; 128 } 129 NSURLComponents *components = [NSURLComponents componentsWithURL:uri resolvingAgainstBaseURL:YES]; 130 131 // if the provided uri is the standalone app manifest uri, 132 // this should have been transformed into customscheme://deep-link 133 // and then all we do here is strip off the deep-link part. 134 if ([EXEnvironment sharedEnvironment].isDetached && [[EXEnvironment sharedEnvironment] isStandaloneUrlScheme:components.scheme]) { 135 if (useLegacy) { 136 return [NSString stringWithFormat:@"%@://%@", components.scheme, kEXExpoLegacyDeepLinkSeparator]; 137 } else { 138 return [NSString stringWithFormat:@"%@://", components.scheme]; 139 } 140 } 141 142 NSMutableString* path = [NSMutableString stringWithString:components.path]; 143 144 // if the uri already contains a deep link, strip everything specific to that 145 path = [[self stringByRemovingDeepLink:path] mutableCopy]; 146 147 if (path.length == 0 || [path characterAtIndex:path.length - 1] != '/') { 148 [path appendString:@"/"]; 149 } 150 // since this is used in a few places we need to keep the legacy option around for compat 151 if (useLegacy) { 152 [path appendString:kEXExpoLegacyDeepLinkSeparator]; 153 } else if ([[self class] _isExpoHostedUrlComponents:components]) { 154 [path appendString:kEXExpoDeepLinkSeparator]; 155 } 156 components.path = path; 157 158 components.query = nil; 159 160 return [components string]; 161} 162 163+ (NSString *)stringByRemovingDeepLink:(NSString *)path 164{ 165 NSRange deepLinkRange = [path rangeOfString:kEXExpoDeepLinkSeparator]; 166 // deprecated but we still need to support these links 167 // TODO: remove this 168 NSRange deepLinkRangeLegacy = [path rangeOfString:kEXExpoLegacyDeepLinkSeparator]; 169 if (deepLinkRange.length > 0) { 170 path = [path substringToIndex:deepLinkRange.location]; 171 } else if (deepLinkRangeLegacy.length > 0) { 172 path = [path substringToIndex:deepLinkRangeLegacy.location]; 173 } 174 return path; 175} 176 177+ (NSURL *)uriTransformedForLinking:(NSURL *)uri isUniversalLink:(BOOL)isUniversalLink 178{ 179 if (!uri) { 180 return nil; 181 } 182 183 // If the initial uri is a universal link in a standalone app don't touch it. 184 if ([EXEnvironment sharedEnvironment].isDetached && isUniversalLink) { 185 return uri; 186 } 187 188 NSURL *normalizedUri = [self _uriNormalizedForLinking:uri]; 189 190 if ([EXEnvironment sharedEnvironment].isDetached && [EXEnvironment sharedEnvironment].hasUrlScheme) { 191 // if the provided uri is the standalone app manifest uri, 192 // transform this into customscheme://deep-link 193 if ([self _isStandaloneManifestUrl:normalizedUri]) { 194 NSString *uriString = normalizedUri.absoluteString; 195 NSRange deepLinkRange = [uriString rangeOfString:kEXExpoDeepLinkSeparator]; 196 // deprecated but we still need to support these links 197 // TODO: remove this 198 NSRange deepLinkRangeLegacy = [uriString rangeOfString:kEXExpoLegacyDeepLinkSeparator]; 199 NSString *deepLink = @""; 200 if (deepLinkRange.length > 0 && [[self class] isExpoHostedUrl:normalizedUri]) { 201 deepLink = [uriString substringFromIndex:deepLinkRange.location + kEXExpoDeepLinkSeparator.length]; 202 } else if (deepLinkRangeLegacy.length > 0) { 203 deepLink = [uriString substringFromIndex:deepLinkRangeLegacy.location + kEXExpoLegacyDeepLinkSeparator.length]; 204 } 205 NSString *result = [NSString stringWithFormat:@"%@://%@", [EXEnvironment sharedEnvironment].urlScheme, deepLink]; 206 return [NSURL URLWithString:result]; 207 } 208 } 209 return normalizedUri; 210} 211 212+ (NSURL *)initialUriWithManifestUrl:(NSURL *)manifestUrl 213{ 214 NSURL *urlToTransform = manifestUrl; 215 if ([EXEnvironment sharedEnvironment].isDetached) { 216 NSDictionary *launchOptions = [ExpoKit sharedInstance].launchOptions; 217 NSURL *launchOptionsUrl = [[self class] initialUrlFromLaunchOptions:launchOptions]; 218 if (!launchOptionsUrl) { 219 return nil; 220 } 221 urlToTransform = launchOptionsUrl; 222 } 223 224 NSURLComponents *urlComponents = [NSURLComponents componentsWithURL:urlToTransform resolvingAgainstBaseURL:YES]; 225 226 return [[self class] uriTransformedForLinking:urlToTransform isUniversalLink:[urlComponents.scheme isEqualToString:@"https"]]; 227} 228 229+ (NSURL *)_uriNormalizedForLinking: (NSURL *)uri 230{ 231 NSURLComponents *components = [NSURLComponents componentsWithURL:uri resolvingAgainstBaseURL:YES]; 232 233 if ([EXEnvironment sharedEnvironment].isDetached && [[EXEnvironment sharedEnvironment] isStandaloneUrlScheme:components.scheme]) { 234 // if we're standalone and this uri had the standalone scheme, leave it alone. 235 } else { 236 if ([components.scheme isEqualToString:@"https"] || [components.scheme isEqualToString:@"exps"]) { 237 components.scheme = @"exps"; 238 } else { 239 components.scheme = @"exp"; 240 } 241 } 242 243 if ([components.scheme isEqualToString:@"exp"] && [components.port integerValue] == 80) { 244 components.port = nil; 245 } else if ([components.scheme isEqualToString:@"exps"] && [components.port integerValue] == 443) { 246 components.port = nil; 247 } 248 249 return [components URL]; 250} 251 252+ (BOOL)_isStandaloneManifestUrl: (NSURL *)normalizedUri 253{ 254 NSString *uriString = normalizedUri.absoluteString; 255 for (NSString *manifestUrl in [EXEnvironment sharedEnvironment].allManifestUrls) { 256 NSURL *normalizedManifestURL = [self _uriNormalizedForLinking:[NSURL URLWithString:manifestUrl]]; 257 if ([normalizedManifestURL.absoluteString isEqualToString:uriString]) { 258 return YES; 259 } 260 } 261 return NO; 262} 263 264+ (BOOL)isExpoHostedUrl: (NSURL *)url 265{ 266 return [[self class] _isExpoHostedUrlComponents:[NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:YES]]; 267} 268 269+ (BOOL)_isExpoHostedUrlComponents: (NSURLComponents *)components 270{ 271 if (components.host) { 272 return [components.host isEqualToString:@"exp.host"] || 273 [components.host isEqualToString:@"expo.io"] || 274 [components.host isEqualToString:@"exp.direct"] || 275 [components.host isEqualToString:@"expo.test"] || 276 [components.host hasSuffix:@".exp.host"] || 277 [components.host hasSuffix:@".exp.direct"] || 278 [components.host hasSuffix:@".expo.test"]; 279 } 280 return NO; 281} 282 283+ (BOOL)_isUrl:(NSURL *)urlToRoute deepLinkIntoAppWithManifestUrl:(NSURL *)manifestUrl 284{ 285 NSURLComponents *urlToRouteComponents = [NSURLComponents componentsWithURL:urlToRoute resolvingAgainstBaseURL:YES]; 286 NSURLComponents *manifestUrlComponents = [NSURLComponents componentsWithURL:[self uriTransformedForLinking:manifestUrl isUniversalLink:NO] resolvingAgainstBaseURL:YES]; 287 288 if (urlToRouteComponents.host && manifestUrlComponents.host && [urlToRouteComponents.host isEqualToString:manifestUrlComponents.host]) { 289 if ((!urlToRouteComponents.port && !manifestUrlComponents.port) || (urlToRouteComponents.port && [urlToRouteComponents.port isEqualToNumber:manifestUrlComponents.port])) { 290 NSString *urlToRouteBasePath = [[self class] _normalizePath:urlToRouteComponents.path]; 291 NSString *manifestUrlBasePath = [[self class] _normalizePath:manifestUrlComponents.path]; 292 293 if ([urlToRouteBasePath isEqualToString:manifestUrlBasePath]) { 294 // release-channel is a special query parameter that we treat as a separate app, so we need to check that here 295 NSString *manifestUrlReleaseChannel = [[self class] releaseChannelWithUrlComponents:manifestUrlComponents]; 296 NSString *urlToRouteReleaseChannel = [[self class] releaseChannelWithUrlComponents:urlToRouteComponents]; 297 if ([manifestUrlReleaseChannel isEqualToString:urlToRouteReleaseChannel]) { 298 return YES; 299 } 300 } 301 } 302 } 303 return NO; 304} 305 306+ (NSString *)_normalizePath:(NSString *)path 307{ 308 if (!path) { 309 return @"/"; 310 } 311 NSString *basePath = [[self class] stringByRemovingDeepLink:path]; 312 NSMutableString *mutablePath = [basePath mutableCopy]; 313 if (mutablePath.length == 0 || [mutablePath characterAtIndex:mutablePath.length - 1] != '/') { 314 [mutablePath appendString:@"/"]; 315 } 316 return mutablePath; 317} 318 319+ (NSString *)releaseChannelWithUrlComponents:(NSURLComponents *)urlComponents 320{ 321 NSString *releaseChannel = @"default"; 322 NSArray<NSURLQueryItem *> *queryItems = urlComponents.queryItems; 323 if (queryItems) { 324 for (NSURLQueryItem *item in queryItems) { 325 if ([item.name isEqualToString:@"release-channel"]) { 326 releaseChannel = item.value; 327 } 328 } 329 } 330 return releaseChannel; 331} 332 333#pragma mark - UIApplication hooks 334 335+ (BOOL)application:(UIApplication *)application 336 openURL:(NSURL *)URL 337 sourceApplication:(NSString *)sourceApplication 338 annotation:(id)annotation 339{ 340 [[EXKernel sharedInstance].serviceRegistry.linkingManager openUrl:URL.absoluteString isUniversalLink:NO]; 341 return YES; 342} 343 344+ (BOOL)application:(UIApplication *)application 345continueUserActivity:(NSUserActivity *)userActivity 346 restorationHandler:(void (^)(NSArray *))restorationHandler 347{ 348 if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { 349 [[EXKernel sharedInstance].serviceRegistry.linkingManager openUrl:userActivity.webpageURL.absoluteString isUniversalLink:YES]; 350 351 } 352 return YES; 353} 354 355+ (NSURL *)initialUrlFromLaunchOptions:(NSDictionary *)launchOptions 356{ 357 NSURL *initialUrl; 358 359 if (launchOptions) { 360 if (launchOptions[UIApplicationLaunchOptionsURLKey]) { 361 initialUrl = launchOptions[UIApplicationLaunchOptionsURLKey]; 362 } else if (launchOptions[UIApplicationLaunchOptionsUserActivityDictionaryKey]) { 363 NSDictionary *userActivityDictionary = launchOptions[UIApplicationLaunchOptionsUserActivityDictionaryKey]; 364 365 if ([userActivityDictionary[UIApplicationLaunchOptionsUserActivityTypeKey] isEqual:NSUserActivityTypeBrowsingWeb]) { 366 initialUrl = ((NSUserActivity *)userActivityDictionary[@"UIApplicationLaunchOptionsUserActivityKey"]).webpageURL; 367 } 368 } 369 } 370 371 return initialUrl; 372} 373 374@end 375