summaryrefslogtreecommitdiff
path: root/shared/utils/src/launch
diff options
context:
space:
mode:
Diffstat (limited to 'shared/utils/src/launch')
-rw-r--r--shared/utils/src/launch/launch-client.ts109
-rw-r--r--shared/utils/src/launch/scheme.ts339
2 files changed, 448 insertions, 0 deletions
diff --git a/shared/utils/src/launch/launch-client.ts b/shared/utils/src/launch/launch-client.ts
new file mode 100644
index 0000000..24378d6
--- /dev/null
+++ b/shared/utils/src/launch/launch-client.ts
@@ -0,0 +1,109 @@
+import { createClientLink } from './scheme';
+import type { Platform } from '../platform';
+
+/**
+ * Navigator for older Microsoft (MS) browsers like Internet Explorer.
+ */
+type MSNavigator = Navigator & {
+ msLaunchUri: (
+ href: string | URL,
+ successCallback: () => void,
+ failureCallback: () => void,
+ ) => void;
+};
+
+/**
+ * Check if the given value is an MSNavigator.
+ */
+function isMSNavigator(value: Partial<MSNavigator>): value is MSNavigator {
+ return typeof value?.msLaunchUri === 'function';
+}
+
+/**
+ * Callback for client launches.
+ */
+export type LaunchCallback = (result: {
+ link: URL;
+ success: boolean;
+}) => void | Promise<void>;
+
+/**
+ * Attempt to launch the native client for the given Web URL.
+ */
+export function launchClient(
+ url: string | URL,
+ platform: Platform,
+ callback: LaunchCallback = () => {},
+): void {
+ const { window, browser, os } = platform;
+
+ /** URL for opening the native application */
+ const link = createClientLink(url, { platform });
+
+ // macOS Safari
+ if (os.isMacOS && browser.isSafari) {
+ launchOnMacOS(link, platform, callback);
+ }
+ // Proprietary msLaunchUri method (IE 10+ on Windows 8+)
+ else if (isMSNavigator(platform.navigator)) {
+ platform.navigator.msLaunchUri(
+ String(link),
+ () => callback({ link, success: true }),
+ () => callback({ link, success: false }),
+ );
+ }
+ // Other platforms
+ else {
+ try {
+ // on iOS, Windows and Android simply opening the href works
+ window!.top!.window.location.href = String(link);
+ callback({ link, success: true });
+ } catch (e) {
+ // we know this is NOT installed
+ callback({ link, success: false });
+ }
+ }
+}
+
+function launchOnMacOS(
+ link: URL,
+ platform: Platform,
+ callback: LaunchCallback,
+): void {
+ const { window } = platform;
+
+ if (typeof window === 'undefined') {
+ callback({ link, success: false });
+ return;
+ }
+
+ /** Timer for blur fallback */
+ let timer: number;
+
+ /** IFrame reference for opening the client link */
+ let iframe: HTMLIFrameElement | undefined;
+
+ /** Cleanup function run after the client launch has been initiated */
+ function finalize() {
+ clearTimeout(timer);
+ window!.removeEventListener('blur', finalize);
+ if (iframe !== undefined) {
+ window!.document.body.removeChild(iframe);
+ }
+
+ callback({ link, success: true });
+ }
+
+ // Add an iFrame window to the current document to open the URL
+ iframe = window.document.createElement('iframe');
+ iframe.id = 'launch-client-opener';
+ iframe.style.display = 'none';
+ window.document.body.appendChild(iframe);
+
+ // Redirect the iFrame to the client link to trigger it to open
+ iframe.contentWindow!.location.href = String(link);
+
+ // Wait a tiny amount of time for the client launch to appear
+ window.addEventListener('blur', finalize);
+ timer = setTimeout(finalize, 50) as unknown as number;
+}
diff --git a/shared/utils/src/launch/scheme.ts b/shared/utils/src/launch/scheme.ts
new file mode 100644
index 0000000..1b548c4
--- /dev/null
+++ b/shared/utils/src/launch/scheme.ts
@@ -0,0 +1,339 @@
+import { removeScheme } from '..';
+import { Platform } from '../platform';
+
+/**
+ * Check if the URL hostname matches the given value.
+ */
+const matchesHostName = (url: URL, hostName: string) =>
+ url.hostname === hostName;
+
+/**
+ * Check if the URL `?app=xyz` search param matches the given value.
+ */
+const matchesAppName = (url: URL, appName: string) =>
+ url.searchParams.get('app') === appName;
+
+/**
+ * Check if the URL `?mt=n` search param matches any of the given values.
+ */
+const matchesMediaType = (url: URL, mediaTypes: string[]) => {
+ const mt = url.searchParams.get('mt');
+ return mt ? mediaTypes.includes(mt) : false;
+};
+
+/**
+ * Check if the URL pathname matches the given pattern.
+ */
+const matchesPathName = (url: URL, pattern: RegExp | string) =>
+ new RegExp(pattern).test(url.pathname);
+
+/**
+ * Check if the URL is for Audiobooks
+ */
+const isAudiobookURL = (url: URL): boolean =>
+ matchesAppName(url, 'audiobook') ||
+ matchesMediaType(url, ['3']) ||
+ matchesPathName(url, /\/(audiobook\/|viewAudiobook)/i);
+
+/**
+ * Check if the URL is for Books.
+ */
+const isBooksURL = (url: URL): boolean =>
+ !isAudiobookURL(url) &&
+ (matchesHostName(url, 'books.apple.com') ||
+ matchesAppName(url, 'books') ||
+ matchesMediaType(url, ['11', '13']) ||
+ matchesPathName(url, '/book/'));
+
+/**
+ * Check if the URL is for Commerce.
+ */
+const isCommerceURL = (url: URL): boolean =>
+ matchesHostName(url, 'finance-app.itunes.apple.com') ||
+ matchesPathName(url, '/account/');
+
+/**
+ * Check if the URL is for a macOS App.
+ */
+const isMacAppURL = (url: URL): boolean =>
+ matchesAppName(url, 'mac-app') ||
+ matchesMediaType(url, ['12']) ||
+ matchesPathName(url, '/mac-app/');
+
+/**
+ * Check if the URL is an AppStore Story.
+ */
+const isStoryURL = (url: URL): boolean =>
+ matchesAppName(url, 'story') || matchesPathName(url, '/story/');
+
+/**
+ * Check if the URL is for Messages.
+ */
+const isMessagesURL = (url: URL): boolean => matchesAppName(url, 'messages');
+
+/**
+ * Check if the URL is for Music.
+ */
+const isMusicURL = (url: URL): boolean =>
+ matchesHostName(url, 'music.apple.com') ||
+ matchesAppName(url, 'music') ||
+ matchesPathName(
+ url,
+ /\/(album|artist|playlist|station|curator|music-video)\//i,
+ );
+
+/**
+ * Check if the URL is for Podcasts.
+ */
+const isPodcastsURL = (url: URL): boolean =>
+ matchesHostName(url, 'podcasts.apple.com') ||
+ matchesAppName(url, 'podcasts') ||
+ matchesMediaType(url, ['2']) ||
+ matchesPathName(url, '/podcast/');
+
+/**
+ * Check if the URL is for TV.
+ */
+const isTVURL = (url: URL): boolean =>
+ matchesHostName(url, 'tv.apple.com') ||
+ matchesPathName(
+ url,
+ /\/(episode|movie|movie-collection|show|season|sporting-event|person)\//i,
+ );
+
+/**
+ * Check if the URL is for the Watch.
+ */
+const isWatchURL = (url: URL): boolean => matchesAppName(url, 'watch');
+
+/**
+ * Check if the URL is developer.apple.com related.
+ */
+const isDeveloperURL = (url: URL): boolean =>
+ matchesAppName(url, 'developer') || matchesPathName(url, '/developer/');
+
+/**
+ * Check if the URL is for an app.
+ */
+const isAppsURL = (url: URL): boolean =>
+ matchesMediaType(url, ['8']) && !isMessagesURL(url) && !isWatchURL(url);
+
+/**
+ * Function for identifying application schemes from web URLs.
+ */
+type SchemeIdentifier = (url: URL, platform: Platform) => boolean;
+
+/**
+ * List of schemes and functions to identify them based on a URL and Platform details.
+ *
+ * These schemes are derived from [Jingle Properties](https://github.pie.apple.com/amp-dev/Jingle/blob/6392929afb8540ac488315647992c3f46a9cc82f/MZConfig/Properties/apps/MZInit2/common.properties#L993).
+ *
+ * ```java
+ * // <rdar://problem/66551318> iOS Bag: Move mobile-url-handlers to a property defined list
+ * MZInit.iOS.acceptedUrlHandlers=("applenews", "applenewss", "applestore", "applestore-sec", "bridge", "com.apple.tv", "disneymoviesanywhere",\
+ * "http", "https", "itms", "itmss", "itms-apps", "itms-appss", "itms-books", "itms-bookss", "itms-gc", "itms-gcs", "itms-itunesu",\
+ * "itms-itunesus", "itms-podcast", "itms-podcasts", "itms-ui", "its-music", "its-musics", "its-news", "its-newss", "its-videos",\
+ * "its-videoss", "itsradio", "livenation", "mailto", "message", "moviesanywhere", "music", "musics", "prefs", "shoebox")
+ * ```
+ */
+const identifiers: [string, SchemeIdentifier, ...SchemeIdentifier[]][] = [
+ [
+ 'itms-apps',
+ (url, platform) =>
+ platform.os.isIOS &&
+ (isCommerceURL(url) ||
+ isAppsURL(url) ||
+ isStoryURL(url) ||
+ isDeveloperURL(url)),
+ ],
+
+ // Watch app on mobile
+ [
+ 'itms-watch',
+ (url, platform) => platform.browser.isMobile && isWatchURL(url),
+ ],
+
+ // Messages app on mobile
+ [
+ 'itms-messages',
+ function (url: URL, platform: Platform) {
+ return platform.browser.isMobile && isMessagesURL(url);
+ },
+ ],
+
+ [
+ 'itms-books',
+ (url, platform) =>
+ platform.os.isMacOS &&
+ platform.os.gte('10.15') &&
+ isAudiobookURL(url),
+ (url, _platform) => isBooksURL(url),
+ ],
+
+ // Music on Android
+ [
+ 'apple-music',
+ (url, platform) => platform.os.isAndroid && isMusicURL(url),
+ ],
+
+ // Music on iOS/macOS
+ [
+ 'music',
+ (url, platform) => platform.os.isIOS && isMusicURL(url),
+ (url, platform) => {
+ return (
+ platform.os.isMacOS &&
+ platform.os.gte('10.15') &&
+ isMusicURL(url)
+ );
+ },
+ ],
+
+ // Podcasts on iOS
+ [
+ 'itms-podcasts',
+ (url, platform) => platform.os.isIOS && isPodcastsURL(url),
+ ],
+
+ // Podcasts on macOS
+ [
+ 'podcasts',
+ (url, platform) =>
+ platform.os.isMacOS &&
+ platform.os.gte('10.15') &&
+ isPodcastsURL(url),
+ ],
+
+ // TV on iOS
+ [
+ 'com.apple.tv',
+ (url, platform) =>
+ platform.os.isIOS && platform.os.gte('10.2') && isTVURL(url),
+ ],
+
+ // TV on macOS
+ [
+ 'videos',
+ (url: URL, platform: Platform) =>
+ platform.os.isMacOS && platform.os.gte('10.15') && isTVURL(url),
+ ],
+
+ [
+ 'macappstore',
+ (url, _platform) => isMacAppURL(url),
+ (url, platform) =>
+ platform.os.isMacOS &&
+ platform.os.gte('10.15') &&
+ isCommerceURL(url),
+
+ // Story and developer pages should launch Mac App Store on Mojave(10.14)+
+ // <rdar://problem/46461633> Story page with ls=1 QP should attempt to open Mac App Store on Mojave +
+ // rdar://81291713 (Star: https://apps.apple.com/developer/id463855590?ls=1 launches Music App)
+ (url, platform) =>
+ platform.os.isMacOS &&
+ platform.os.gte('10.14') &&
+ (isStoryURL(url) || isDeveloperURL(url)),
+ ],
+
+ // Catch All
+ ['itms', (_url, _platform) => true],
+];
+
+/**
+ * Get the Scheme for attempting to open a platform native application.
+ *
+ * @see {@link https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Syntax}
+ */
+export function detectClientScheme(
+ url: string | URL,
+ options?: { platform?: Platform },
+): string {
+ url = new URL(url);
+
+ // Assume that any URLs that don't have the http(s) scheme already have the
+ // correct scheme assigned.
+ if (/https?/i.test(url.protocol)) {
+ const platform = options?.platform ?? Platform.detect();
+
+ for (const [scheme, ...fns] of identifiers) {
+ for (const fn of fns) {
+ if (fn(url, platform)) {
+ return scheme;
+ }
+ }
+ }
+ }
+
+ // At this point something should have matched. If not just return the original
+ // scheme and have the browser or system handle it.
+ return normalizeScheme(url.protocol);
+}
+
+/**
+ * Check if the given URL has an Apple specific Scheme.
+ *
+ * @example
+ * ```javascript
+ * hasAppleClientScheme('music://music.apple.com/browse') // => true
+ * hasAppleClientScheme('https://music.apple.com/browse') // => false
+ * ```
+ */
+export function hasAppleClientScheme(
+ url: URL | string,
+ _options?: { platform?: Platform },
+) {
+ const pattern =
+ /^(?:itms(?:-.*)?|macappstore|podcast|video|(?:apple-)?music)s?(:|$)/im;
+ return pattern.test(new URL(url).protocol);
+}
+
+/**
+ * Create a link for attempting to open a platform native application based on a web URL.
+ *
+ * @example
+ * ```javascript
+ * createClientLink('https://music.apple.com/browse');
+ * // => 'music://music.apple.com/browse'
+ * ```
+ */
+export function createClientLink(
+ url: string | URL,
+ options?: { platform?: Platform },
+): URL {
+ const link = new URL(url);
+
+ // Removes any development prefixes in order to correctly identify the scheme
+ link.host = link.host.replace(
+ /^(?:[^-]+[-.])?([^.]+)\.apple\.com/,
+ '$1.apple.com',
+ );
+
+ // Remove any port designation, this should not be present in application links
+ link.port = '';
+
+ const scheme = detectClientScheme(link, {
+ platform: options?.platform,
+ });
+
+ // If the identified scheme is already assigned we want to leave the URL unmodified
+ if (scheme === normalizeScheme(link.protocol)) {
+ return new URL(url);
+ }
+
+ return new URL(scheme + '://' + removeScheme(link));
+}
+
+/**
+ * Normalize a scheme value by removing any separators from it.
+ *
+ * @example
+ * ```javascript
+ * normalizeScheme('music') // => 'music'
+ * normalizeScheme('TV') // => 'tv'
+ * normalizeScheme('https:') // => 'https'
+ * normalizeScheme('https://') // => 'https'
+ * ```
+ */
+function normalizeScheme(value: string): string {
+ return value.replace(/[:]+$/, '').toLowerCase();
+}