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 }