UNPKG

@fingerprintjs/fingerprintjs

Version:

Browser fingerprinting library with the highest accuracy and stability

1,238 lines (1,227 loc) 133 kB
/** * FingerprintJS v5.1.0 - Copyright (c) FingerprintJS, Inc, 2026 (https://fingerprint.com) * * Licensed under MIT License * * Copyright (c) 2025 FingerprintJS, Inc * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ var FingerprintJS = (function (exports) { 'use strict'; var version = "5.1.0"; function wait(durationMs, resolveWith) { return new Promise((resolve) => setTimeout(resolve, durationMs, resolveWith)); } /** * Allows asynchronous actions and microtasks to happen. */ function releaseEventLoop() { // Don't use setTimeout because Chrome throttles it in some cases causing very long agent execution: // https://stackoverflow.com/a/6032591/1118709 // https://github.com/chromium/chromium/commit/0295dd09496330f3a9103ef7e543fa9b6050409b // Reusing a MessageChannel object gives no noticeable benefits return new Promise((resolve) => { const channel = new MessageChannel(); channel.port1.onmessage = () => resolve(); channel.port2.postMessage(null); }); } function requestIdleCallbackIfAvailable(fallbackTimeout, deadlineTimeout = Infinity) { const { requestIdleCallback } = window; if (requestIdleCallback) { // The function `requestIdleCallback` loses the binding to `window` here. // `globalThis` isn't always equal `window` (see https://github.com/fingerprintjs/fingerprintjs/issues/683). // Therefore, an error can occur. `call(window,` prevents the error. return new Promise((resolve) => requestIdleCallback.call(window, () => resolve(), { timeout: deadlineTimeout })); } else { return wait(Math.min(fallbackTimeout, deadlineTimeout)); } } function isPromise(value) { return !!value && typeof value.then === 'function'; } /** * Calls a maybe asynchronous function without creating microtasks when the function is synchronous. * Catches errors in both cases. * * If just you run a code like this: * ``` * console.time('Action duration') * await action() * console.timeEnd('Action duration') * ``` * The synchronous function time can be measured incorrectly because another microtask may run before the `await` * returns the control back to the code. */ function awaitIfAsync(action, callback) { try { const returnedValue = action(); if (isPromise(returnedValue)) { returnedValue.then((result) => callback(true, result), (error) => callback(false, error)); } else { callback(true, returnedValue); } } catch (error) { callback(false, error); } } /** * If you run many synchronous tasks without using this function, the JS main loop will be busy and asynchronous tasks * (e.g. completing a network request, rendering the page) won't be able to happen. * This function allows running many synchronous tasks such way that asynchronous tasks can run too in background. */ async function mapWithBreaks(items, callback, loopReleaseInterval = 16) { const results = Array(items.length); let lastLoopReleaseTime = Date.now(); for (let i = 0; i < items.length; ++i) { results[i] = callback(items[i], i); const now = Date.now(); if (now >= lastLoopReleaseTime + loopReleaseInterval) { lastLoopReleaseTime = now; await releaseEventLoop(); } } return results; } /** * Makes the given promise never emit an unhandled promise rejection console warning. * The promise will still pass errors to the next promises. * Returns the input promise for convenience. * * Otherwise, promise emits a console warning unless it has a `catch` listener. */ function suppressUnhandledRejectionWarning(promise) { promise.then(undefined, () => undefined); return promise; } /* * This file contains functions to work with pure data only (no browser features, DOM, side effects, etc). */ /** * Does the same as Array.prototype.includes but has better typing */ function includes(haystack, needle) { for (let i = 0, l = haystack.length; i < l; ++i) { if (haystack[i] === needle) { return true; } } return false; } /** * Like `!includes()` but with proper typing */ function excludes(haystack, needle) { return !includes(haystack, needle); } /** * Be careful, NaN can return */ function toInt(value) { return parseInt(value); } /** * Be careful, NaN can return */ function toFloat(value) { return parseFloat(value); } function replaceNaN(value, replacement) { return typeof value === 'number' && isNaN(value) ? replacement : value; } function countTruthy(values) { return values.reduce((sum, value) => sum + (value ? 1 : 0), 0); } function round(value, base = 1) { if (Math.abs(base) >= 1) { return Math.round(value / base) * base; } else { // Sometimes when a number is multiplied by a small number, precision is lost, // for example 1234 * 0.0001 === 0.12340000000000001, and it's more precise divide: 1234 / (1 / 0.0001) === 0.1234. const counterBase = 1 / base; return Math.round(value * counterBase) / counterBase; } } /** * Parses a CSS selector into tag name with HTML attributes. * Only single element selector are supported (without operators like space, +, >, etc). * * Multiple values can be returned for each attribute. You decide how to handle them. */ function parseSimpleCssSelector(selector) { var _a, _b; const errorMessage = `Unexpected syntax '${selector}'`; const tagMatch = /^\s*([a-z-]*)(.*)$/i.exec(selector); const tag = tagMatch[1] || undefined; const attributes = {}; const partsRegex = /([.:#][\w-]+|\[.+?\])/gi; const addAttribute = (name, value) => { attributes[name] = attributes[name] || []; attributes[name].push(value); }; for (;;) { const match = partsRegex.exec(tagMatch[2]); if (!match) { break; } const part = match[0]; switch (part[0]) { case '.': addAttribute('class', part.slice(1)); break; case '#': addAttribute('id', part.slice(1)); break; case '[': { const attributeMatch = /^\[([\w-]+)([~|^$*]?=("(.*?)"|([\w-]+)))?(\s+[is])?\]$/.exec(part); if (attributeMatch) { addAttribute(attributeMatch[1], (_b = (_a = attributeMatch[4]) !== null && _a !== void 0 ? _a : attributeMatch[5]) !== null && _b !== void 0 ? _b : ''); } else { throw new Error(errorMessage); } break; } default: throw new Error(errorMessage); } } return [tag, attributes]; } /** * Converts a string to UTF8 bytes */ function getUTF8Bytes(input) { // Benchmark: https://jsbench.me/b6klaaxgwq/1 // If you want to just count bytes, see solutions at https://jsbench.me/ehklab415e/1 const result = new Uint8Array(input.length); for (let i = 0; i < input.length; i++) { // `charCode` is faster than encoding, so we prefer that when it's possible const charCode = input.charCodeAt(i); // In case of non-ASCII symbols we use proper encoding if (charCode > 127) { return new TextEncoder().encode(input); } result[i] = charCode; } return result; } /* * Based on https://github.com/karanlyons/murmurHash3.js/blob/a33d0723127e2e5415056c455f8aed2451ace208/murmurHash3.js */ /** * Adds two 64-bit values (provided as tuples of 32-bit values) * and updates (mutates) first value to write the result */ function x64Add(m, n) { const m0 = m[0] >>> 16, m1 = m[0] & 0xffff, m2 = m[1] >>> 16, m3 = m[1] & 0xffff; const n0 = n[0] >>> 16, n1 = n[0] & 0xffff, n2 = n[1] >>> 16, n3 = n[1] & 0xffff; let o0 = 0, o1 = 0, o2 = 0, o3 = 0; o3 += m3 + n3; o2 += o3 >>> 16; o3 &= 0xffff; o2 += m2 + n2; o1 += o2 >>> 16; o2 &= 0xffff; o1 += m1 + n1; o0 += o1 >>> 16; o1 &= 0xffff; o0 += m0 + n0; o0 &= 0xffff; m[0] = (o0 << 16) | o1; m[1] = (o2 << 16) | o3; } /** * Multiplies two 64-bit values (provided as tuples of 32-bit values) * and updates (mutates) first value to write the result */ function x64Multiply(m, n) { const m0 = m[0] >>> 16, m1 = m[0] & 0xffff, m2 = m[1] >>> 16, m3 = m[1] & 0xffff; const n0 = n[0] >>> 16, n1 = n[0] & 0xffff, n2 = n[1] >>> 16, n3 = n[1] & 0xffff; let o0 = 0, o1 = 0, o2 = 0, o3 = 0; o3 += m3 * n3; o2 += o3 >>> 16; o3 &= 0xffff; o2 += m2 * n3; o1 += o2 >>> 16; o2 &= 0xffff; o2 += m3 * n2; o1 += o2 >>> 16; o2 &= 0xffff; o1 += m1 * n3; o0 += o1 >>> 16; o1 &= 0xffff; o1 += m2 * n2; o0 += o1 >>> 16; o1 &= 0xffff; o1 += m3 * n1; o0 += o1 >>> 16; o1 &= 0xffff; o0 += m0 * n3 + m1 * n2 + m2 * n1 + m3 * n0; o0 &= 0xffff; m[0] = (o0 << 16) | o1; m[1] = (o2 << 16) | o3; } /** * Provides left rotation of the given int64 value (provided as tuple of two int32) * by given number of bits. Result is written back to the value */ function x64Rotl(m, bits) { const m0 = m[0]; bits %= 64; if (bits === 32) { m[0] = m[1]; m[1] = m0; } else if (bits < 32) { m[0] = (m0 << bits) | (m[1] >>> (32 - bits)); m[1] = (m[1] << bits) | (m0 >>> (32 - bits)); } else { bits -= 32; m[0] = (m[1] << bits) | (m0 >>> (32 - bits)); m[1] = (m0 << bits) | (m[1] >>> (32 - bits)); } } /** * Provides a left shift of the given int32 value (provided as tuple of [0, int32]) * by given number of bits. Result is written back to the value */ function x64LeftShift(m, bits) { bits %= 64; if (bits === 0) { return; } else if (bits < 32) { m[0] = m[1] >>> (32 - bits); m[1] = m[1] << bits; } else { m[0] = m[1] << (bits - 32); m[1] = 0; } } /** * Provides a XOR of the given int64 values(provided as tuple of two int32). * Result is written back to the first value */ function x64Xor(m, n) { m[0] ^= n[0]; m[1] ^= n[1]; } const F1 = [0xff51afd7, 0xed558ccd]; const F2 = [0xc4ceb9fe, 0x1a85ec53]; /** * Calculates murmurHash3's final x64 mix of that block and writes result back to the input value. * (`[0, h[0] >>> 1]` is a 33 bit unsigned right shift. This is the * only place where we need to right shift 64bit ints.) */ function x64Fmix(h) { const shifted = [0, h[0] >>> 1]; x64Xor(h, shifted); x64Multiply(h, F1); shifted[1] = h[0] >>> 1; x64Xor(h, shifted); x64Multiply(h, F2); shifted[1] = h[0] >>> 1; x64Xor(h, shifted); } const C1 = [0x87c37b91, 0x114253d5]; const C2 = [0x4cf5ad43, 0x2745937f]; const M$1 = [0, 5]; const N1 = [0, 0x52dce729]; const N2 = [0, 0x38495ab5]; /** * Given a string and an optional seed as an int, returns a 128 bit * hash using the x64 flavor of MurmurHash3, as an unsigned hex. * All internal functions mutates passed value to achieve minimal memory allocations and GC load * * Benchmark https://jsbench.me/p4lkpaoabi/1 */ function x64hash128(input, seed) { const key = getUTF8Bytes(input); seed = seed || 0; const length = [0, key.length]; const remainder = length[1] % 16; const bytes = length[1] - remainder; const h1 = [0, seed]; const h2 = [0, seed]; const k1 = [0, 0]; const k2 = [0, 0]; let i; for (i = 0; i < bytes; i = i + 16) { k1[0] = key[i + 4] | (key[i + 5] << 8) | (key[i + 6] << 16) | (key[i + 7] << 24); k1[1] = key[i] | (key[i + 1] << 8) | (key[i + 2] << 16) | (key[i + 3] << 24); k2[0] = key[i + 12] | (key[i + 13] << 8) | (key[i + 14] << 16) | (key[i + 15] << 24); k2[1] = key[i + 8] | (key[i + 9] << 8) | (key[i + 10] << 16) | (key[i + 11] << 24); x64Multiply(k1, C1); x64Rotl(k1, 31); x64Multiply(k1, C2); x64Xor(h1, k1); x64Rotl(h1, 27); x64Add(h1, h2); x64Multiply(h1, M$1); x64Add(h1, N1); x64Multiply(k2, C2); x64Rotl(k2, 33); x64Multiply(k2, C1); x64Xor(h2, k2); x64Rotl(h2, 31); x64Add(h2, h1); x64Multiply(h2, M$1); x64Add(h2, N2); } k1[0] = 0; k1[1] = 0; k2[0] = 0; k2[1] = 0; const val = [0, 0]; switch (remainder) { case 15: val[1] = key[i + 14]; x64LeftShift(val, 48); x64Xor(k2, val); // fallthrough case 14: val[1] = key[i + 13]; x64LeftShift(val, 40); x64Xor(k2, val); // fallthrough case 13: val[1] = key[i + 12]; x64LeftShift(val, 32); x64Xor(k2, val); // fallthrough case 12: val[1] = key[i + 11]; x64LeftShift(val, 24); x64Xor(k2, val); // fallthrough case 11: val[1] = key[i + 10]; x64LeftShift(val, 16); x64Xor(k2, val); // fallthrough case 10: val[1] = key[i + 9]; x64LeftShift(val, 8); x64Xor(k2, val); // fallthrough case 9: val[1] = key[i + 8]; x64Xor(k2, val); x64Multiply(k2, C2); x64Rotl(k2, 33); x64Multiply(k2, C1); x64Xor(h2, k2); // fallthrough case 8: val[1] = key[i + 7]; x64LeftShift(val, 56); x64Xor(k1, val); // fallthrough case 7: val[1] = key[i + 6]; x64LeftShift(val, 48); x64Xor(k1, val); // fallthrough case 6: val[1] = key[i + 5]; x64LeftShift(val, 40); x64Xor(k1, val); // fallthrough case 5: val[1] = key[i + 4]; x64LeftShift(val, 32); x64Xor(k1, val); // fallthrough case 4: val[1] = key[i + 3]; x64LeftShift(val, 24); x64Xor(k1, val); // fallthrough case 3: val[1] = key[i + 2]; x64LeftShift(val, 16); x64Xor(k1, val); // fallthrough case 2: val[1] = key[i + 1]; x64LeftShift(val, 8); x64Xor(k1, val); // fallthrough case 1: val[1] = key[i]; x64Xor(k1, val); x64Multiply(k1, C1); x64Rotl(k1, 31); x64Multiply(k1, C2); x64Xor(h1, k1); // fallthrough } x64Xor(h1, length); x64Xor(h2, length); x64Add(h1, h2); x64Add(h2, h1); x64Fmix(h1); x64Fmix(h2); x64Add(h1, h2); x64Add(h2, h1); return (('00000000' + (h1[0] >>> 0).toString(16)).slice(-8) + ('00000000' + (h1[1] >>> 0).toString(16)).slice(-8) + ('00000000' + (h2[0] >>> 0).toString(16)).slice(-8) + ('00000000' + (h2[1] >>> 0).toString(16)).slice(-8)); } /** * Converts an error object to a plain object that can be used with `JSON.stringify`. * If you just run `JSON.stringify(error)`, you'll get `'{}'`. */ function errorToObject(error) { var _a; return { name: error.name, message: error.message, stack: (_a = error.stack) === null || _a === void 0 ? void 0 : _a.split('\n'), // The fields are not enumerable, so TS is wrong saying that they will be overridden ...error, }; } function isFunctionNative(func) { return /^function\s.*?\{\s*\[native code]\s*}$/.test(String(func)); } /* eslint-disable @typescript-eslint/no-explicit-any */ function isFinalResultLoaded(loadResult) { return typeof loadResult !== 'function'; } /** * Loads the given entropy source. Returns a function that gets an entropy component from the source. * * The result is returned synchronously to prevent `loadSources` from * waiting for one source to load before getting the components from the other sources. */ function loadSource(source, sourceOptions) { const sourceLoadPromise = suppressUnhandledRejectionWarning(new Promise((resolveLoad) => { const loadStartTime = Date.now(); // `awaitIfAsync` is used instead of just `await` in order to measure the duration of synchronous sources // correctly (other microtasks won't affect the duration). awaitIfAsync(source.bind(null, sourceOptions), (...loadArgs) => { const loadDuration = Date.now() - loadStartTime; // Source loading failed if (!loadArgs[0]) { return resolveLoad(() => ({ error: loadArgs[1], duration: loadDuration })); } const loadResult = loadArgs[1]; // Source loaded with the final result if (isFinalResultLoaded(loadResult)) { return resolveLoad(() => ({ value: loadResult, duration: loadDuration })); } // Source loaded with "get" stage resolveLoad(() => new Promise((resolveGet) => { const getStartTime = Date.now(); awaitIfAsync(loadResult, (...getArgs) => { const duration = loadDuration + Date.now() - getStartTime; // Source getting failed if (!getArgs[0]) { return resolveGet({ error: getArgs[1], duration }); } // Source getting succeeded resolveGet({ value: getArgs[1], duration }); }); })); }); })); return function getComponent() { return sourceLoadPromise.then((finalizeSource) => finalizeSource()); }; } /** * Loads the given entropy sources. Returns a function that collects the entropy components. * * The result is returned synchronously in order to allow start getting the components * before the sources are loaded completely. * * Warning for package users: * This function is out of Semantic Versioning, i.e. can change unexpectedly. Usage is at your own risk. */ function loadSources(sources, sourceOptions, excludeSources, loopReleaseInterval) { const includedSources = Object.keys(sources).filter((sourceKey) => excludes(excludeSources, sourceKey)); // Using `mapWithBreaks` allows asynchronous sources to complete between synchronous sources // and measure the duration correctly const sourceGettersPromise = suppressUnhandledRejectionWarning(mapWithBreaks(includedSources, (sourceKey) => loadSource(sources[sourceKey], sourceOptions), loopReleaseInterval)); return async function getComponents() { const sourceGetters = await sourceGettersPromise; const componentPromises = await mapWithBreaks(sourceGetters, (sourceGetter) => suppressUnhandledRejectionWarning(sourceGetter()), loopReleaseInterval); const componentArray = await Promise.all(componentPromises); // Keeping the component keys order the same as the source keys order const components = {}; for (let index = 0; index < includedSources.length; ++index) { components[includedSources[index]] = componentArray[index]; } return components; }; } /** * Modifies an entropy source by transforming its returned value with the given function. * Keeps the source properties: sync/async, 1/2 stages. * * Warning for package users: * This function is out of Semantic Versioning, i.e. can change unexpectedly. Usage is at your own risk. */ function transformSource(source, transformValue) { const transformLoadResult = (loadResult) => { if (isFinalResultLoaded(loadResult)) { return transformValue(loadResult); } return () => { const getResult = loadResult(); if (isPromise(getResult)) { return getResult.then(transformValue); } return transformValue(getResult); }; }; return (options) => { const loadResult = source(options); if (isPromise(loadResult)) { return loadResult.then(transformLoadResult); } return transformLoadResult(loadResult); }; } /* * Functions to help with features that vary through browsers */ /** * Checks whether the browser is based on Trident (the Internet Explorer engine) without using user-agent. * * Warning for package users: * This function is out of Semantic Versioning, i.e. can change unexpectedly. Usage is at your own risk. */ function isTrident() { const w = window; const n = navigator; // The properties are checked to be in IE 10, IE 11 and not to be in other browsers in October 2020 return (countTruthy([ 'MSCSSMatrix' in w, 'msSetImmediate' in w, 'msIndexedDB' in w, 'msMaxTouchPoints' in n, 'msPointerEnabled' in n, ]) >= 4); } /** * Checks whether the browser is based on EdgeHTML (the pre-Chromium Edge engine) without using user-agent. * * Warning for package users: * This function is out of Semantic Versioning, i.e. can change unexpectedly. Usage is at your own risk. */ function isEdgeHTML() { // Based on research in October 2020 const w = window; const n = navigator; return (countTruthy(['msWriteProfilerMark' in w, 'MSStream' in w, 'msLaunchUri' in n, 'msSaveBlob' in n]) >= 3 && !isTrident()); } /** * Checks whether the browser is based on Chromium without using user-agent. * * Warning for package users: * This function is out of Semantic Versioning, i.e. can change unexpectedly. Usage is at your own risk. */ function isChromium() { // Based on research in October 2020. Tested to detect Chromium 42-86. const w = window; const n = navigator; return (countTruthy([ 'webkitPersistentStorage' in n, 'webkitTemporaryStorage' in n, (n.vendor || '').indexOf('Google') === 0, 'webkitResolveLocalFileSystemURL' in w, 'BatteryManager' in w, 'webkitMediaStream' in w, 'webkitSpeechGrammar' in w, ]) >= 5); } /** * Checks whether the browser is based on mobile or desktop Safari without using user-agent. * All iOS browsers use WebKit (the Safari engine). * * Warning for package users: * This function is out of Semantic Versioning, i.e. can change unexpectedly. Usage is at your own risk. */ function isWebKit() { // Based on research in August 2024 const w = window; const n = navigator; return (countTruthy([ 'ApplePayError' in w, 'CSSPrimitiveValue' in w, 'Counter' in w, n.vendor.indexOf('Apple') === 0, 'RGBColor' in w, 'WebKitMediaKeys' in w, ]) >= 4); } /** * Checks whether this WebKit browser is a desktop browser. * It doesn't check that the browser is based on WebKit, there is a separate function for this. * * Warning for package users: * This function is out of Semantic Versioning, i.e. can change unexpectedly. Usage is at your own risk. */ function isDesktopWebKit() { // Checked in Safari and DuckDuckGo const w = window; const { HTMLElement, Document } = w; return (countTruthy([ 'safari' in w, !('ongestureend' in w), !('TouchEvent' in w), !('orientation' in w), HTMLElement && !('autocapitalize' in HTMLElement.prototype), Document && 'pointerLockElement' in Document.prototype, ]) >= 4); } /** * Checks whether this WebKit browser is Safari. * It doesn't check that the browser is based on WebKit, there is a separate function for this. * * Warning! The function works properly only for Safari version 15.4 and newer. */ function isSafariWebKit() { // Checked in Safari, Chrome, Firefox, Yandex, UC Browser, Opera, Edge and DuckDuckGo. // iOS Safari and Chrome were checked on iOS 11-18. DuckDuckGo was checked on iOS 17-18 and macOS 14-15. // Desktop Safari versions 12-18 were checked. // The other browsers were checked on iOS 17 and 18; there was no chance to check them on the other OS versions. const w = window; return ( // Filters-out Chrome, Yandex, DuckDuckGo (macOS and iOS), Edge isFunctionNative(w.print) && // Doesn't work in Safari < 15.4 String(w.browser) === '[object WebPageNamespace]'); } /** * Checks whether the browser is based on Gecko (Firefox engine) without using user-agent. * * Warning for package users: * This function is out of Semantic Versioning, i.e. can change unexpectedly. Usage is at your own risk. */ function isGecko() { var _a, _b; const w = window; // Based on research in September 2020 return (countTruthy([ 'buildID' in navigator, 'MozAppearance' in ((_b = (_a = document.documentElement) === null || _a === void 0 ? void 0 : _a.style) !== null && _b !== void 0 ? _b : {}), 'onmozfullscreenchange' in w, 'mozInnerScreenX' in w, 'CSSMozDocumentRule' in w, 'CanvasCaptureMediaStream' in w, ]) >= 4); } /** * Checks whether the browser is based on Gecko version ≥120 (Firefox ≥120) without using user-agent. * It doesn't check that the browser is based on Gecko; there is a separate function for this. * * @see https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/120 Firefox 120 release notes */ function isGecko120OrNewer() { // Checked in Firefox 119 vs. Firefox 120+ const w = window; const n = navigator; const { CSS } = w; // We use a threshold of 3 out of 4 because `globalPrivacyControl` was added in Firefox 120 on desktop, // but only in Firefox 122 on mobile. Using >= 3 ensures detection works on both platforms for Firefox 120+. // @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/globalPrivacyControl#browser_compatibility return (countTruthy([ // User Activation API - added in Firefox 120 'userActivation' in n, // CSS light-dark() function - added in Firefox 120 CSS.supports('color', 'light-dark(#000, #fff)'), // CSS lh unit - added in Firefox 120 CSS.supports('height', '1lh'), // Global Privacy Control - added in Firefox 120 (desktop) / Firefox 122 (mobile) 'globalPrivacyControl' in n, ]) >= 3); } /** * Checks whether the browser is based on Gecko version ≥143 (Firefox ≥143) without using user-agent. * It doesn't check that the browser is based on Gecko; there is a separate function for this. * * Firefox 143 shipped Phase 2 fingerprinting protections (screen resolution, processor count, touch points) * in Private Browsing and ETP Strict mode. * * @see https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/143 Firefox 143 release notes * @see https://bugzilla.mozilla.org/show_bug.cgi?id=1978414 Bug that shipped these protections */ function isGecko143OrNewer() { // Checked in Firefox 142 vs. Firefox 143+ const { CSS } = window; return (countTruthy([ // CSS ::details-content pseudo-element - added in Firefox 143 CSS.supports('selector(::details-content)'), // CSS ::before::marker nested pseudo-element - added in Firefox 143 CSS.supports('selector(::before::marker)'), // CSS ::after::marker nested pseudo-element - added in Firefox 143 CSS.supports('selector(::after::marker)'), // CompositionEvent.locale property - removed in Firefox 143 (Bug 1700969) !('locale' in CompositionEvent.prototype), ]) >= 3); } /** * Checks whether the browser is based on Chromium version ≥86 without using user-agent. * It doesn't check that the browser is based on Chromium, there is a separate function for this. */ function isChromium86OrNewer() { // Checked in Chrome 85 vs Chrome 86 both on desktop and Android. Checked in macOS Chrome 128, Android Chrome 127. const w = window; return (countTruthy([ !('MediaSettingsRange' in w), 'RTCEncodedAudioFrame' in w, '' + w.Intl === '[object Intl]', '' + w.Reflect === '[object Reflect]', ]) >= 3); } /** * Checks whether the browser is based on Chromium version ≥122 without using user-agent. * It doesn't check that the browser is based on Chromium, there is a separate function for this. */ function isChromium122OrNewer() { // Checked in Chrome 121 vs Chrome 122 and 129 both on desktop and Android const w = window; const { URLPattern } = w; return (countTruthy([ 'union' in Set.prototype, 'Iterator' in w, URLPattern && 'hasRegExpGroups' in URLPattern.prototype, 'RGB8' in WebGLRenderingContext.prototype, ]) >= 3); } /** * Checks whether the browser is based on WebKit version ≥606 (Safari ≥12) without using user-agent. * It doesn't check that the browser is based on WebKit, there is a separate function for this. * * @see https://en.wikipedia.org/wiki/Safari_version_history#Release_history Safari-WebKit versions map */ function isWebKit606OrNewer() { // Checked in Safari 9–18 const w = window; return (countTruthy([ 'DOMRectList' in w, 'RTCPeerConnectionIceEvent' in w, 'SVGGeometryElement' in w, 'ontransitioncancel' in w, ]) >= 3); } /** * Checks whether the browser is based on WebKit version ≥616 (Safari ≥17) without using user-agent. * It doesn't check that the browser is based on WebKit, there is a separate function for this. * * @see https://developer.apple.com/documentation/safari-release-notes/safari-17-release-notes Safari 17 release notes * @see https://tauri.app/v1/references/webview-versions/#webkit-versions-in-safari Safari-WebKit versions map */ function isWebKit616OrNewer() { const w = window; const n = navigator; const { CSS, HTMLButtonElement } = w; return (countTruthy([ !('getStorageUpdates' in n), HTMLButtonElement && 'popover' in HTMLButtonElement.prototype, 'CSSCounterStyleRule' in w, CSS.supports('font-size-adjust: ex-height 0.5'), CSS.supports('text-transform: full-width'), ]) >= 4); } /** * Checks whether the device is an iPad. * It doesn't check that the engine is WebKit and that the WebKit isn't desktop. */ function isIPad() { // Checked on: // Safari on iPadOS (both mobile and desktop modes): 8, 11-18 // Chrome on iPadOS (both mobile and desktop modes): 11-18 // Safari on iOS (both mobile and desktop modes): 9-18 // Chrome on iOS (both mobile and desktop modes): 9-18 // Before iOS 13. Safari tampers the value in "request desktop site" mode since iOS 13. if (navigator.platform === 'iPad') { return true; } const s = screen; const screenRatio = s.width / s.height; return (countTruthy([ // Since iOS 13. Doesn't work in Chrome on iPadOS <15, but works in desktop mode. 'MediaSource' in window, // Since iOS 12. Doesn't work in Chrome on iPadOS. !!Element.prototype.webkitRequestFullscreen, // iPhone 4S that runs iOS 9 matches this, but it is not supported // Doesn't work in incognito mode of Safari ≥17 with split screen because of tracking prevention screenRatio > 0.65 && screenRatio < 1.53, ]) >= 2); } /** * Warning for package users: * This function is out of Semantic Versioning, i.e. can change unexpectedly. Usage is at your own risk. */ function getFullscreenElement() { const d = document; return d.fullscreenElement || d.msFullscreenElement || d.mozFullScreenElement || d.webkitFullscreenElement || null; } function exitFullscreen() { const d = document; // `call` is required because the function throws an error without a proper "this" context return (d.exitFullscreen || d.msExitFullscreen || d.mozCancelFullScreen || d.webkitExitFullscreen).call(d); } /** * Checks whether the device runs on Android without using user-agent. * * Warning for package users: * This function is out of Semantic Versioning, i.e. can change unexpectedly. Usage is at your own risk. */ function isAndroid() { const isItChromium = isChromium(); const isItGecko = isGecko(); const w = window; const n = navigator; const c = 'connection'; // Chrome removes all words "Android" from `navigator` when desktop version is requested // Firefox keeps "Android" in `navigator.appVersion` when desktop version is requested if (isItChromium) { return (countTruthy([ !('SharedWorker' in w), // `typechange` is deprecated, but it's still present on Android (tested on Chrome Mobile 117) // Removal proposal https://bugs.chromium.org/p/chromium/issues/detail?id=699892 // Note: this expression returns true on ChromeOS, so additional detectors are required to avoid false-positives n[c] && 'ontypechange' in n[c], !('sinkId' in new Audio()), ]) >= 2); } else if (isItGecko) { return countTruthy(['onorientationchange' in w, 'orientation' in w, /android/i.test(n.appVersion)]) >= 2; } else { // Only 2 browser engines are presented on Android. // Actually, there is also Android 4.1 browser, but it's not worth detecting it at the moment. return false; } } /** * Checks whether the browser is Samsung Internet without using user-agent. * It doesn't check that the browser is based on Chromium, please use `isChromium` before using this function. * * Warning for package users: * This function is out of Semantic Versioning, i.e. can change unexpectedly. Usage is at your own risk. */ function isSamsungInternet() { // Checked in Samsung Internet 21, 25 and 27 const n = navigator; const w = window; const audioPrototype = Audio.prototype; const { visualViewport } = w; return (countTruthy([ 'srLatency' in audioPrototype, 'srChannelCount' in audioPrototype, 'devicePosture' in n, visualViewport && 'segments' in visualViewport, 'getTextInformation' in Image.prototype, // Not available in Samsung Internet 21 ]) >= 3); } /** * A deep description: https://fingerprint.com/blog/audio-fingerprinting/ * Inspired by and based on https://github.com/cozylife/audio-fingerprint * * A version of the entropy source with stabilization to make it suitable for static fingerprinting. * Audio signal is noised in private mode of Safari 17, so audio fingerprinting is skipped in Safari 17. */ function getAudioFingerprint() { if (doesBrowserPerformAntifingerprinting()) { return -4 /* SpecialFingerprint.KnownForAntifingerprinting */; } return getUnstableAudioFingerprint(); } /** * A version of the entropy source without stabilization. * * Warning for package users: * This function is out of Semantic Versioning, i.e. can change unexpectedly. Usage is at your own risk. */ function getUnstableAudioFingerprint() { const w = window; const AudioContext = w.OfflineAudioContext || w.webkitOfflineAudioContext; if (!AudioContext) { return -2 /* SpecialFingerprint.NotSupported */; } // In some browsers, audio context always stays suspended unless the context is started in response to a user action // (e.g. a click or a tap). It prevents audio fingerprint from being taken at an arbitrary moment of time. // Such browsers are old and unpopular, so the audio fingerprinting is just skipped in them. // See a similar case explanation at https://stackoverflow.com/questions/46363048/onaudioprocess-not-called-on-ios11#46534088 if (doesBrowserSuspendAudioContext()) { return -1 /* SpecialFingerprint.KnownForSuspending */; } const hashFromIndex = 4500; const hashToIndex = 5000; const context = new AudioContext(1, hashToIndex, 44100); const oscillator = context.createOscillator(); oscillator.type = 'triangle'; oscillator.frequency.value = 10000; const compressor = context.createDynamicsCompressor(); compressor.threshold.value = -50; compressor.knee.value = 40; compressor.ratio.value = 12; compressor.attack.value = 0; compressor.release.value = 0.25; oscillator.connect(compressor); compressor.connect(context.destination); oscillator.start(0); const [renderPromise, finishRendering] = startRenderingAudio(context); // Suppresses the console error message in case when the fingerprint fails before requested const fingerprintPromise = suppressUnhandledRejectionWarning(renderPromise.then((buffer) => getHash(buffer.getChannelData(0).subarray(hashFromIndex)), (error) => { if (error.name === "timeout" /* InnerErrorName.Timeout */ || error.name === "suspended" /* InnerErrorName.Suspended */) { return -3 /* SpecialFingerprint.Timeout */; } throw error; })); return () => { finishRendering(); return fingerprintPromise; }; } /** * Checks if the current browser is known for always suspending audio context */ function doesBrowserSuspendAudioContext() { // Mobile Safari 11 and older return isWebKit() && !isDesktopWebKit() && !isWebKit606OrNewer(); } /** * Checks if the current browser is known for applying anti-fingerprinting measures in all or some critical modes */ function doesBrowserPerformAntifingerprinting() { return ( // Safari ≥17 (isWebKit() && isWebKit616OrNewer() && isSafariWebKit()) || // Samsung Internet ≥26 (isChromium() && isSamsungInternet() && isChromium122OrNewer())); } /** * Starts rendering the audio context. * When the returned function is called, the render process starts finishing. */ function startRenderingAudio(context) { const renderTryMaxCount = 3; const renderRetryDelay = 500; const runningMaxAwaitTime = 500; const runningSufficientTime = 5000; let finalize = () => undefined; const resultPromise = new Promise((resolve, reject) => { let isFinalized = false; let renderTryCount = 0; let startedRunningAt = 0; context.oncomplete = (event) => resolve(event.renderedBuffer); const startRunningTimeout = () => { setTimeout(() => reject(makeInnerError("timeout" /* InnerErrorName.Timeout */)), Math.min(runningMaxAwaitTime, startedRunningAt + runningSufficientTime - Date.now())); }; const tryRender = () => { try { const renderingPromise = context.startRendering(); // `context.startRendering` has two APIs: Promise and callback, we check that it's really a promise just in case if (isPromise(renderingPromise)) { // Suppresses all unhandled rejections in case of scheduled redundant retries after successful rendering suppressUnhandledRejectionWarning(renderingPromise); } switch (context.state) { case 'running': startedRunningAt = Date.now(); if (isFinalized) { startRunningTimeout(); } break; // Sometimes the audio context doesn't start after calling `startRendering` (in addition to the cases where // audio context doesn't start at all). A known case is starting an audio context when the browser tab is in // background on iPhone. Retries usually help in this case. case 'suspended': // The audio context can reject starting until the tab is in foreground. Long fingerprint duration // in background isn't a problem, therefore the retry attempts don't count in background. It can lead to // a situation when a fingerprint takes very long time and finishes successfully. FYI, the audio context // can be suspended when `document.hidden === false` and start running after a retry. if (!document.hidden) { renderTryCount++; } if (isFinalized && renderTryCount >= renderTryMaxCount) { reject(makeInnerError("suspended" /* InnerErrorName.Suspended */)); } else { setTimeout(tryRender, renderRetryDelay); } break; } } catch (error) { reject(error); } }; tryRender(); finalize = () => { if (!isFinalized) { isFinalized = true; if (startedRunningAt > 0) { startRunningTimeout(); } } }; }); return [resultPromise, finalize]; } function getHash(signal) { let hash = 0; for (let i = 0; i < signal.length; ++i) { hash += Math.abs(signal[i]); } return hash; } function makeInnerError(name) { const error = new Error(name); error.name = name; return error; } /** * Creates and keeps an invisible iframe while the given function runs. * The given function is called when the iframe is loaded and has a body. * The iframe allows to measure DOM sizes inside itself. * * Notice: passing an initial HTML code doesn't work in IE. * * Warning for package users: * This function is out of Semantic Versioning, i.e. can change unexpectedly. Usage is at your own risk. */ async function withIframe(action, initialHtml, domPollInterval = 50) { var _a, _b, _c; const d = document; // document.body can be null while the page is loading while (!d.body) { await wait(domPollInterval); } const iframe = d.createElement('iframe'); try { await new Promise((_resolve, _reject) => { let isComplete = false; const resolve = () => { isComplete = true; _resolve(); }; const reject = (error) => { isComplete = true; _reject(error); }; iframe.onload = resolve; iframe.onerror = reject; const { style } = iframe; style.setProperty('display', 'block', 'important'); // Required for browsers to calculate the layout style.position = 'absolute'; style.top = '0'; style.left = '0'; style.visibility = 'hidden'; if (initialHtml && 'srcdoc' in iframe) { iframe.srcdoc = initialHtml; } else { iframe.src = 'about:blank'; } d.body.appendChild(iframe); // WebKit in WeChat doesn't fire the iframe's `onload` for some reason. // This code checks for the loading state manually. // See https://github.com/fingerprintjs/fingerprintjs/issues/645 const checkReadyState = () => { var _a, _b; // The ready state may never become 'complete' in Firefox despite the 'load' event being fired. // So an infinite setTimeout loop can happen without this check. // See https://github.com/fingerprintjs/fingerprintjs/pull/716#issuecomment-986898796 if (isComplete) { return; } // Make sure iframe.contentWindow and iframe.contentWindow.document are both loaded // The contentWindow.document can miss in JSDOM (https://github.com/jsdom/jsdom). if (((_b = (_a = iframe.contentWindow) === null || _a === void 0 ? void 0 : _a.document) === null || _b === void 0 ? void 0 : _b.readyState) === 'complete') { resolve(); } else { setTimeout(checkReadyState, 10); } }; checkReadyState(); }); while (!((_b = (_a = iframe.contentWindow) === null || _a === void 0 ? void 0 : _a.document) === null || _b === void 0 ? void 0 : _b.body)) { await wait(domPollInterval); } return await action(iframe, iframe.contentWindow); } finally { (_c = iframe.parentNode) === null || _c === void 0 ? void 0 : _c.removeChild(iframe); } } /** * Creates a DOM element that matches the given selector. * Only single element selector are supported (without operators like space, +, >, etc). */ function selectorToElement(selector) { const [tag, attributes] = parseSimpleCssSelector(selector); const element = document.createElement(tag !== null && tag !== void 0 ? tag : 'div'); for (const name of Object.keys(attributes)) { const value = attributes[name].join(' '); // Changing the `style` attribute can cause a CSP error, therefore we change the `style.cssText` property. // https://github.com/fingerprintjs/fingerprintjs/issues/733 if (name === 'style') { addStyleString(element.style, value); } else { element.setAttribute(name, value); } } return element; } /** * Adds CSS styles from a string in such a way that doesn't trigger a CSP warning (unsafe-inline or unsafe-eval) */ function addStyleString(style, source) { // We don't use `style.cssText` because browsers must block it when no `unsafe-eval` CSP is presented: https://csplite.com/csp145/#w3c_note // Even though the browsers ignore this standard, we don't use `cssText` just in case. for (const property of source.split(';')) { const match = /^\s*([\w-]+)\s*:\s*(.+?)(\s*!([\w-]+))?\s*$/.exec(property); if (match) { const [, name, value, , priority] = match; style.setProperty(name, value, priority || ''); // The last argument can't be undefined in IE11 } } } /** * Returns true if the code runs in an iframe, and any parent page's origin doesn't match the current origin */ function isAnyParentCrossOrigin() { let currentWindow = window; for (;;) { const parentWindow = currentWindow.parent; if (!parentWindow || parentWindow === currentWindow) { return false; // The top page is reached } try { if (parentWindow.location.origin !== currentWindow.location.origin) {