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      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      urlToTransform = launchOptionsUrl;
220    }
221  }
222
223  NSURLComponents *urlComponents = [NSURLComponents componentsWithURL:urlToTransform resolvingAgainstBaseURL:YES];
224
225  return [[self class] uriTransformedForLinking:urlToTransform isUniversalLink:[urlComponents.scheme isEqualToString:@"https"]];
226}
227
228+ (NSURL *)_uriNormalizedForLinking: (NSURL *)uri
229{
230  NSURLComponents *components = [NSURLComponents componentsWithURL:uri resolvingAgainstBaseURL:YES];
231
232  if ([EXEnvironment sharedEnvironment].isDetached && [[EXEnvironment sharedEnvironment] isStandaloneUrlScheme:components.scheme]) {
233    // if we're standalone and this uri had the standalone scheme, leave it alone.
234  } else {
235    if ([components.scheme isEqualToString:@"https"] || [components.scheme isEqualToString:@"exps"]) {
236      components.scheme = @"exps";
237    } else {
238      components.scheme = @"exp";
239    }
240  }
241
242  if ([components.scheme isEqualToString:@"exp"] && [components.port integerValue] == 80) {
243    components.port = nil;
244  } else if ([components.scheme isEqualToString:@"exps"] && [components.port integerValue] == 443) {
245    components.port = nil;
246  }
247
248  return [components URL];
249}
250
251+ (BOOL)_isStandaloneManifestUrl: (NSURL *)normalizedUri
252{
253  NSString *uriString = normalizedUri.absoluteString;
254  for (NSString *manifestUrl in [EXEnvironment sharedEnvironment].allManifestUrls) {
255    NSURL *normalizedManifestURL = [self _uriNormalizedForLinking:[NSURL URLWithString:manifestUrl]];
256    if ([normalizedManifestURL.absoluteString isEqualToString:uriString]) {
257      return YES;
258    }
259  }
260  return NO;
261}
262
263+ (BOOL)isExpoHostedUrl: (NSURL *)url
264{
265  return [[self class] _isExpoHostedUrlComponents:[NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:YES]];
266}
267
268+ (BOOL)_isExpoHostedUrlComponents: (NSURLComponents *)components
269{
270  if (components.host) {
271    return [components.host isEqualToString:@"exp.host"] ||
272      [components.host isEqualToString:@"expo.io"] ||
273      [components.host isEqualToString:@"exp.direct"] ||
274      [components.host isEqualToString:@"expo.test"] ||
275      [components.host hasSuffix:@".exp.host"] ||
276      [components.host hasSuffix:@".exp.direct"] ||
277      [components.host hasSuffix:@".expo.test"];
278  }
279  return NO;
280}
281
282+ (BOOL)_isUrl:(NSURL *)urlToRoute deepLinkIntoAppWithManifestUrl:(NSURL *)manifestUrl
283{
284  NSURLComponents *urlToRouteComponents = [NSURLComponents componentsWithURL:urlToRoute resolvingAgainstBaseURL:YES];
285  NSURLComponents *manifestUrlComponents = [NSURLComponents componentsWithURL:[self uriTransformedForLinking:manifestUrl isUniversalLink:NO] resolvingAgainstBaseURL:YES];
286
287  if (urlToRouteComponents.host && manifestUrlComponents.host && [urlToRouteComponents.host isEqualToString:manifestUrlComponents.host]) {
288    if ((!urlToRouteComponents.port && !manifestUrlComponents.port) || (urlToRouteComponents.port && [urlToRouteComponents.port isEqualToNumber:manifestUrlComponents.port])) {
289      NSString *urlToRouteBasePath = [[self class] _normalizePath:urlToRouteComponents.path];
290      NSString *manifestUrlBasePath = [[self class] _normalizePath:manifestUrlComponents.path];
291
292      if ([urlToRouteBasePath isEqualToString:manifestUrlBasePath]) {
293        // release-channel is a special query parameter that we treat as a separate app, so we need to check that here
294        NSString *manifestUrlReleaseChannel = [[self class] releaseChannelWithUrlComponents:manifestUrlComponents];
295        NSString *urlToRouteReleaseChannel = [[self class] releaseChannelWithUrlComponents:urlToRouteComponents];
296        if ([manifestUrlReleaseChannel isEqualToString:urlToRouteReleaseChannel]) {
297          return YES;
298        }
299      }
300    }
301  }
302  return NO;
303}
304
305+ (NSString *)_normalizePath:(NSString *)path
306{
307  if (!path) {
308    return @"/";
309  }
310  NSString *basePath = [[self class] stringByRemovingDeepLink:path];
311  NSMutableString *mutablePath = [basePath mutableCopy];
312  if (mutablePath.length == 0 || [mutablePath characterAtIndex:mutablePath.length - 1] != '/') {
313    [mutablePath appendString:@"/"];
314  }
315  return mutablePath;
316}
317
318+ (NSString *)releaseChannelWithUrlComponents:(NSURLComponents *)urlComponents
319{
320  NSString *releaseChannel = @"default";
321  NSArray<NSURLQueryItem *> *queryItems = urlComponents.queryItems;
322  if (queryItems) {
323    for (NSURLQueryItem *item in queryItems) {
324      if ([item.name isEqualToString:@"release-channel"]) {
325        releaseChannel = item.value;
326      }
327    }
328  }
329  return releaseChannel;
330}
331
332#pragma mark - UIApplication hooks
333
334+ (BOOL)application:(UIApplication *)application
335            openURL:(NSURL *)URL
336  sourceApplication:(NSString *)sourceApplication
337         annotation:(id)annotation
338{
339  [[EXKernel sharedInstance].serviceRegistry.linkingManager openUrl:URL.absoluteString isUniversalLink:NO];
340  return YES;
341}
342
343+ (BOOL)application:(UIApplication *)application
344continueUserActivity:(NSUserActivity *)userActivity
345 restorationHandler:(void (^)(NSArray *))restorationHandler
346{
347  if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) {
348    [[EXKernel sharedInstance].serviceRegistry.linkingManager openUrl:userActivity.webpageURL.absoluteString isUniversalLink:YES];
349
350  }
351  return YES;
352}
353
354+ (NSURL *)initialUrlFromLaunchOptions:(NSDictionary *)launchOptions
355{
356  NSURL *initialUrl;
357
358  if (launchOptions) {
359    if (launchOptions[UIApplicationLaunchOptionsURLKey]) {
360      initialUrl = launchOptions[UIApplicationLaunchOptionsURLKey];
361    } else if (launchOptions[UIApplicationLaunchOptionsUserActivityDictionaryKey]) {
362      NSDictionary *userActivityDictionary = launchOptions[UIApplicationLaunchOptionsUserActivityDictionaryKey];
363
364      if ([userActivityDictionary[UIApplicationLaunchOptionsUserActivityTypeKey] isEqual:NSUserActivityTypeBrowsingWeb]) {
365        initialUrl = ((NSUserActivity *)userActivityDictionary[@"UIApplicationLaunchOptionsUserActivityKey"]).webpageURL;
366      }
367    }
368  }
369
370  return initialUrl;
371}
372
373@end
374