From bce557cc2dc767628bed6aac87301a1be7c5431b Mon Sep 17 00:00:00 2001 From: rxliuli Date: Tue, 4 Nov 2025 05:03:50 +0800 Subject: init commit --- shared/utils/src/launch/launch-client.ts | 109 ++++++++++ shared/utils/src/launch/scheme.ts | 339 +++++++++++++++++++++++++++++++ 2 files changed, 448 insertions(+) create mode 100644 shared/utils/src/launch/launch-client.ts create mode 100644 shared/utils/src/launch/scheme.ts (limited to 'shared/utils/src/launch') 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): value is MSNavigator { + return typeof value?.msLaunchUri === 'function'; +} + +/** + * Callback for client launches. + */ +export type LaunchCallback = (result: { + link: URL; + success: boolean; +}) => void | Promise; + +/** + * 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 + * // 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)+ + // 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(); +} -- cgit v1.2.3