diff options
| author | rxliuli <rxliuli@gmail.com> | 2025-11-04 05:03:50 +0800 |
|---|---|---|
| committer | rxliuli <rxliuli@gmail.com> | 2025-11-04 05:03:50 +0800 |
| commit | bce557cc2dc767628bed6aac87301a1be7c5431b (patch) | |
| tree | b51a051228d01fe3306cd7626d4a96768aadb944 /shared/utils/src | |
init commit
Diffstat (limited to 'shared/utils/src')
| -rw-r--r-- | shared/utils/src/get-pwa-display-mode.ts | 39 | ||||
| -rw-r--r-- | shared/utils/src/history.ts | 168 | ||||
| -rw-r--r-- | shared/utils/src/is-pojo.ts | 20 | ||||
| -rw-r--r-- | shared/utils/src/launch/launch-client.ts | 109 | ||||
| -rw-r--r-- | shared/utils/src/launch/scheme.ts | 339 | ||||
| -rw-r--r-- | shared/utils/src/lru-map.ts | 60 | ||||
| -rw-r--r-- | shared/utils/src/object-from-entries.ts | 18 | ||||
| -rw-r--r-- | shared/utils/src/optional.ts | 22 | ||||
| -rw-r--r-- | shared/utils/src/platform.ts | 249 | ||||
| -rw-r--r-- | shared/utils/src/try-scroll.ts | 65 | ||||
| -rw-r--r-- | shared/utils/src/url.ts | 90 | ||||
| -rw-r--r-- | shared/utils/src/uuid.ts | 22 |
12 files changed, 1201 insertions, 0 deletions
diff --git a/shared/utils/src/get-pwa-display-mode.ts b/shared/utils/src/get-pwa-display-mode.ts new file mode 100644 index 0000000..506c80d --- /dev/null +++ b/shared/utils/src/get-pwa-display-mode.ts @@ -0,0 +1,39 @@ +export enum PWADisplayMode { + TWA = 'twa', + BROWSER = 'browser', + STANDALONE = 'standalone', + MINIMAL = 'minimal-ui', + FULLSCREEN = 'fullscreen', + OVERLAY = 'window-controls-overlay', + UNKNOWN = 'unknown', +} + +/** + * For PWA, reads the "display" value from the manifest.json and returns the proper value if it matches. + * Inspired by the sample snippet here: https://web.dev/learn/pwa/detection + */ +export const getPWADisplayMode = (): PWADisplayMode => { + switch (true) { + case document.referrer.startsWith('android-app://'): + return PWADisplayMode.TWA; + + case window.matchMedia('(display-mode: browser)').matches: + return PWADisplayMode.BROWSER; + + case window.matchMedia('(display-mode: standalone)').matches: + return PWADisplayMode.STANDALONE; + + case window.matchMedia('(display-mode: minimal-ui)').matches: + return PWADisplayMode.MINIMAL; + + case window.matchMedia('(display-mode: fullscreen)').matches: + return PWADisplayMode.FULLSCREEN; + + case window.matchMedia('(display-mode: window-controls-overlay)') + .matches: + return PWADisplayMode.OVERLAY; + + default: + return PWADisplayMode.UNKNOWN; + } +}; diff --git a/shared/utils/src/history.ts b/shared/utils/src/history.ts new file mode 100644 index 0000000..498f7d1 --- /dev/null +++ b/shared/utils/src/history.ts @@ -0,0 +1,168 @@ +import type { Logger, LoggerFactory } from '@amp/web-apps-logger'; +import { LruMap } from './lru-map'; +import type { ScrollableElement } from './try-scroll'; +import { tryScroll } from './try-scroll'; +import { removeHost } from './url'; +import { generateUuid } from './uuid'; + +export interface Options { + getScrollablePageElement(): ScrollableElement | null; +} + +type Id = string; +const HISTORY_SIZE_LIMIT = 10; + +interface WithScrollPosition<State> { + scrollY: number; + state: State; +} +/** + * We are using a currentStateId on this class to always store the state id instead of saving + * it on the window.history.state because there seems to be a bug in Safari where it is mutating + * the window.history.state to null after our Sign In flow which includes multiple iframes + * and multiple internal state changes inside the iframes. We can move back to window.history.state storing the id + * if the Safari Issue is fixed in future. + */ +export class History<State> { + private readonly log: Logger; + private readonly states: LruMap<Id, WithScrollPosition<State>>; + private readonly getScrollablePageElement: () => ScrollableElement | null; + private currentStateId: string | undefined; + + constructor( + loggerFactory: LoggerFactory, + options: Options, + sizeLimit: number = HISTORY_SIZE_LIMIT, + ) { + this.log = loggerFactory.loggerFor('History'); + this.states = new LruMap(sizeLimit); + this.getScrollablePageElement = options.getScrollablePageElement; + } + + // Update page data but keep scroll position + updateState(update: (state?: State) => State): void { + if (!this.currentStateId) { + this.log.warn( + 'failed: encountered a null currentStateId inside updateState', + ); + return; + } + + const currentState = this.states.get(this.currentStateId); + const newState = update(currentState?.state); + this.log.info('updateState', newState, this.currentStateId); + this.states.set(this.currentStateId, { + ...(currentState as WithScrollPosition<State>), + state: newState, + }); + } + + replaceState(state: State, url: string | null): void { + const id = generateId(); + this.log.info('replaceState', state, url, id); + window.history.replaceState({ id }, '', this.removeHost(url)); + this.currentStateId = id; + this.states.set(id, { state, scrollY: 0 }); + this.scrollTop = 0; + } + + pushState(state: State, url: string | null): void { + const id = generateId(); + this.log.info('pushState', state, url, id); + window.history.pushState({ id }, '', this.removeHost(url)); + this.currentStateId = id; + this.states.set(id, { state, scrollY: 0 }); + this.scrollTop = 0; + } + + beforeTransition(): void { + const { state } = window.history; + + if (!state) { + return; + } + + const oldState = this.states.get(state.id); + if (!oldState) { + this.log.info( + 'current history state evicted from LRU, not saving scroll position', + ); + return; + } + + const { scrollTop } = this; + + this.states.set(state.id, { + ...oldState, + scrollY: scrollTop, + }); + + this.log.info('saving scroll position', scrollTop); + } + + private removeHost(url: string | null): string | undefined { + if (!url) { + this.log.warn('received null URL'); + return; + } + + // TODO: rdar://77982655 (Investigate router improvements): host mismatch? + return removeHost(url); + } + + onPopState( + listener: (url: string, state: State | undefined) => void, + ): void { + window.addEventListener('popstate', (event: PopStateEvent): void => { + this.currentStateId = event.state?.id; + + if (!this.currentStateId) { + this.log.warn( + 'encountered a null event.state.id in onPopState event: ', + window.location.href, + ); + } + + this.log.info('popstate', this.states, this.currentStateId); + const state = this.currentStateId + ? this.states.get(this.currentStateId) + : undefined; + listener(window.location.href, state?.state); + + if (!state) { + return; + } + + const { scrollY } = state; + + this.log.info('restoring scroll to', scrollY); + + tryScroll(this.log, () => this.getScrollablePageElement(), scrollY); + }); + } + + private get scrollTop(): number { + return this.getScrollablePageElement()?.scrollTop || 0; + } + + private set scrollTop(scrollTop: number) { + const element = this.getScrollablePageElement(); + if (element) { + element.scrollTop = scrollTop; + } + } + + // TODO: rdar://77982655 (Investigate router improvements): offPopState? +} + +/** + * Generate a (unique) id for storing in window.history.state. + * + * @return the generated ID + */ +function generateId(): Id { + // The use of something random (and not say, an incrementing counter) is important + // here. These states can survive refreshes so the IDs used must be globally unique + // (and not just unique to the current page load). + return generateUuid(); +} diff --git a/shared/utils/src/is-pojo.ts b/shared/utils/src/is-pojo.ts new file mode 100644 index 0000000..4363454 --- /dev/null +++ b/shared/utils/src/is-pojo.ts @@ -0,0 +1,20 @@ +/** + * Determine if {@linkcode arg} is a Plain Old JavaScript Object. + * + * @see https://masteringjs.io/tutorials/fundamentals/pojo + * + * @param arg to test + * @returns true if {@linkcode arg} is a POJO + */ +export function isPOJO(arg: unknown): arg is Record<string, unknown> { + if (!arg || typeof arg !== 'object') { + return false; + } + + const proto = Object.getPrototypeOf(arg); + if (!proto) { + return true; // `Object.create(null)` + } + + return proto === Object.prototype; +} 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(); +} diff --git a/shared/utils/src/lru-map.ts b/shared/utils/src/lru-map.ts new file mode 100644 index 0000000..79eb41c --- /dev/null +++ b/shared/utils/src/lru-map.ts @@ -0,0 +1,60 @@ +/** + * LRU Map implementation storing key/values up to a provided size limit. Beyond that + * size limit, the least recently used entry is evicted. + * + * @see https://github.pie.apple.com/isao/lru-map + */ +export class LruMap<K, V> extends Map<K, V> { + private sizeLimit: number; + + constructor(sizeLimit: number) { + super(); + this.setSizeLimit(sizeLimit); + // Needed to convince TS that this is set (it's actually handled by setSizeLimit) + this.sizeLimit = sizeLimit; + } + + /** + * Retrieve a value from the map with a given key. + * @param key The key for the entry + * @return value The entry's value (or undefined if non existent) + */ + get(key: K): V | undefined { + let value: V | undefined; + + if (this.has(key)) { + value = super.get(key); + + // Map entries are always in insertion order. So + // readding, pushes this entry to the top of the LRU. + this.delete(key); + super.set(key, value!); + } + + return value; + } + + set(key: K, value: V): this { + super.set(key, value); + this.prune(); + return this; + } + + setSizeLimit(newSizeLimit: number): void { + if (newSizeLimit < 0 || !isFinite(newSizeLimit)) { + throw new Error( + `setSizeLimit expects finite positive number, got: ${newSizeLimit}`, + ); + } + + this.sizeLimit = newSizeLimit; + this.prune(); + } + + private prune(): void { + while (this.size > this.sizeLimit) { + const leastRecentlyUsedKey = this.keys().next().value; + this.delete(leastRecentlyUsedKey); + } + } +} diff --git a/shared/utils/src/object-from-entries.ts b/shared/utils/src/object-from-entries.ts new file mode 100644 index 0000000..80d3cdb --- /dev/null +++ b/shared/utils/src/object-from-entries.ts @@ -0,0 +1,18 @@ +// TODO: rdar://78109780 (Update to Node 16) +/** + * Create an object from an iterable of key/value pairs. + * + * @param entries The key value pairs (ex. [['a', 1], ['b', 2]]) + * @return The created object + */ +export function fromEntries<V>(entries: Iterable<readonly [PropertyKey, V]>): { + [k: string]: V; +} { + const result: Record<PropertyKey, V> = {}; + + for (const [key, value] of entries) { + result[key] = value; + } + + return result; +} diff --git a/shared/utils/src/optional.ts b/shared/utils/src/optional.ts new file mode 100644 index 0000000..7058803 --- /dev/null +++ b/shared/utils/src/optional.ts @@ -0,0 +1,22 @@ +export type Optional<T> = T | None; +export type None = null | undefined; + +/** + * Determine if an optional value is present. + * + * @param optional value + * @return true if present, false otherwise + */ +export function isSome<T>(optional: Optional<T>): optional is T { + return optional !== null && optional !== undefined; +} + +/** + * Determine if an optional value is not present. + * + * @param optional value + * @return true if not present, false otherwise + */ +export function isNone<T>(optional: Optional<T>): optional is None { + return optional === null || optional === undefined; +} diff --git a/shared/utils/src/platform.ts b/shared/utils/src/platform.ts new file mode 100644 index 0000000..15644e5 --- /dev/null +++ b/shared/utils/src/platform.ts @@ -0,0 +1,249 @@ +import { + parseUserAgent, + flagsExtension, + compareExtension, +} from '@amp/runtime-detect'; +import { launchClient, type LaunchCallback } from './launch/launch-client'; + +type NavigatorLike = { + userAgent: string; + maxTouchPoints?: number; +}; + +/** + * Detect a Platform descriptor from the browsers user agent. + */ +function detectDescriptor(options?: { + window?: Window; + navigator?: NavigatorLike; +}) { + const defaultNavigator: NavigatorLike = + typeof options?.window?.navigator !== 'undefined' + ? options.window.navigator + : { + userAgent: '', + maxTouchPoints: 0, + }; + + return parseUserAgent(options?.navigator ?? defaultNavigator, { + extensions: [flagsExtension, compareExtension], + }); +} + +export type PlatformDescriptor = ReturnType<typeof detectDescriptor>; + +export class Platform { + static detect( + this: typeof Platform, + options?: { window?: Window; navigator?: NavigatorLike }, + ) { + const window = options?.window ?? globalThis?.window; + return new this({ + window: window, + descriptor: detectDescriptor({ + window: window, + navigator: options?.navigator, + }), + }); + } + + /** + * Descriptor from detecting platform data. + */ + readonly descriptor: PlatformDescriptor; + + /** + * Navigator value used to create the platform descriptor. + */ + readonly navigator: NavigatorLike; + + /** + * Reference to the platform Window object. This might be `undefined` in some + * environments. + */ + readonly window: Window | undefined; + + /** + * User Agent string the platform descriptor was parsed from. + */ + readonly ua: string; + + /** + * Browser descriptor for the Platform. + */ + readonly browser: PlatformDescriptor['browser']; + + /** + * Browser Engine descriptor for the Platform. + */ + readonly engine: PlatformDescriptor['engine']; + + /** + * Operating System descriptor for the Platform. + */ + readonly os: PlatformDescriptor['os']; + + constructor(config: { + descriptor: PlatformDescriptor; + window?: Window; + navigator?: NavigatorLike; + }) { + const { descriptor } = config; + this.descriptor = descriptor; + this.navigator = config.navigator ?? descriptor.navigator; + this.window = config.window; + + this.ua = descriptor.ua; + this.browser = descriptor.browser; + this.engine = descriptor.engine; + this.os = descriptor.os; + } + + /** + * Check if Apple native applications can be opened on the Platform. + */ + canOpenNative(): boolean { + return this.ismacOS() || this.isiOS(); + } + + /** + * Check if the Platform is running a mobile browser. + */ + isMobile(): boolean { + return this.browser.isMobile; + } + + /** + * Check if the Platform registers as running the Android operating system. + */ + isAndroid(): boolean { + return this.os.isAndroid; + } + + /** + * Check if the Platform registers as running the iOS operating system. + */ + isiOS(): boolean { + return this.os.isIOS; + } + + /** + * Check if the Platform registers as running the iPadOS operating system. + */ + isiPadOS(): boolean { + return this.os.isIPadOS; + } + + /** + * Check if the Platform registers as running the macOS operating system. + */ + ismacOS(): boolean { + return this.os.isMacOS; + } + + /** + * Check if the Platform registers as running the Windows operating system. + */ + isWindows(): boolean { + return this.os.isWindows; + } + + /** + * Check if the Platform registers as running a Linux operating system. + */ + isLinux(): boolean { + return this.os.isLinux; + } + + /** + * Check if the Platform is running the Apple Safari browser. + */ + isSafari(): boolean { + return this.browser.isSafari; + } + + /** + * Check if the Platform is running the Google Chrome browser. + */ + isChrome(): boolean { + return this.browser.isChrome; + } + + /** + * Check if the Platform is running the Mozilla Firefox browser. + */ + isFirefox(): boolean { + return this.browser.isFirefox; + } + + /** + * Check if the Platform is running the Microsoft Edge browser. + */ + isEdge(): boolean { + return this.browser.isEdge; + } + + /** + * Get name for the Platform browser. + * @deprecated Use `platform.browser.name` directly + */ + clientName(): string { + return this.browser.name[0].toUpperCase() + this.browser.name.slice(1); + } + + /** + * Get the Platform browser major version number. + * @deprecated Use `platform.browser.major` directly + */ + majorVersion(): number { + return this.browser.major ?? 0; + } + + /** + * Get the Platform browser minor version number. + * @deprecated Use `platform.browser.minor` directly + */ + minorVersion(): number { + return this.browser.minor ?? 0; + } + + /** + * Get the name for the Platform operating system. + * @deprecated Use `platform.os.name` directly + */ + osName(): string { + return this.os.name; + } + + /** + * Attempt to launch a native client for the given web URL. + * + * The callback is called with a report if the attempt was successful. + * + * @example + * ```javascript + * platform.launchClient( + * 'https://music.apple.com/browse', + * function ({ link, success }) { + * if (success) { + * console.log(`Opened client with ${link}`); + * } else { + * console.log(`Failed to open client with ${link}`); + * } + * } + * ); + * ``` + */ + launchClient(url: string, callback?: LaunchCallback): void { + launchClient(url, this, callback); + } + + /** + * Check if the platform has full support for playing encrypted HLS content. + */ + hasEncryptedPlaybackSupport(): boolean { + return !this.os.isIOS || this.os.gte('17.5'); + } +} + +export const platform = Platform.detect(); diff --git a/shared/utils/src/try-scroll.ts b/shared/utils/src/try-scroll.ts new file mode 100644 index 0000000..1e6b0d2 --- /dev/null +++ b/shared/utils/src/try-scroll.ts @@ -0,0 +1,65 @@ +import type { Logger } from '@amp/web-apps-logger'; +export interface ScrollableElement { + scrollTop: number; + scrollHeight: number; + offsetHeight: number; +} + +// Global is okay here as this only runs in the browser +let nextTry: number | null = null; + +export function tryScroll( + log: Logger, + getScrollablePageElement: Function, + scrollY: number, +): void { + let tries = 0; + + if (nextTry !== null) { + window.cancelAnimationFrame(nextTry); + } + + nextTry = window.requestAnimationFrame(function doNextTry() { + // At 16ms per frame, this is 1600ms + // See: https://github.com/DockYard/ember-router-scroll/blob/2f17728f/addon/services/router-scroll.js#L56 + if (++tries >= 100) { + log.warn("wasn't able to restore scroll within 100 frames"); + nextTry = null; + return; + } + + let element = getScrollablePageElement(); + if (!element) { + log.warn( + 'could not restore scroll: the scrollable element is missing', + ); + return; + } + const { scrollHeight, offsetHeight } = element; + + // Only scroll once we're able to get a full screen of content when + // scrollTop is set to scrollY + // + // +16 is a bit of a fudge factor to count for imperfections in + // features like lazy loading. If the scroll position to restore is + // the very bottom of the page, then scrollY + offsetHeight must be + // exactly scrollHeight. But if lazy loading components (for example) + // cause the page to grow by a few pixels, then this will never hold. + // Thus, we fudge by a few pixels to be more forgiving in this scenario. + const canScroll = scrollY + offsetHeight <= scrollHeight + 16; + + if (!canScroll) { + log.info('page is not tall enough for scroll yet', { + scrollHeight, + offsetHeight, + }); + + nextTry = window.requestAnimationFrame(doNextTry); + return; + } + + element.scrollTop = scrollY; + log.info('scroll restored to', scrollY); + nextTry = null; + }); +} diff --git a/shared/utils/src/url.ts b/shared/utils/src/url.ts new file mode 100644 index 0000000..f15792d --- /dev/null +++ b/shared/utils/src/url.ts @@ -0,0 +1,90 @@ +/** + * Remove the scheme and separators from the given URL. + * + * @example + * ```javascript + * removeScheme('https://music.apple.com/browse') // => 'music.apple.com/browse' + * removeScheme('apple-music://music.apple.com/browse') // => 'music.apple.com/browse' + * removeScheme('music.apple.com/browse') // => 'music.apple.com/browse' + * ``` + */ +export function removeScheme( + url: string | URL | null | undefined, +): string | undefined { + if (url === null || url === undefined) { + return undefined; + } + + return String(url).replace(/^((?:[^:]*:[/]{0,2})|(?::?\/\/))/i, ''); +} + +/** + * Strip scheme and host (hostname + port) from a URL, leaving just the path, query + * params, and hash. + * + * @param {string} url The URL possibly containing a host + * @returns {string} hostlessUrl The url without its host + */ +export function removeHost( + url: string | URL | null | undefined, +): string | undefined { + return removeScheme(url)?.replace(/^([^/]*)/i, ''); +} + +/** + * Strip query params and fragment from a URL. + */ +export function removeQueryParams( + url: string | URL | undefined, +): string | undefined { + if (url === undefined) { + return undefined; + } + + const value = String(url); + const splitIndex = value.indexOf('?'); + return splitIndex >= 0 ? value.slice(0, splitIndex) : value; +} + +export function getBaseUrl(): string { + const currentUrl = new URL(window.location.href); + return `${currentUrl.protocol}//${currentUrl.host}`; +} + +export function buildUrl(props: { + protocol?: string; + hostname: string; + pathname?: string | string[]; + queryParams?: string | Record<string, string>; + hash?: string; +}): URL { + const { + hostname, + pathname = '/', + queryParams = {}, + protocol = 'https', + hash = '', + } = props; + + // Base URL with domain + const url = new URL(protocol + '://' + removeScheme(hostname)); + + // URL path + url.pathname = Array.isArray(pathname) + ? '/' + pathname.map(encodeURIComponent).join('/').replace(/[/]+/, '/') + : pathname; + + // URL search (a.k.a. queryParams) + if (typeof queryParams === 'string') { + url.search = queryParams; + } else { + for (const [key, value] of Object.entries(queryParams)) { + url.searchParams.set(key, value); + } + } + + // URL hash + url.hash = hash; + + return url; +} diff --git a/shared/utils/src/uuid.ts b/shared/utils/src/uuid.ts new file mode 100644 index 0000000..0afa5ee --- /dev/null +++ b/shared/utils/src/uuid.ts @@ -0,0 +1,22 @@ +/** + * Generate a variant 1 UUIDv4. + * + * @return the UUID + */ +export function generateUuid(): string { + return 'xxxxxxxx-xxxx-4xxx-Vxxx-xxxxxxxxxxxx'.replace( + /[xV]/g, + (placeholder) => { + let nibble = (Math.random() * 16) | 0; + + if (placeholder === 'V') { + // Per RFC, the two MSB of byte 8 must be 0b10 (0x8). + // 0x3 (0b11) masks out the bottom two bits. + // See: https://tools.ietf.org/html/rfc4122.html#section-4.1.1 + nibble = (nibble & 0x3) | 0x8; + } + + return nibble.toString(16); + }, + ); +} |
