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