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