/ shared / utils / src / launch / scheme.ts
scheme.ts
  1  import { removeScheme } from '..';
  2  import { Platform } from '../platform';
  3  
  4  /**
  5   * Check if the URL hostname matches the given value.
  6   */
  7  const matchesHostName = (url: URL, hostName: string) =>
  8      url.hostname === hostName;
  9  
 10  /**
 11   * Check if the URL `?app=xyz` search param matches the given value.
 12   */
 13  const matchesAppName = (url: URL, appName: string) =>
 14      url.searchParams.get('app') === appName;
 15  
 16  /**
 17   * Check if the URL `?mt=n` search param matches any of the given values.
 18   */
 19  const matchesMediaType = (url: URL, mediaTypes: string[]) => {
 20      const mt = url.searchParams.get('mt');
 21      return mt ? mediaTypes.includes(mt) : false;
 22  };
 23  
 24  /**
 25   * Check if the URL pathname matches the given pattern.
 26   */
 27  const matchesPathName = (url: URL, pattern: RegExp | string) =>
 28      new RegExp(pattern).test(url.pathname);
 29  
 30  /**
 31   * Check if the URL is for Audiobooks
 32   */
 33  const isAudiobookURL = (url: URL): boolean =>
 34      matchesAppName(url, 'audiobook') ||
 35      matchesMediaType(url, ['3']) ||
 36      matchesPathName(url, /\/(audiobook\/|viewAudiobook)/i);
 37  
 38  /**
 39   * Check if the URL is for Books.
 40   */
 41  const isBooksURL = (url: URL): boolean =>
 42      !isAudiobookURL(url) &&
 43      (matchesHostName(url, 'books.apple.com') ||
 44          matchesAppName(url, 'books') ||
 45          matchesMediaType(url, ['11', '13']) ||
 46          matchesPathName(url, '/book/'));
 47  
 48  /**
 49   * Check if the URL is for Commerce.
 50   */
 51  const isCommerceURL = (url: URL): boolean =>
 52      matchesHostName(url, 'finance-app.itunes.apple.com') ||
 53      matchesPathName(url, '/account/');
 54  
 55  /**
 56   * Check if the URL is for a macOS App.
 57   */
 58  const isMacAppURL = (url: URL): boolean =>
 59      matchesAppName(url, 'mac-app') ||
 60      matchesMediaType(url, ['12']) ||
 61      matchesPathName(url, '/mac-app/');
 62  
 63  /**
 64   * Check if the URL is an AppStore Story.
 65   */
 66  const isStoryURL = (url: URL): boolean =>
 67      matchesAppName(url, 'story') || matchesPathName(url, '/story/');
 68  
 69  /**
 70   * Check if the URL is for Messages.
 71   */
 72  const isMessagesURL = (url: URL): boolean => matchesAppName(url, 'messages');
 73  
 74  /**
 75   * Check if the URL is for Music.
 76   */
 77  const isMusicURL = (url: URL): boolean =>
 78      matchesHostName(url, 'music.apple.com') ||
 79      matchesAppName(url, 'music') ||
 80      matchesPathName(
 81          url,
 82          /\/(album|artist|playlist|station|curator|music-video)\//i,
 83      );
 84  
 85  /**
 86   * Check if the URL is for Podcasts.
 87   */
 88  const isPodcastsURL = (url: URL): boolean =>
 89      matchesHostName(url, 'podcasts.apple.com') ||
 90      matchesAppName(url, 'podcasts') ||
 91      matchesMediaType(url, ['2']) ||
 92      matchesPathName(url, '/podcast/');
 93  
 94  /**
 95   * Check if the URL is for TV.
 96   */
 97  const isTVURL = (url: URL): boolean =>
 98      matchesHostName(url, 'tv.apple.com') ||
 99      matchesPathName(
100          url,
101          /\/(episode|movie|movie-collection|show|season|sporting-event|person)\//i,
102      );
103  
104  /**
105   * Check if the URL is for the Watch.
106   */
107  const isWatchURL = (url: URL): boolean => matchesAppName(url, 'watch');
108  
109  /**
110   * Check if the URL is developer.apple.com related.
111   */
112  const isDeveloperURL = (url: URL): boolean =>
113      matchesAppName(url, 'developer') || matchesPathName(url, '/developer/');
114  
115  /**
116   * Check if the URL is for an app.
117   */
118  const isAppsURL = (url: URL): boolean =>
119      matchesMediaType(url, ['8']) && !isMessagesURL(url) && !isWatchURL(url);
120  
121  /**
122   * Function for identifying application schemes from web URLs.
123   */
124  type SchemeIdentifier = (url: URL, platform: Platform) => boolean;
125  
126  /**
127   * List of schemes and functions to identify them based on a URL and Platform details.
128   *
129   * These schemes are derived from [Jingle Properties](https://github.pie.apple.com/amp-dev/Jingle/blob/6392929afb8540ac488315647992c3f46a9cc82f/MZConfig/Properties/apps/MZInit2/common.properties#L993).
130   *
131   * ```java
132   * // <rdar://problem/66551318> iOS Bag: Move mobile-url-handlers to a property defined list
133   * MZInit.iOS.acceptedUrlHandlers=("applenews", "applenewss", "applestore", "applestore-sec", "bridge", "com.apple.tv", "disneymoviesanywhere",\
134   * "http", "https", "itms", "itmss", "itms-apps", "itms-appss", "itms-books", "itms-bookss", "itms-gc", "itms-gcs", "itms-itunesu",\
135   * "itms-itunesus", "itms-podcast", "itms-podcasts", "itms-ui", "its-music", "its-musics", "its-news", "its-newss", "its-videos",\
136   * "its-videoss", "itsradio", "livenation", "mailto", "message", "moviesanywhere", "music", "musics", "prefs", "shoebox")
137   * ```
138   */
139  const identifiers: [string, SchemeIdentifier, ...SchemeIdentifier[]][] = [
140      [
141          'itms-apps',
142          (url, platform) =>
143              platform.os.isIOS &&
144              (isCommerceURL(url) ||
145                  isAppsURL(url) ||
146                  isStoryURL(url) ||
147                  isDeveloperURL(url)),
148      ],
149  
150      // Watch app on mobile
151      [
152          'itms-watch',
153          (url, platform) => platform.browser.isMobile && isWatchURL(url),
154      ],
155  
156      // Messages app on mobile
157      [
158          'itms-messages',
159          function (url: URL, platform: Platform) {
160              return platform.browser.isMobile && isMessagesURL(url);
161          },
162      ],
163  
164      [
165          'itms-books',
166          (url, platform) =>
167              platform.os.isMacOS &&
168              platform.os.gte('10.15') &&
169              isAudiobookURL(url),
170          (url, _platform) => isBooksURL(url),
171      ],
172  
173      // Music on Android
174      [
175          'apple-music',
176          (url, platform) => platform.os.isAndroid && isMusicURL(url),
177      ],
178  
179      // Music on iOS/macOS
180      [
181          'music',
182          (url, platform) => platform.os.isIOS && isMusicURL(url),
183          (url, platform) => {
184              return (
185                  platform.os.isMacOS &&
186                  platform.os.gte('10.15') &&
187                  isMusicURL(url)
188              );
189          },
190      ],
191  
192      // Podcasts on iOS
193      [
194          'itms-podcasts',
195          (url, platform) => platform.os.isIOS && isPodcastsURL(url),
196      ],
197  
198      // Podcasts on macOS
199      [
200          'podcasts',
201          (url, platform) =>
202              platform.os.isMacOS &&
203              platform.os.gte('10.15') &&
204              isPodcastsURL(url),
205      ],
206  
207      // TV on iOS
208      [
209          'com.apple.tv',
210          (url, platform) =>
211              platform.os.isIOS && platform.os.gte('10.2') && isTVURL(url),
212      ],
213  
214      // TV on macOS
215      [
216          'videos',
217          (url: URL, platform: Platform) =>
218              platform.os.isMacOS && platform.os.gte('10.15') && isTVURL(url),
219      ],
220  
221      [
222          'macappstore',
223          (url, _platform) => isMacAppURL(url),
224          (url, platform) =>
225              platform.os.isMacOS &&
226              platform.os.gte('10.15') &&
227              isCommerceURL(url),
228  
229          // Story and developer pages should launch Mac App Store on Mojave(10.14)+
230          // <rdar://problem/46461633> Story page with ls=1 QP should attempt to open Mac App Store on Mojave +
231          // rdar://81291713 (Star: https://apps.apple.com/developer/id463855590?ls=1 launches Music App)
232          (url, platform) =>
233              platform.os.isMacOS &&
234              platform.os.gte('10.14') &&
235              (isStoryURL(url) || isDeveloperURL(url)),
236      ],
237  
238      // Catch All
239      ['itms', (_url, _platform) => true],
240  ];
241  
242  /**
243   * Get the Scheme for attempting to open a platform native application.
244   *
245   * @see {@link https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Syntax}
246   */
247  export function detectClientScheme(
248      url: string | URL,
249      options?: { platform?: Platform },
250  ): string {
251      url = new URL(url);
252  
253      // Assume that any URLs that don't have the http(s) scheme already have the
254      // correct scheme assigned.
255      if (/https?/i.test(url.protocol)) {
256          const platform = options?.platform ?? Platform.detect();
257  
258          for (const [scheme, ...fns] of identifiers) {
259              for (const fn of fns) {
260                  if (fn(url, platform)) {
261                      return scheme;
262                  }
263              }
264          }
265      }
266  
267      // At this point something should have matched. If not just return the original
268      // scheme and have the browser or system handle it.
269      return normalizeScheme(url.protocol);
270  }
271  
272  /**
273   * Check if the given URL has an Apple specific Scheme.
274   *
275   * @example
276   * ```javascript
277   * hasAppleClientScheme('music://music.apple.com/browse') // => true
278   * hasAppleClientScheme('https://music.apple.com/browse') // => false
279   * ```
280   */
281  export function hasAppleClientScheme(
282      url: URL | string,
283      _options?: { platform?: Platform },
284  ) {
285      const pattern =
286          /^(?:itms(?:-.*)?|macappstore|podcast|video|(?:apple-)?music)s?(:|$)/im;
287      return pattern.test(new URL(url).protocol);
288  }
289  
290  /**
291   * Create a link for attempting to open a platform native application based on a web URL.
292   *
293   * @example
294   * ```javascript
295   * createClientLink('https://music.apple.com/browse');
296   * // => 'music://music.apple.com/browse'
297   * ```
298   */
299  export function createClientLink(
300      url: string | URL,
301      options?: { platform?: Platform },
302  ): URL {
303      const link = new URL(url);
304  
305      // Removes any development prefixes in order to correctly identify the scheme
306      link.host = link.host.replace(
307          /^(?:[^-]+[-.])?([^.]+)\.apple\.com/,
308          '$1.apple.com',
309      );
310  
311      // Remove any port designation, this should not be present in application links
312      link.port = '';
313  
314      const scheme = detectClientScheme(link, {
315          platform: options?.platform,
316      });
317  
318      // If the identified scheme is already assigned we want to leave the URL unmodified
319      if (scheme === normalizeScheme(link.protocol)) {
320          return new URL(url);
321      }
322  
323      return new URL(scheme + '://' + removeScheme(link));
324  }
325  
326  /**
327   * Normalize a scheme value by removing any separators from it.
328   *
329   * @example
330   * ```javascript
331   * normalizeScheme('music') // => 'music'
332   * normalizeScheme('TV') // => 'tv'
333   * normalizeScheme('https:') // => 'https'
334   * normalizeScheme('https://') // => 'https'
335   * ```
336   */
337  function normalizeScheme(value: string): string {
338      return value.replace(/[:]+$/, '').toLowerCase();
339  }