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