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 | |
init commit
Diffstat (limited to 'shared/utils')
| -rw-r--r-- | shared/utils/node_modules/@amp/runtime-detect/dist/extensions/compare.js | 83 | ||||
| -rw-r--r-- | shared/utils/node_modules/@amp/runtime-detect/dist/extensions/flags.js | 105 | ||||
| -rw-r--r-- | shared/utils/node_modules/@amp/runtime-detect/dist/rules.js | 22 | ||||
| -rw-r--r-- | shared/utils/node_modules/@amp/runtime-detect/dist/user-agent.js | 392 | ||||
| -rw-r--r-- | shared/utils/node_modules/@amp/runtime-detect/dist/version.js | 99 | ||||
| -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 |
17 files changed, 1902 insertions, 0 deletions
diff --git a/shared/utils/node_modules/@amp/runtime-detect/dist/extensions/compare.js b/shared/utils/node_modules/@amp/runtime-detect/dist/extensions/compare.js new file mode 100644 index 0000000..200cdfb --- /dev/null +++ b/shared/utils/node_modules/@amp/runtime-detect/dist/extensions/compare.js @@ -0,0 +1,83 @@ +import { eq, gt, gte, lt, lte } from '../version.js'; + +function _define_property(obj, key, value) { + if (key in obj) { + Object.defineProperty(obj, key, { + value: value, + enumerable: true, + configurable: true, + writable: true + }); + } else { + obj[key] = value; + } + return obj; +} +function _object_spread(target) { + for(var i = 1; i < arguments.length; i++){ + var source = arguments[i] != null ? arguments[i] : {}; + var ownKeys = Object.keys(source); + if (typeof Object.getOwnPropertySymbols === "function") { + ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function(sym) { + return Object.getOwnPropertyDescriptor(source, sym).enumerable; + })); + } + ownKeys.forEach(function(key) { + _define_property(target, key, source[key]); + }); + } + return target; +} +function ownKeys(object, enumerableOnly) { + var keys = Object.keys(object); + if (Object.getOwnPropertySymbols) { + var symbols = Object.getOwnPropertySymbols(object); + if (enumerableOnly) { + symbols = symbols.filter(function(sym) { + return Object.getOwnPropertyDescriptor(object, sym).enumerable; + }); + } + keys.push.apply(keys, symbols); + } + return keys; +} +function _object_spread_props(target, source) { + source = source != null ? source : {}; + if (Object.getOwnPropertyDescriptors) { + Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); + } else { + ownKeys(Object(source)).forEach(function(key) { + Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); + }); + } + return target; +} +function compareExtension(descriptor) { + function makeComparable(data) { + var _data_major, _data_minor, _data_patch; + const version = { + major: (_data_major = data.major) !== null && _data_major !== void 0 ? _data_major : 0, + minor: (_data_minor = data.minor) !== null && _data_minor !== void 0 ? _data_minor : 0, + patch: (_data_patch = data.patch) !== null && _data_patch !== void 0 ? _data_patch : 0 + }; + return _object_spread_props(_object_spread({}, data), { + eq: (value)=>eq(version, value), + gt: (value)=>gt(version, value), + gte: (value)=>gte(version, value), + lt: (value)=>lt(version, value), + lte: (value)=>lte(version, value), + is: (value)=>data.name === value || data.variant === value + }); + } + return _object_spread_props(_object_spread({}, descriptor), { + extensions: [ + ...descriptor.extensions, + 'compare' + ], + browser: makeComparable(descriptor.browser), + engine: makeComparable(descriptor.engine), + os: makeComparable(descriptor.os) + }); +} + +export { compareExtension }; diff --git a/shared/utils/node_modules/@amp/runtime-detect/dist/extensions/flags.js b/shared/utils/node_modules/@amp/runtime-detect/dist/extensions/flags.js new file mode 100644 index 0000000..04980ee --- /dev/null +++ b/shared/utils/node_modules/@amp/runtime-detect/dist/extensions/flags.js @@ -0,0 +1,105 @@ +function _define_property(obj, key, value) { + if (key in obj) { + Object.defineProperty(obj, key, { + value: value, + enumerable: true, + configurable: true, + writable: true + }); + } else { + obj[key] = value; + } + return obj; +} +function _object_spread(target) { + for(var i = 1; i < arguments.length; i++){ + var source = arguments[i] != null ? arguments[i] : {}; + var ownKeys = Object.keys(source); + if (typeof Object.getOwnPropertySymbols === "function") { + ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function(sym) { + return Object.getOwnPropertyDescriptor(source, sym).enumerable; + })); + } + ownKeys.forEach(function(key) { + _define_property(target, key, source[key]); + }); + } + return target; +} +function ownKeys(object, enumerableOnly) { + var keys = Object.keys(object); + if (Object.getOwnPropertySymbols) { + var symbols = Object.getOwnPropertySymbols(object); + if (enumerableOnly) { + symbols = symbols.filter(function(sym) { + return Object.getOwnPropertyDescriptor(object, sym).enumerable; + }); + } + keys.push.apply(keys, symbols); + } + return keys; +} +function _object_spread_props(target, source) { + source = source != null ? source : {}; + if (Object.getOwnPropertyDescriptors) { + Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); + } else { + ownKeys(Object(source)).forEach(function(key) { + Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); + }); + } + return target; +} +function flagsExtension(descriptor) { + let osName = descriptor.os.name; + const isAndroid = descriptor.os.name === 'android'; + let isMacOS = descriptor.os.name === 'macos'; + let isIOS = descriptor.os.name === 'ios'; + var _descriptor_navigator_maxTouchPoints; + /** + * Newer iPads will identify as macOS in the UserAgent string but can still be differentiated by + * inspecting `maxTouchPoints`. The macOS and iOS values need to be reset when detected. + */ const isIPadOS = osName === 'ipados' || isIOS && /ipad/i.test(descriptor.ua) || isMacOS && ((_descriptor_navigator_maxTouchPoints = descriptor.navigator.maxTouchPoints) !== null && _descriptor_navigator_maxTouchPoints !== void 0 ? _descriptor_navigator_maxTouchPoints : 0) >= 2; + if (isIPadOS) { + osName = 'ipados'; + isIOS = false; + isMacOS = false; + } + const browser = _object_spread_props(_object_spread({}, descriptor.browser), { + isUnknown: descriptor.browser.name === 'unknown', + isSafari: descriptor.browser.name === 'safari', + isChrome: descriptor.browser.name === 'chrome', + isFirefox: descriptor.browser.name === 'firefox', + isEdge: descriptor.browser.name === 'edge', + isWebView: descriptor.browser.name === 'webview', + isOther: descriptor.browser.name === 'other', + isMobile: descriptor.browser.mobile || isIOS || isIPadOS || isAndroid || false + }); + const engine = _object_spread_props(_object_spread({}, descriptor.engine), { + isUnknown: descriptor.engine.name === 'unknown', + isWebKit: descriptor.engine.name === 'webkit', + isBlink: descriptor.engine.name === 'blink', + isGecko: descriptor.engine.name === 'gecko' + }); + const os = _object_spread_props(_object_spread({}, descriptor.os), { + name: osName, + isUnknown: descriptor.os.name === 'unknown', + isLinux: descriptor.os.name === 'linux', + isWindows: descriptor.os.name === 'windows', + isMacOS, + isAndroid, + isIOS, + isIPadOS + }); + return _object_spread_props(_object_spread({}, descriptor), { + extensions: [ + ...descriptor.extensions, + 'flags' + ], + browser, + os, + engine + }); +} + +export { flagsExtension }; diff --git a/shared/utils/node_modules/@amp/runtime-detect/dist/rules.js b/shared/utils/node_modules/@amp/runtime-detect/dist/rules.js new file mode 100644 index 0000000..5ffb91f --- /dev/null +++ b/shared/utils/node_modules/@amp/runtime-detect/dist/rules.js @@ -0,0 +1,22 @@ +function applyRules(rules, navigator, data) { + const { userAgent } = navigator !== null && navigator !== void 0 ? navigator : {}; + if (typeof userAgent !== 'string' || userAgent.trim() === '') { + return data; + } + for (const rule of rules){ + const patterns = rule.slice(0, -1); + const parser = rule[rule.length - 1]; + let match = null; + for (const pattern of patterns){ + match = userAgent.match(pattern); + if (match !== null) { + Object.assign(data, parser(match, navigator, data)); + break; + } + } + if (match !== null) break; + } + return data; +} + +export { applyRules }; diff --git a/shared/utils/node_modules/@amp/runtime-detect/dist/user-agent.js b/shared/utils/node_modules/@amp/runtime-detect/dist/user-agent.js new file mode 100644 index 0000000..f936336 --- /dev/null +++ b/shared/utils/node_modules/@amp/runtime-detect/dist/user-agent.js @@ -0,0 +1,392 @@ +import { parseVersion } from './version.js'; +import { applyRules } from './rules.js'; + +function _define_property(obj, key, value) { + if (key in obj) { + Object.defineProperty(obj, key, { + value: value, + enumerable: true, + configurable: true, + writable: true + }); + } else { + obj[key] = value; + } + return obj; +} +function _object_spread(target) { + for(var i = 1; i < arguments.length; i++){ + var source = arguments[i] != null ? arguments[i] : {}; + var ownKeys = Object.keys(source); + if (typeof Object.getOwnPropertySymbols === "function") { + ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function(sym) { + return Object.getOwnPropertyDescriptor(source, sym).enumerable; + })); + } + ownKeys.forEach(function(key) { + _define_property(target, key, source[key]); + }); + } + return target; +} +function ownKeys(object, enumerableOnly) { + var keys = Object.keys(object); + if (Object.getOwnPropertySymbols) { + var symbols = Object.getOwnPropertySymbols(object); + if (enumerableOnly) { + symbols = symbols.filter(function(sym) { + return Object.getOwnPropertyDescriptor(object, sym).enumerable; + }); + } + keys.push.apply(keys, symbols); + } + return keys; +} +function _object_spread_props(target, source) { + source = source != null ? source : {}; + if (Object.getOwnPropertyDescriptors) { + Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); + } else { + ownKeys(Object(source)).forEach(function(key) { + Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); + }); + } + return target; +} +var _match_, _match_1; +const RULES = { + // BROWSER ========================================================================== + browser: [ + // WEBVIEW ------------------------------------------------------------------------ + // iTunes/Music.app/TV.app + [ + /^(itunes|music|tv)\/([\w.]+)\s/i, + (match)=>_object_spread_props(_object_spread({ + name: 'webview', + variant: match[1].trim().toLowerCase().replace(/(music|tv)/i, '$1.app') + }, parseVersion(match[2])), { + mobile: false + }) + ], + // Facebook + [ + /(?:(?:fban\/fbios|fb_iab\/fb4a)(?!.+fbav)|;fbav\/([\w.]+);)/i, + (match)=>_object_spread({ + name: 'webview', + variant: 'facebook', + mobile: true + }, parseVersion(match[1])) + ], + // Instagram / SnapChat + [ + /(instagram|snapchat)[/ ]([-\w.]+)/i, + (match)=>_object_spread({ + name: 'webview', + variant: match[1].trim().toLowerCase(), + mobile: true + }, parseVersion(match[2])) + ], + // TikTok + [ + /musical_ly(?:.+app_?version\/|_)([\w.]+)/i, + (match)=>_object_spread({ + name: 'webview', + variant: 'tiktok', + mobile: true + }, parseVersion(match[1])) + ], + // Twitter + [ + /twitter/i, + ()=>({ + name: 'webview', + variant: 'twitter', + mobile: true + }) + ], + // Chrome WebView + [ + / wv\).+?(?:version|chrome)\/([\w.]+)/i, + (match)=>_object_spread({ + name: 'webview', + mobile: true + }, parseVersion(match[1])) + ], + // ELECTRON ----------------------------------------------------------------------- + [ + /electron\/([\w.]+) safari/i, + (match)=>_object_spread({ + name: 'electron', + mobile: false + }, parseVersion(match[1])) + ], + // OTHER -------------------------------------------------------------------------- + [ + /tesla\/(.*?(20\d\d\.([-\w.])+))/i, + (match)=>_object_spread_props(_object_spread({ + name: 'other', + variant: 'tesla', + mobile: false + }, parseVersion(match[2])), { + version: match[1] + }) + ], + [ + /(samsung|huawei)browser\/([-\w.]+)/i, + (match)=>_object_spread({ + name: 'other', + variant: match[1].trim().toLowerCase().replace(/browser/i, ''), + mobile: true + }, parseVersion(match[2])) + ], + [ + /yabrowser\/([-\w.]+)/i, + (match)=>_object_spread({ + name: 'other', + variant: 'yandex', + mobile: false + }, parseVersion(match[1])) + ], + [ + /(brave|flock|rockmelt|midori|epiphany|silk|skyfire|ovibrowser|bolt|iron|vivaldi|iridium|phantomjs|bowser|quark|qupzilla|falkon|rekonq|puffin|whale(?!.+naver)|qqbrowserlite|qq|duckduckgo)\/([-\w.]+)/i, + (match, { userAgent })=>_object_spread({ + name: 'other', + variant: match[1].trim().toLowerCase(), + mobile: /mobile/i.test(userAgent) + }, parseVersion(match[2].replace(/-/g, '.'))) + ], + // EDGE / IE ---------------------------------------------------------------------- + [ + /edg(e|ios|a)?\/([\w.]+)/i, + (match)=>_object_spread({ + name: 'edge', + mobile: /(edgios|edga)/i.test((_match_ = match[1]) !== null && _match_ !== void 0 ? _match_ : '') + }, parseVersion(match[2])) + ], + [ + /trident.+rv[: ]([\w.]{1,9})\b.+like gecko/i, + (match)=>_object_spread({ + name: 'ie', + mobile: false + }, parseVersion(match[1])) + ], + // OPERA -------------------------------------------------------------------------- + [ + /opr\/([\w.]+)/i, + /opera mini\/([-\w.]+)/i, + /opera [mobiletab]{3,6}\b.+version\/([-\w.]+)/i, + /opera(?:.+version\/|[/ ]+)([\w.]+)/i, + (match)=>_object_spread({ + name: 'opera', + mobile: /mobile/i.test(match[0]) + }, parseVersion(match[1])) + ], + // CHROME ------------------------------------------------------------------------- + // Headless + [ + /headlesschrome(?:\/([\w.]+)| )/i, + (match)=>_object_spread({ + name: 'chrome', + variant: 'headless', + mobile: false + }, parseVersion(match[1])) + ], + // Chrome for iOS + [ + /\b(?:crmo|crios)\/([\w.]+)/i, + (match)=>_object_spread({ + name: 'chrome', + mobile: true + }, parseVersion(match[1])) + ], + // Chrome + [ + /chrome(?: browser)?\/v?([\w.]+)( mobile)?/i, + (match)=>_object_spread({ + name: 'chrome', + mobile: /mobile/i.test((_match_1 = match[2]) !== null && _match_1 !== void 0 ? _match_1 : '') + }, parseVersion(match[1])) + ], + // FIREFOX ------------------------------------------------------------------------ + // Focus + [ + /\bfocus\/([\w.]+)/i, + (match)=>_object_spread({ + name: 'firefox', + variant: 'focus', + mobile: true + }, parseVersion(match[1])) + ], + // Firefox for iOS + [ + /fxios\/([\w.-]+)/i, + /(?:mobile|tablet);.*(?:firefox)\/([\w.-]+)/i, + (match)=>_object_spread({ + name: 'firefox', + mobile: true + }, parseVersion(match[1])) + ], + // Firefox OSS versions + [ + /(icedragon|iceweasel|camino|chimera|fennec|maemo browser|minimo|conkeror|klar)[/ ]?([\w.+]+)/i, + (match)=>_object_spread({ + name: 'firefox', + variant: match[1].trim().toLowerCase(), + mobile: false + }, parseVersion(match[2])) + ], + // Firefox + [ + /(?:firefox)\/([\w.]+)/i, + /(?:mozilla)\/([\w.]+) .+rv:.+gecko\/\d+/i, + (match)=>_object_spread({ + name: 'firefox', + mobile: false + }, parseVersion(match[1])) + ], + // SAFARI ------------------------------------------------------------------------- + [ + /version\/([\w.,]+) .*mobile(?:\/\w+ | ?)safari/i, + /version\/([\w.,]+) .*(safari)/i, + /webkit.+?(?:mobile ?safari|safari)(?:\/([\w.]+))/i, + (match)=>_object_spread({ + name: 'safari', + mobile: /mobile/i.test(match[0]) + }, parseVersion(match[1])) + ] + ], + // ENGINE --------------------------------------------------------------------------- + engine: [ + [ + /webkit\/(?:537\.36).+chrome\/(?!27)([\w.]+)/i, + (match)=>_object_spread({ + name: 'blink' + }, parseVersion(match[1])) + ], + [ + /windows.+ edge\/([\w.]+)/i, + (match)=>_object_spread({ + name: 'blink' + }, parseVersion(match[1])) + ], + [ + /presto\/([\w.]+)/i, + (match)=>_object_spread({ + name: 'presto' + }, parseVersion(match[2])) + ], + [ + /trident\/([\w.]+)/i, + (match)=>_object_spread({ + name: 'trident' + }, parseVersion(match[1])) + ], + [ + /gecko\/([\w.]+)/i, + (match)=>_object_spread({ + name: 'gecko' + }, parseVersion(match[1])) + ], + [ + /(khtml|netfront|netsurf|amaya|lynx|w3m|goanna)\/([\w.]+)/i, + (match)=>_object_spread({ + name: 'other' + }, parseVersion(match[2])) + ], + [ + /webkit\/([\w.]+)/i, + (match)=>_object_spread({ + name: 'webkit' + }, parseVersion(match[1])) + ] + ], + // OS ------------------------------------------------------------------------------- + os: [ + // Windows + [ + /microsoft windows (vista|xp)/i, + /windows nt 6\.2; (arm)/i, + /windows (?:phone(?: os)?|mobile)[/ ]?([\d.\w ]*)/i, + /windows[/ ]?([ntce\d. ]+\w)(?!.+xbox)/i, + /(?:win(?=3|9|n)|win 9x )([nt\d.]+)/i, + (match)=>_object_spread({ + name: 'windows' + }, parseVersion(match[1])) + ], + // iOS (iPhone/iPad) + [ + /ip[honead]{2,4}\b(?:.*os ([\w]+) like mac|; opera)/i, + /(?:ios;fbsv\/|iphone.+ios[/ ])([\d.]+)/i, + (match)=>_object_spread({ + name: 'ios' + }, parseVersion(match[1].replace(/_/g, '.'))) + ], + // macOS + [ + /mac(?:intosh;?)? os x ?([\d._]+)/i, + (match)=>_object_spread({ + name: 'macos' + }, parseVersion(match[1].replace(/_/g, '.'))) + ], + // ChromeOS + [ + /cros [\w]+(?:\)| ([\w.]+)\b)/i, + (match)=>_object_spread({ + name: 'chromeos' + }, parseVersion(match[1])) + ], + // Android + [ + /(?:android|webos|qnx|bada|rim tablet os|maemo|meego|sailfish)[-/ ]?([\w.]*)/i, + /droid ([\w.]+|[\d+])\b.+(android[- ]x86|harmonyos)/i, + (match)=>_object_spread({ + name: 'android' + }, parseVersion(match[1])) + ], + // Linux + [ + /linux/i, + ()=>({ + name: 'linux' + }) + ] + ] +}; +/** + * Extend a data structure by running a list of functions over it. + */ function applyExtensions(data, extensions) { + let result = data; + for (const extension of extensions){ + result = extension(result); + } + return result; +} +/** + * Parse the user agent string from the navigator object into a descriptor. + * + * @param navigator The Navigator object to parse + * @param options Parse options + * @returns The descriptor with optional extensions applied + */ function parseUserAgent(navigator, options) { + var _navigator, _options; + var _navigator_userAgent; + const descriptor = { + navigator: navigator, + ua: (_navigator_userAgent = (_navigator = navigator) === null || _navigator === void 0 ? void 0 : _navigator.userAgent) !== null && _navigator_userAgent !== void 0 ? _navigator_userAgent : '', + extensions: [], + browser: applyRules(RULES.browser, navigator, { + name: 'unknown', + mobile: false + }), + engine: applyRules(RULES.engine, navigator, { + name: 'unknown' + }), + os: applyRules(RULES.os, navigator, { + name: 'unknown' + }) + }; + var _options_extensions; + return applyExtensions(descriptor, (_options_extensions = (_options = options) === null || _options === void 0 ? void 0 : _options.extensions) !== null && _options_extensions !== void 0 ? _options_extensions : []); +} + +export { parseUserAgent }; diff --git a/shared/utils/node_modules/@amp/runtime-detect/dist/version.js b/shared/utils/node_modules/@amp/runtime-detect/dist/version.js new file mode 100644 index 0000000..eba858c --- /dev/null +++ b/shared/utils/node_modules/@amp/runtime-detect/dist/version.js @@ -0,0 +1,99 @@ +function parseVersion(input) { + const data = { + version: input.toLowerCase() + }; + const parts = input.toLowerCase().split('.').filter((p)=>!!p); + // Only parse single parts that are actual numbers. + // This check will prevent `parseInt` from pasing the leading chars if they are + // valid numbers. + if (parts.length <= 1 && !/^\d+$/.test(parts[0])) { + return data; + } + const major = parseInt(parts[0], 10); + const minor = parseInt(parts[1], 10); + const patch = parseInt(parts[2], 10); + // Only add converted versions if `major` part was valid + if (!Number.isNaN(major)) { + data.major = major; + if (!Number.isNaN(minor)) data.minor = minor; + if (!Number.isNaN(patch)) data.patch = patch; + } + return data; +} +/** + * Check if the given value is a complete Version struct. + */ // eslint-disable-next-line @typescript-eslint/no-explicit-any +function isVersion(value) { + var _value, _value1; + return typeof ((_value = value) === null || _value === void 0 ? void 0 : _value.major) === 'number' && typeof ((_value1 = value) === null || _value1 === void 0 ? void 0 : _value1.minor) === 'number'; +} +/** + * Compare two version numbers together. + * + * NOTE: This only supports the first 3 segments (major, minor, patch) and does not + * do a full SemVer compare. + * + * @example + * ```javascript + * compareVersion('1.2.3', '1.2.4'); + * // => -1 + * ``` + */ function compareVersion(base, comp) { + let baseList = toNumbers(base); + let compList = toNumbers(comp); + // Right pad versions with zeros to make them equal length + const versionLength = Math.max(baseList.length, compList.length); + baseList = baseList.concat(Array(versionLength).fill(0)).slice(0, versionLength); + compList = compList.concat(Array(versionLength).fill(0)).slice(0, versionLength); + /** Constrain the given value to the output range of this function. */ const constrain = (value)=>{ + if (value <= -1) return -1; + else if (value >= 1) return 1; + else return 0; + }; + for(let index = 0; index < versionLength; index++){ + const aValue = baseList[index]; + const bValue = compList[index]; + if (aValue !== bValue) { + return constrain(aValue - bValue); + } + } + return 0; +} +function eq(base, comp) { + return compareVersion(base, comp) === 0; +} +function gt(base, comp) { + return compareVersion(base, comp) > 0; +} +function gte(base, comp) { + const result = compareVersion(base, comp); + return result > 0 || result === 0; +} +function lt(base, comp) { + return compareVersion(base, comp) < 0; +} +function lte(base, comp) { + const result = compareVersion(base, comp); + return result < 0 || result === 0; +} +function toNumbers(value) { + if (Array.isArray(value)) { + return value; + } else if (typeof value === 'number') { + return [ + value + ]; + } else if (typeof value === 'string') { + return toNumbers(parseVersion(value)); + } else { + const values = [ + value.major, + value.minor, + value.patch + ]; + const uidx = values.indexOf(undefined); + return uidx === -1 ? values : values.slice(0, uidx); + } +} + +export { compareVersion, eq, gt, gte, isVersion, lt, lte, parseVersion }; 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); + }, + ); +} |
