summaryrefslogtreecommitdiff
path: root/shared/utils
diff options
context:
space:
mode:
authorrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
committerrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
commitbce557cc2dc767628bed6aac87301a1be7c5431b (patch)
treeb51a051228d01fe3306cd7626d4a96768aadb944 /shared/utils
init commit
Diffstat (limited to 'shared/utils')
-rw-r--r--shared/utils/node_modules/@amp/runtime-detect/dist/extensions/compare.js83
-rw-r--r--shared/utils/node_modules/@amp/runtime-detect/dist/extensions/flags.js105
-rw-r--r--shared/utils/node_modules/@amp/runtime-detect/dist/rules.js22
-rw-r--r--shared/utils/node_modules/@amp/runtime-detect/dist/user-agent.js392
-rw-r--r--shared/utils/node_modules/@amp/runtime-detect/dist/version.js99
-rw-r--r--shared/utils/src/get-pwa-display-mode.ts39
-rw-r--r--shared/utils/src/history.ts168
-rw-r--r--shared/utils/src/is-pojo.ts20
-rw-r--r--shared/utils/src/launch/launch-client.ts109
-rw-r--r--shared/utils/src/launch/scheme.ts339
-rw-r--r--shared/utils/src/lru-map.ts60
-rw-r--r--shared/utils/src/object-from-entries.ts18
-rw-r--r--shared/utils/src/optional.ts22
-rw-r--r--shared/utils/src/platform.ts249
-rw-r--r--shared/utils/src/try-scroll.ts65
-rw-r--r--shared/utils/src/url.ts90
-rw-r--r--shared/utils/src/uuid.ts22
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);
+ },
+ );
+}