get-browser-fingerprint 5.1.1 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -29
- package/package.json +3 -3
- package/src/index.d.ts +2 -1
- package/src/index.js +298 -261
package/README.md
CHANGED
|
@@ -1,60 +1,130 @@
|
|
|
1
1
|
# get-browser-fingerprint
|
|
2
2
|
|
|
3
|
-
A single
|
|
4
|
-
|
|
3
|
+
A single asynchronous function that returns a browser fingerprint (8‑char hex) without requiring any user permission.
|
|
4
|
+
|
|
5
|
+
Live [example here](https://damianobarbati.github.io/get-browser-fingerprint/).
|
|
6
|
+
|
|
7
|
+
## Return value
|
|
8
|
+
|
|
9
|
+
- **`fingerprint`** — MurmurHash3 (32-bit, hex) of the stable stringified payload.
|
|
10
|
+
- **`elapsedMs`** — Execution time in milliseconds.
|
|
11
|
+
- All collected signals are also spread on the returned object (e.g. `timezone`, `canvasID`, `webglID`, `languages`, …).
|
|
12
|
+
|
|
13
|
+
## Collected signals
|
|
14
|
+
|
|
15
|
+
- **UA / platform:** `vendor`, `platform`, `userAgentHighEntropy` (UA Client Hints: architecture, bitness, platformVersion, model, fullVersionList).
|
|
16
|
+
- **Locale / Intl:** `languages`, `timezone`, `timezoneOffset`, `numberingSystem`, `intlCalendar`, `hourCycle`, `intlSupportedCalendars`.
|
|
17
|
+
- **Preferences:** `reducedMotion`, `colorScheme`, `prefersContrast`, `prefersReducedTransparency`, `forcedColors`.
|
|
18
|
+
- **Screen:** `devicePixelRatio`, `pixelDepth`, `colorDepth`, `screenOrientation`; on mobile: `width`, `height`, `aspectRatio`, `maxTouchPoints`.
|
|
19
|
+
- **Network:** `connectionType`, `effectiveType`, `saveData` (when available).
|
|
20
|
+
- **Device:** `hardwareConcurrency`, `deviceMemory`, `touchSupport`.
|
|
21
|
+
- **Media / codecs:** `mediaCodecs` (canPlayType for common MIME types), `canvasID`, `webglID`, `webgpuID`, `audioID`.
|
|
22
|
+
- **Other:** `fonts` (detected list), `pdfViewerEnabled`, `webdriver`, `permissionsState` (geolocation, notifications).
|
|
23
|
+
|
|
24
|
+
Signals that fail or are unavailable are set to `null` (no throw).
|
|
5
25
|
|
|
6
26
|
## Assumptions
|
|
7
27
|
|
|
8
|
-
The function
|
|
28
|
+
The function targets stock installations of:
|
|
29
|
+
|
|
9
30
|
- Google Chrome (desktop and Android)
|
|
10
|
-
- Mozilla Firefox (desktop, default tracking protection
|
|
31
|
+
- Mozilla Firefox (desktop, default tracking protection)
|
|
11
32
|
- Microsoft Edge (desktop and Android)
|
|
12
33
|
- Apple Safari (macOS)
|
|
13
34
|
- Safari on iOS / iPadOS
|
|
14
35
|
|
|
15
|
-
|
|
16
|
-
- No privacy-focused extensions or add-ons installed.
|
|
17
|
-
- Browser privacy settings left at factory defaults (no manual changes to fingerprint resistance features).
|
|
18
|
-
- No hardened, anti-detect or heavily modified browser variants.
|
|
36
|
+
Assumptions:
|
|
19
37
|
|
|
20
|
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
- Offline audio context (useful on Chrome, Edge, Firefox; heavily restricted on Safari)
|
|
24
|
-
- WebGPU properties (if available; low but increasing entropy)
|
|
25
|
-
- Locally installed fonts (valuable mainly on desktop Windows and macOS)
|
|
26
|
-
- Passive signals (hardwareConcurrency, deviceMemory, screen, timezone, languages, etc.)
|
|
38
|
+
- No privacy-focused extensions or add-ons.
|
|
39
|
+
- Browser privacy settings at factory defaults.
|
|
40
|
+
- No hardened or anti-detect browser variants.
|
|
27
41
|
|
|
28
|
-
|
|
29
|
-
The script does not attempt to bypass intentional randomization or hardening.
|
|
42
|
+
Under these conditions the signals keep useful entropy; on Safari iOS or strict Firefox modes entropy can be lower. The script does not try to bypass intentional randomization or hardening.
|
|
30
43
|
|
|
31
44
|
## Usage
|
|
32
45
|
|
|
33
|
-
Get browser fingerprint:
|
|
34
46
|
```js
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
47
|
+
// Import the default async function
|
|
48
|
+
import getBrowserFingerprint from 'get-browser-fingerprint'
|
|
49
|
+
|
|
50
|
+
// Call once; result holds fingerprint, elapsedMs, and all collected signals
|
|
51
|
+
const result = await getBrowserFingerprint()
|
|
52
|
+
// Log the 8-char hex fingerprint
|
|
53
|
+
console.log(result.fingerprint)
|
|
54
|
+
// Log execution time in milliseconds
|
|
55
|
+
console.log(result.elapsedMs)
|
|
56
|
+
|
|
57
|
+
// Or destructure to get only the fingerprint
|
|
58
|
+
const { fingerprint } = await getBrowserFingerprint()
|
|
59
|
+
console.log(fingerprint)
|
|
38
60
|
```
|
|
39
61
|
|
|
40
|
-
Options
|
|
41
|
-
|
|
62
|
+
Options:
|
|
63
|
+
|
|
64
|
+
- **`debug`** (default `false`): append canvas / WebGL / audio debug elements to `document.body` and include extra debug output.
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
const result = await getBrowserFingerprint({ debug: true })
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### CDN
|
|
71
|
+
|
|
72
|
+
Load as ESM from a CDN:
|
|
73
|
+
|
|
74
|
+
```html
|
|
75
|
+
<script type="module">
|
|
76
|
+
import getBrowserFingerprint from 'https://cdn.jsdelivr.net/npm/get-browser-fingerprint/+esm'
|
|
77
|
+
const { fingerprint, elapsedMs } = await getBrowserFingerprint()
|
|
78
|
+
console.log(fingerprint, elapsedMs)
|
|
79
|
+
</script>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Or with a version pin:
|
|
83
|
+
|
|
84
|
+
```html
|
|
85
|
+
<script type="module">
|
|
86
|
+
import getBrowserFingerprint from 'https://cdn.jsdelivr.net/npm/get-browser-fingerprint@latest/+esm'
|
|
87
|
+
const result = await getBrowserFingerprint()
|
|
88
|
+
console.log(result.fingerprint)
|
|
89
|
+
</script>
|
|
90
|
+
```
|
|
42
91
|
|
|
43
92
|
## Development
|
|
44
93
|
|
|
45
|
-
|
|
94
|
+
Install and run tests:
|
|
95
|
+
|
|
46
96
|
```sh
|
|
47
|
-
fnm install
|
|
97
|
+
fnm install
|
|
48
98
|
npm install -g corepack
|
|
49
|
-
corepack enable
|
|
50
|
-
corepack install
|
|
51
|
-
pnpm install
|
|
99
|
+
corepack enable
|
|
100
|
+
corepack install
|
|
101
|
+
pnpm install
|
|
52
102
|
pnpm exec playwright install chromium
|
|
53
103
|
pnpm exec playwright install firefox
|
|
54
104
|
pnpm test
|
|
55
105
|
```
|
|
56
106
|
|
|
57
|
-
|
|
107
|
+
Run the demo locally (serve `src/`):
|
|
108
|
+
|
|
58
109
|
```sh
|
|
59
110
|
pnpm serve -p 80 ./src
|
|
111
|
+
# or
|
|
112
|
+
npm run serve
|
|
60
113
|
```
|
|
114
|
+
|
|
115
|
+
Then open `http://localhost/index.html` (or port 80 if using `-p 80`).
|
|
116
|
+
|
|
117
|
+
## References
|
|
118
|
+
|
|
119
|
+
- [Fingerprinting (MDN Glossary)](https://developer.mozilla.org/en-US/docs/Glossary/Fingerprinting)
|
|
120
|
+
- [HTTP Client hints (MDN)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Client_hints)
|
|
121
|
+
- [Intl.DateTimeFormat.resolvedOptions() (MDN)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/resolvedOptions)
|
|
122
|
+
- [Intl.supportedValuesOf() (MDN)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/supportedValuesOf)
|
|
123
|
+
- [Mitigating Browser Fingerprinting (W3C)](https://w3c.github.io/fingerprinting-guidance/)
|
|
124
|
+
- [Navigator (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Navigator)
|
|
125
|
+
- [NavigatorUAData.getHighEntropyValues() (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/NavigatorUAData/getHighEntropyValues)
|
|
126
|
+
- [NetworkInformation (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation)
|
|
127
|
+
- [Permissions API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API)
|
|
128
|
+
- [Screen (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Screen)
|
|
129
|
+
- [User-Agent Client Hints API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/User-Agent_Client_Hints_API)
|
|
130
|
+
- [Window.devicePixelRatio (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "get-browser-fingerprint",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.0",
|
|
4
4
|
"author": "Damiano Barbati <damiano.barbati@gmail.com> (https://github.com/damianobarbati)",
|
|
5
5
|
"repository": "https://github.com/damianobarbati/get-browser-fingerprint",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"test": "node --test --test-reporter=spec --test-reporter-destination=stdout"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
|
-
"@biomejs/biome": "^2.4.
|
|
27
|
+
"@biomejs/biome": "^2.4.8",
|
|
28
28
|
"@playwright/test": "^1.58.2",
|
|
29
|
-
"serve": "^14.2.
|
|
29
|
+
"serve": "^14.2.6"
|
|
30
30
|
}
|
|
31
31
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -4,7 +4,8 @@ export interface FingerprintOptions {
|
|
|
4
4
|
|
|
5
5
|
export type FingerprintResult = {
|
|
6
6
|
fingerprint: string;
|
|
7
|
-
|
|
7
|
+
elapsedMs: number;
|
|
8
|
+
} & Record<string, unknown>;
|
|
8
9
|
|
|
9
10
|
export default function getBrowserFingerprint(options?: FingerprintOptions): Promise<FingerprintResult>;
|
|
10
11
|
|
package/src/index.js
CHANGED
|
@@ -1,297 +1,104 @@
|
|
|
1
|
-
const
|
|
2
|
-
let h = seed ^ key.length;
|
|
3
|
-
|
|
4
|
-
let i = 0;
|
|
5
|
-
const len = key.length;
|
|
6
|
-
|
|
7
|
-
while (i + 3 < len) {
|
|
8
|
-
let k = (key.charCodeAt(i) & 0xff) | ((key.charCodeAt(i + 1) & 0xff) << 8) | ((key.charCodeAt(i + 2) & 0xff) << 16) | ((key.charCodeAt(i + 3) & 0xff) << 24);
|
|
9
|
-
k = Math.imul(k, 0xcc9e2d51);
|
|
10
|
-
k = (k << 15) | (k >>> 17); // ROTL32(k, 15)
|
|
11
|
-
k = Math.imul(k, 0x1b873593);
|
|
12
|
-
h ^= k;
|
|
13
|
-
h = (h << 13) | (h >>> 19); // ROTL32(h, 13)
|
|
14
|
-
h = Math.imul(h, 5) + 0xe6546b64;
|
|
15
|
-
i += 4;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// tail
|
|
19
|
-
let k1 = 0;
|
|
20
|
-
switch (len - i) {
|
|
21
|
-
// biome-ignore lint/suspicious/noFallthroughSwitchClause: ignore
|
|
22
|
-
case 3:
|
|
23
|
-
k1 ^= (key.charCodeAt(i + 2) & 0xff) << 16;
|
|
24
|
-
// biome-ignore lint/suspicious/noFallthroughSwitchClause: ignore
|
|
25
|
-
case 2:
|
|
26
|
-
k1 ^= (key.charCodeAt(i + 1) & 0xff) << 8;
|
|
27
|
-
case 1:
|
|
28
|
-
k1 ^= key.charCodeAt(i) & 0xff;
|
|
29
|
-
k1 = Math.imul(k1, 0xcc9e2d51);
|
|
30
|
-
k1 = (k1 << 15) | (k1 >>> 17);
|
|
31
|
-
k1 = Math.imul(k1, 0x1b873593);
|
|
32
|
-
h ^= k1;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
h ^= h >>> 16;
|
|
36
|
-
h = Math.imul(h, 0x85ebca6b);
|
|
37
|
-
h ^= h >>> 13;
|
|
38
|
-
h = Math.imul(h, 0xc2b2ae35);
|
|
39
|
-
h ^= h >>> 16;
|
|
40
|
-
h = h >>> 0; // force unsigned 32 bit
|
|
41
|
-
|
|
42
|
-
const result = h.toString(16).padStart(8, '0');
|
|
43
|
-
return result;
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
const safe = async (fn) => {
|
|
47
|
-
try {
|
|
48
|
-
return await fn();
|
|
49
|
-
} catch {
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const stableStringify = (obj) => {
|
|
55
|
-
// handle null, string, number, boolean
|
|
56
|
-
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
|
|
57
|
-
// handle arrays recursively
|
|
58
|
-
if (Array.isArray(obj)) return `[${obj.map(stableStringify).join(',')}]`;
|
|
59
|
-
|
|
60
|
-
const keys = Object.keys(obj).sort();
|
|
61
|
-
const parts = [];
|
|
62
|
-
for (const key of keys) {
|
|
63
|
-
const value = obj[key];
|
|
64
|
-
if (value !== undefined) parts.push(`${stableStringify(key)}:${stableStringify(value)}`);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return `{${parts.join(',')}}`;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
const isMobile = () =>
|
|
71
|
-
navigator.userAgentData?.mobile === true ||
|
|
72
|
-
/Mobi|Android|iPhone|iPad|iPod|webOS|BlackBerry|Windows Phone/i.test(navigator.userAgent) ||
|
|
73
|
-
(navigator.maxTouchPoints > 0 && matchMedia('(pointer:coarse)').matches && innerWidth <= 1024);
|
|
74
|
-
|
|
75
|
-
/** @type {import('./index.d.ts').getBrowserFingerprint} */
|
|
76
|
-
const getBrowserFingerprint = async ({ debug = false } = {}) => {
|
|
77
|
-
// software
|
|
78
|
-
const fonts = await safe(() => getFonts());
|
|
79
|
-
const numberingSystem = await safe(() => new Intl.NumberFormat(window.navigator.languages?.[0] || 'en').resolvedOptions().numberingSystem);
|
|
80
|
-
const languages = window.navigator.languages ? Array.from(window.navigator.languages).join(',') : window.navigator.language || null;
|
|
81
|
-
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
82
|
-
const timezoneOffset = new Date().getTimezoneOffset();
|
|
83
|
-
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'reduce' : 'no-preference';
|
|
84
|
-
const colorScheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
85
|
-
|
|
86
|
-
// hardware
|
|
87
|
-
const forcedColors = window.matchMedia('(forced-colors: active)').matches ? 'active' : null;
|
|
88
|
-
const { pixelDepth, colorDepth } = window.screen;
|
|
89
|
-
const touchSupport = 'ontouchstart' in window || window.navigator.maxTouchPoints > 0;
|
|
90
|
-
const canvasID = await safe(() => getCanvasID(debug));
|
|
91
|
-
const webglID = await safe(() => getWebglID());
|
|
92
|
-
const webgpuID = await safe(() => getWebgpuID(debug));
|
|
93
|
-
const audioID = await safe(() => getAudioID(debug));
|
|
94
|
-
const aspectRatio = window.screen.width && window.screen.height ? (window.screen.width / window.screen.height).toFixed(4) : null;
|
|
95
|
-
|
|
96
|
-
const data = {
|
|
97
|
-
// SOFTWARE
|
|
98
|
-
vendor: window.navigator.vendor || null, // vendor
|
|
99
|
-
platform: window.navigator.platform || null, // os
|
|
100
|
-
fonts, // stable default os setting
|
|
101
|
-
numberingSystem, // stable default os setting
|
|
102
|
-
languages, // stable user locale/setting
|
|
103
|
-
timezone, // stable user locale/setting
|
|
104
|
-
timezoneOffset, // stable user locale/setting
|
|
105
|
-
reducedMotion, // stable user locale/setting
|
|
106
|
-
colorScheme, // stable user locale/setting
|
|
107
|
-
// HARDWARE
|
|
108
|
-
hardwareConcurrency: window.navigator.hardwareConcurrency || null, // cpu
|
|
109
|
-
deviceMemory: window.navigator.deviceMemory || null, // ram
|
|
110
|
-
forcedColors, // display
|
|
111
|
-
pixelDepth, // display
|
|
112
|
-
colorDepth, // display
|
|
113
|
-
touchSupport, // display
|
|
114
|
-
canvasID, // gpu
|
|
115
|
-
webglID, // gpu
|
|
116
|
-
webgpuID, // gpu
|
|
117
|
-
audioID, // audio
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
const mobileData = {
|
|
121
|
-
aspectRatio,
|
|
122
|
-
width: window.screen.width,
|
|
123
|
-
height: window.screen.height,
|
|
124
|
-
maxTouchPoints: window.navigator.maxTouchPoints,
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
if (isMobile()) Object.assign(data, mobileData);
|
|
128
|
-
|
|
129
|
-
const payload = stableStringify(data);
|
|
130
|
-
const fingerprint = murmurHash3(payload);
|
|
131
|
-
return { fingerprint, ...data };
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
const getCanvasID = async (debug) => {
|
|
135
|
-
const canvas = document.createElement('canvas');
|
|
136
|
-
canvas.width = 280;
|
|
137
|
-
canvas.height = 100;
|
|
138
|
-
const ctx = canvas.getContext('2d');
|
|
139
|
-
|
|
140
|
-
// background with gradient
|
|
141
|
-
const grad = ctx.createLinearGradient(0, 0, 280, 100);
|
|
142
|
-
grad.addColorStop(0, '#f60');
|
|
143
|
-
grad.addColorStop(0.4, '#09f');
|
|
144
|
-
grad.addColorStop(0.7, '#f09');
|
|
145
|
-
grad.addColorStop(1, '#0f9');
|
|
146
|
-
ctx.fillStyle = grad;
|
|
147
|
-
ctx.fillRect(0, 0, 280, 100);
|
|
148
|
-
|
|
149
|
-
// text with multiple fonts and emoji
|
|
150
|
-
ctx.font = '18px "Arial","Helvetica","DejaVu Sans",sans-serif';
|
|
151
|
-
ctx.fillStyle = '#000';
|
|
152
|
-
ctx.textBaseline = 'alphabetic';
|
|
153
|
-
ctx.fillText('Fingerprint ¼½¾™©®∆π∑€¶§', 12, 45);
|
|
154
|
-
|
|
155
|
-
ctx.font = 'bold 22px "Georgia","Times New Roman",serif';
|
|
156
|
-
ctx.fillStyle = '#222';
|
|
157
|
-
ctx.fillText('🦊🐱🚀 2026', 12, 78);
|
|
158
|
-
|
|
159
|
-
// content with antialiasing and compositing
|
|
160
|
-
ctx.globalAlpha = 0.7;
|
|
161
|
-
ctx.fillStyle = 'rgba(255, 80, 0, 0.35)';
|
|
162
|
-
ctx.fillRect(180, 20, 80, 60);
|
|
163
|
-
|
|
164
|
-
ctx.beginPath();
|
|
165
|
-
ctx.arc(220, 50, 32, 0, Math.PI * 2);
|
|
166
|
-
ctx.strokeStyle = '#fff';
|
|
167
|
-
ctx.lineWidth = 4;
|
|
168
|
-
ctx.stroke();
|
|
169
|
-
|
|
170
|
-
ctx.beginPath();
|
|
171
|
-
ctx.moveTo(30, 85);
|
|
172
|
-
ctx.quadraticCurveTo(140, 20, 240, 85);
|
|
173
|
-
ctx.strokeStyle = '#00f';
|
|
174
|
-
ctx.lineWidth = 3;
|
|
175
|
-
ctx.stroke();
|
|
176
|
-
|
|
177
|
-
if (debug) {
|
|
178
|
-
canvas.style.border = '1px solid #444';
|
|
179
|
-
document.body.appendChild(canvas);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const dataUrl = canvas.toDataURL('image/png');
|
|
183
|
-
const result = murmurHash3(dataUrl);
|
|
184
|
-
return result;
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
const getWebglID = () => {
|
|
188
|
-
const canvas = document.createElement('canvas');
|
|
189
|
-
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
|
190
|
-
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
|
|
191
|
-
const vendor = debugInfo ? gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR);
|
|
192
|
-
const renderer = debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
|
|
193
|
-
const info = { vendor, renderer };
|
|
194
|
-
const result = murmurHash3(stableStringify(info));
|
|
195
|
-
return result;
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
const getWebgpuID = async (debug) => {
|
|
199
|
-
const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });
|
|
200
|
-
const vendor = adapter.info?.vendor ?? '';
|
|
201
|
-
const architecture = adapter.info?.architecture ?? '';
|
|
202
|
-
const description = adapter.info?.description ?? '';
|
|
203
|
-
const features = Array.from(adapter.features).sort();
|
|
204
|
-
const limits = Array.from(adapter.limits).sort();
|
|
205
|
-
const info = { vendor, architecture, description, features, limits };
|
|
206
|
-
|
|
207
|
-
if (debug) {
|
|
208
|
-
const debugDiv = document.createElement('pre');
|
|
209
|
-
debugDiv.style.margin = '10px 0';
|
|
210
|
-
debugDiv.style.padding = '10px';
|
|
211
|
-
debugDiv.style.border = '1px solid #444';
|
|
212
|
-
debugDiv.style.background = '#111';
|
|
213
|
-
debugDiv.style.color = '#0f8';
|
|
214
|
-
debugDiv.style.fontFamily = 'monospace';
|
|
215
|
-
debugDiv.textContent = `WebGPU:\n${JSON.stringify(info, null, 2)}`;
|
|
216
|
-
document.body.appendChild(debugDiv);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const result = murmurHash3(stableStringify(info));
|
|
220
|
-
return result;
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
const getAudioID = async (debug) => {
|
|
1
|
+
const getAudioID = async (isDebug) => {
|
|
224
2
|
const OfflineCtx = window.OfflineAudioContext || window.webkitOfflineAudioContext;
|
|
225
|
-
|
|
226
3
|
const context = new OfflineCtx(1, 6000, 44100);
|
|
227
4
|
const osc = context.createOscillator();
|
|
228
5
|
osc.type = 'sawtooth';
|
|
229
6
|
osc.frequency.value = 8800;
|
|
230
|
-
|
|
231
7
|
const compressor = context.createDynamicsCompressor();
|
|
232
8
|
compressor.threshold.value = -48;
|
|
233
9
|
compressor.knee.value = 30;
|
|
234
10
|
compressor.ratio.value = 14;
|
|
235
11
|
compressor.attack.value = 0;
|
|
236
12
|
compressor.release.value = 0.3;
|
|
237
|
-
|
|
238
13
|
const gain = context.createGain();
|
|
239
14
|
gain.gain.value = 0.4;
|
|
240
|
-
|
|
241
15
|
osc.connect(compressor);
|
|
242
16
|
compressor.connect(gain);
|
|
243
17
|
gain.connect(context.destination);
|
|
244
|
-
|
|
245
18
|
osc.start(0);
|
|
246
19
|
const buffer = await context.startRendering();
|
|
247
20
|
const channel = buffer.getChannelData(0);
|
|
248
|
-
|
|
249
21
|
let hash = 0;
|
|
250
22
|
let hash2 = 0;
|
|
251
23
|
for (let i = 3000; i < 5800; i += 2) {
|
|
252
|
-
const
|
|
253
|
-
hash = (hash * 31 + Math.floor((
|
|
254
|
-
hash2 = Math.imul(hash2 ^ Math.floor(
|
|
24
|
+
const sampleValue = channel[i];
|
|
25
|
+
hash = (hash * 31 + Math.floor((sampleValue + 1) * 100000)) | 0;
|
|
26
|
+
hash2 = Math.imul(hash2 ^ Math.floor(sampleValue * 98765), 0x85ebca77);
|
|
255
27
|
}
|
|
256
28
|
const final = (hash >>> 0) ^ (hash2 >>> 0);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const ctx = wc.getContext('2d');
|
|
29
|
+
if (isDebug) {
|
|
30
|
+
const waveformCanvas = document.createElement('canvas');
|
|
31
|
+
waveformCanvas.width = 500;
|
|
32
|
+
waveformCanvas.height = 120;
|
|
33
|
+
waveformCanvas.style.border = '1px solid #444';
|
|
34
|
+
waveformCanvas.style.margin = '10px 0';
|
|
35
|
+
waveformCanvas.title = 'Audio buffer snippet (4500–5000 samples)';
|
|
36
|
+
const ctx = waveformCanvas.getContext('2d');
|
|
267
37
|
ctx.fillStyle = '#111';
|
|
268
|
-
ctx.fillRect(0, 0,
|
|
269
|
-
|
|
38
|
+
ctx.fillRect(0, 0, waveformCanvas.width, waveformCanvas.height);
|
|
270
39
|
ctx.font = '14px monospace';
|
|
271
40
|
ctx.fillStyle = '#0f8';
|
|
272
41
|
ctx.textAlign = 'left';
|
|
273
42
|
ctx.fillText('AudioID', 10, 20);
|
|
274
|
-
|
|
275
43
|
ctx.strokeStyle = '#0f8';
|
|
276
44
|
ctx.lineWidth = 1.5;
|
|
277
45
|
ctx.beginPath();
|
|
278
|
-
|
|
279
46
|
const waveform = Array.from(channel.slice(4500, 5000));
|
|
280
|
-
const step =
|
|
281
|
-
|
|
47
|
+
const step = waveformCanvas.width / waveform.length;
|
|
282
48
|
for (let i = 0; i < waveform.length; i++) {
|
|
283
49
|
const x = i * step;
|
|
284
|
-
const y = ((waveform[i] + 1) / 2) *
|
|
285
|
-
if (i === 0)
|
|
286
|
-
|
|
50
|
+
const y = ((waveform[i] + 1) / 2) * waveformCanvas.height;
|
|
51
|
+
if (i === 0) {
|
|
52
|
+
ctx.moveTo(x, y);
|
|
53
|
+
} else {
|
|
54
|
+
ctx.lineTo(x, y);
|
|
55
|
+
}
|
|
287
56
|
}
|
|
288
57
|
ctx.stroke();
|
|
289
|
-
|
|
290
|
-
document.body.appendChild(wc);
|
|
58
|
+
document.body.appendChild(waveformCanvas);
|
|
291
59
|
}
|
|
60
|
+
return murmurHash3Hex(final.toString(16));
|
|
61
|
+
};
|
|
292
62
|
|
|
293
|
-
|
|
294
|
-
|
|
63
|
+
const getCanvasID = async (isDebug) => {
|
|
64
|
+
const canvas = document.createElement('canvas');
|
|
65
|
+
canvas.width = 280;
|
|
66
|
+
canvas.height = 100;
|
|
67
|
+
const ctx = canvas.getContext('2d');
|
|
68
|
+
const gradient = ctx.createLinearGradient(0, 0, 280, 100);
|
|
69
|
+
gradient.addColorStop(0, '#f60');
|
|
70
|
+
gradient.addColorStop(0.4, '#09f');
|
|
71
|
+
gradient.addColorStop(0.7, '#f09');
|
|
72
|
+
gradient.addColorStop(1, '#0f9');
|
|
73
|
+
ctx.fillStyle = gradient;
|
|
74
|
+
ctx.fillRect(0, 0, 280, 100);
|
|
75
|
+
ctx.font = '18px "Arial","Helvetica","DejaVu Sans",sans-serif';
|
|
76
|
+
ctx.fillStyle = '#000';
|
|
77
|
+
ctx.textBaseline = 'alphabetic';
|
|
78
|
+
ctx.fillText('Fingerprint ¼½¾™©®∆π∑€¶§', 12, 45);
|
|
79
|
+
ctx.font = 'bold 22px "Georgia","Times New Roman",serif';
|
|
80
|
+
ctx.fillStyle = '#222';
|
|
81
|
+
ctx.fillText('🦊🐱🚀 2026', 12, 78);
|
|
82
|
+
ctx.globalAlpha = 0.7;
|
|
83
|
+
ctx.fillStyle = 'rgba(255, 80, 0, 0.35)';
|
|
84
|
+
ctx.fillRect(180, 20, 80, 60);
|
|
85
|
+
ctx.beginPath();
|
|
86
|
+
ctx.arc(220, 50, 32, 0, Math.PI * 2);
|
|
87
|
+
ctx.strokeStyle = '#fff';
|
|
88
|
+
ctx.lineWidth = 4;
|
|
89
|
+
ctx.stroke();
|
|
90
|
+
ctx.beginPath();
|
|
91
|
+
ctx.moveTo(30, 85);
|
|
92
|
+
ctx.quadraticCurveTo(140, 20, 240, 85);
|
|
93
|
+
ctx.strokeStyle = '#00f';
|
|
94
|
+
ctx.lineWidth = 3;
|
|
95
|
+
ctx.stroke();
|
|
96
|
+
if (isDebug) {
|
|
97
|
+
canvas.style.border = '1px solid #444';
|
|
98
|
+
document.body.appendChild(canvas);
|
|
99
|
+
}
|
|
100
|
+
const dataUrl = canvas.toDataURL('image/png');
|
|
101
|
+
return murmurHash3Hex(dataUrl);
|
|
295
102
|
};
|
|
296
103
|
|
|
297
104
|
const getFonts = () => {
|
|
@@ -313,7 +120,6 @@ const getFonts = () => {
|
|
|
313
120
|
'-apple-system',
|
|
314
121
|
'BlinkMacSystemFont',
|
|
315
122
|
];
|
|
316
|
-
|
|
317
123
|
const span = document.createElement('span');
|
|
318
124
|
span.style.fontSize = '72px';
|
|
319
125
|
span.style.position = 'absolute';
|
|
@@ -321,13 +127,11 @@ const getFonts = () => {
|
|
|
321
127
|
span.style.whiteSpace = 'nowrap';
|
|
322
128
|
span.textContent = 'mmmmmmmmmwwwwwww';
|
|
323
129
|
document.body.appendChild(span);
|
|
324
|
-
|
|
325
130
|
const defaults = {};
|
|
326
131
|
for (const base of baseFonts) {
|
|
327
132
|
span.style.fontFamily = base;
|
|
328
133
|
defaults[base] = span.offsetWidth;
|
|
329
134
|
}
|
|
330
|
-
|
|
331
135
|
const detected = [];
|
|
332
136
|
for (const font of testFonts) {
|
|
333
137
|
let found = false;
|
|
@@ -346,10 +150,243 @@ const getFonts = () => {
|
|
|
346
150
|
}
|
|
347
151
|
}
|
|
348
152
|
}
|
|
349
|
-
|
|
350
153
|
document.body.removeChild(span);
|
|
351
|
-
|
|
352
|
-
|
|
154
|
+
return detected.length ? detected.sort().join(',') : null;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const getMediaCodecs = () => {
|
|
158
|
+
const video = document.createElement('video');
|
|
159
|
+
const mimeList = ['video/webm', 'video/mp4', 'video/ogg', 'audio/mp4', 'audio/webm', 'audio/ogg', 'audio/mpeg'];
|
|
160
|
+
const codecSupportMap = {};
|
|
161
|
+
for (const mime of mimeList) {
|
|
162
|
+
const playbackSupport = video.canPlayType(mime);
|
|
163
|
+
codecSupportMap[mime] = playbackSupport === '' ? 'no' : playbackSupport;
|
|
164
|
+
}
|
|
165
|
+
return stableStringify(codecSupportMap);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const getWebglID = async () => {
|
|
169
|
+
const canvas = document.createElement('canvas');
|
|
170
|
+
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
|
171
|
+
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
|
|
172
|
+
const vendor = debugInfo ? gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR);
|
|
173
|
+
const renderer = debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
|
|
174
|
+
const webglInfo = { vendor, renderer };
|
|
175
|
+
return murmurHash3Hex(stableStringify(webglInfo));
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const getWebgpuID = async (isDebug) => {
|
|
179
|
+
const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });
|
|
180
|
+
const vendor = adapter.info?.vendor ?? '';
|
|
181
|
+
const architecture = adapter.info?.architecture ?? '';
|
|
182
|
+
const description = adapter.info?.description ?? '';
|
|
183
|
+
const features = Array.from(adapter.features).sort();
|
|
184
|
+
const limits = Array.from(adapter.limits).sort();
|
|
185
|
+
const webgpuInfo = { vendor, architecture, description, features, limits };
|
|
186
|
+
if (isDebug) {
|
|
187
|
+
const debugDiv = document.createElement('pre');
|
|
188
|
+
debugDiv.style.margin = '10px 0';
|
|
189
|
+
debugDiv.style.padding = '10px';
|
|
190
|
+
debugDiv.style.border = '1px solid #444';
|
|
191
|
+
debugDiv.style.background = '#111';
|
|
192
|
+
debugDiv.style.color = '#0f8';
|
|
193
|
+
debugDiv.style.fontFamily = 'monospace';
|
|
194
|
+
debugDiv.textContent = `WebGPU:\n${JSON.stringify(webgpuInfo, null, 2)}`;
|
|
195
|
+
document.body.appendChild(debugDiv);
|
|
196
|
+
}
|
|
197
|
+
return murmurHash3Hex(stableStringify(webgpuInfo));
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const isMobile = () =>
|
|
201
|
+
navigator.userAgentData?.mobile === true ||
|
|
202
|
+
/Mobi|Android|iPhone|iPad|iPod|webOS|BlackBerry|Windows Phone/i.test(navigator.userAgent) ||
|
|
203
|
+
(navigator.maxTouchPoints > 0 && matchMedia('(pointer:coarse)').matches && innerWidth <= 1024);
|
|
204
|
+
|
|
205
|
+
const safe = async (asyncFn) => {
|
|
206
|
+
try {
|
|
207
|
+
return await asyncFn();
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const murmurHash3Hex = (inputString, seed = 1) => {
|
|
214
|
+
const data = new TextEncoder().encode(inputString);
|
|
215
|
+
const c1 = 0xcc9e2d51;
|
|
216
|
+
const c2 = 0x1b873593;
|
|
217
|
+
let h1 = seed >>> 0;
|
|
218
|
+
|
|
219
|
+
const blockCount = Math.floor(data.length / 4);
|
|
220
|
+
const dataView = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
221
|
+
for (let i = 0; i < blockCount; i++) {
|
|
222
|
+
let k1 = dataView.getUint32(i * 4, true);
|
|
223
|
+
k1 = Math.imul(k1, c1);
|
|
224
|
+
k1 = (k1 << 15) | (k1 >>> 17);
|
|
225
|
+
k1 = Math.imul(k1, c2);
|
|
226
|
+
h1 ^= k1;
|
|
227
|
+
h1 = (h1 << 13) | (h1 >>> 19);
|
|
228
|
+
h1 = (Math.imul(h1, 5) + 0xe6546b64) >>> 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let k1 = 0;
|
|
232
|
+
const tailIndex = blockCount * 4;
|
|
233
|
+
const remainder = data.length & 3;
|
|
234
|
+
if (remainder === 3) {
|
|
235
|
+
k1 ^= data[tailIndex + 2] << 16;
|
|
236
|
+
}
|
|
237
|
+
if (remainder >= 2) {
|
|
238
|
+
k1 ^= data[tailIndex + 1] << 8;
|
|
239
|
+
}
|
|
240
|
+
if (remainder >= 1) {
|
|
241
|
+
k1 ^= data[tailIndex];
|
|
242
|
+
k1 = Math.imul(k1, c1);
|
|
243
|
+
k1 = (k1 << 15) | (k1 >>> 17);
|
|
244
|
+
k1 = Math.imul(k1, c2);
|
|
245
|
+
h1 ^= k1;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
h1 ^= data.length;
|
|
249
|
+
h1 ^= h1 >>> 16;
|
|
250
|
+
h1 = Math.imul(h1, 0x85ebca6b) >>> 0;
|
|
251
|
+
h1 ^= h1 >>> 13;
|
|
252
|
+
h1 = Math.imul(h1, 0xc2b2ae35) >>> 0;
|
|
253
|
+
h1 ^= h1 >>> 16;
|
|
254
|
+
return h1.toString(16).padStart(8, '0');
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const stableStringify = (value) => {
|
|
258
|
+
if (value === null || typeof value !== 'object') {
|
|
259
|
+
return JSON.stringify(value);
|
|
260
|
+
}
|
|
261
|
+
if (Array.isArray(value)) {
|
|
262
|
+
return `[${value.map(stableStringify).join(',')}]`;
|
|
263
|
+
}
|
|
264
|
+
const keys = Object.keys(value).sort();
|
|
265
|
+
const parts = [];
|
|
266
|
+
for (const key of keys) {
|
|
267
|
+
const item = value[key];
|
|
268
|
+
if (item !== undefined) {
|
|
269
|
+
parts.push(`${stableStringify(key)}:${stableStringify(item)}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return `{${parts.join(',')}}`;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const getBrowserFingerprint = async ({ debug: isDebug = false } = {}) => {
|
|
276
|
+
const startTime = performance.now();
|
|
277
|
+
const userAgentHighEntropy = await safe(async () => {
|
|
278
|
+
if (!window.navigator.userAgentData || typeof window.navigator.userAgentData.getHighEntropyValues !== 'function') {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
const highEntropyValues = await window.navigator.userAgentData.getHighEntropyValues(['architecture', 'bitness', 'platformVersion', 'model', 'fullVersionList']);
|
|
282
|
+
return stableStringify(highEntropyValues);
|
|
283
|
+
});
|
|
284
|
+
const mediaCodecs = await safe(() => getMediaCodecs());
|
|
285
|
+
const fonts = await safe(() => getFonts());
|
|
286
|
+
const numberingSystem = await safe(() => new Intl.NumberFormat(window.navigator.languages?.[0] || 'en').resolvedOptions().numberingSystem);
|
|
287
|
+
const languages = window.navigator.languages ? Array.from(window.navigator.languages).join(',') : window.navigator.language || null;
|
|
288
|
+
const intlResolved = Intl.DateTimeFormat().resolvedOptions();
|
|
289
|
+
const timezone = intlResolved.timeZone;
|
|
290
|
+
const timezoneOffset = new Date().getTimezoneOffset();
|
|
291
|
+
const intlCalendar = intlResolved.calendar || null;
|
|
292
|
+
const hourCycle = intlResolved.hour12 !== undefined ? (intlResolved.hour12 ? 'h12' : 'h23') : intlResolved.hourCycle || null;
|
|
293
|
+
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'reduce' : 'no-preference';
|
|
294
|
+
const colorScheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
295
|
+
const prefersContrast = window.matchMedia('(prefers-contrast: more)').matches
|
|
296
|
+
? 'more'
|
|
297
|
+
: window.matchMedia('(prefers-contrast: less)').matches
|
|
298
|
+
? 'less'
|
|
299
|
+
: window.matchMedia('(prefers-contrast: custom)').matches
|
|
300
|
+
? 'custom'
|
|
301
|
+
: 'no-preference';
|
|
302
|
+
const prefersReducedTransparency = window.matchMedia('(prefers-reduced-transparency: reduce)').matches ? 'reduce' : 'no-preference';
|
|
303
|
+
const forcedColors = window.matchMedia('(forced-colors: active)').matches ? 'active' : null;
|
|
304
|
+
const devicePixelRatio = typeof window.devicePixelRatio === 'number' ? Math.round(window.devicePixelRatio * 100) / 100 : null;
|
|
305
|
+
const { pixelDepth, colorDepth } = window.screen;
|
|
306
|
+
const touchSupport = 'ontouchstart' in window || window.navigator.maxTouchPoints > 0;
|
|
307
|
+
const canvasID = await safe(() => getCanvasID(isDebug));
|
|
308
|
+
const webglID = await safe(() => getWebglID());
|
|
309
|
+
const webgpuID = await safe(() => getWebgpuID(isDebug));
|
|
310
|
+
const audioID = await safe(() => getAudioID(isDebug));
|
|
311
|
+
const aspectRatio = window.screen.width && window.screen.height ? (window.screen.width / window.screen.height).toFixed(4) : null;
|
|
312
|
+
const screenOrientation = await safe(() => window.screen.orientation?.type || null);
|
|
313
|
+
const networkConnection = window.navigator.connection || window.navigator.mozConnection || window.navigator.webkitConnection;
|
|
314
|
+
const connectionType = networkConnection ? networkConnection.type || null : null;
|
|
315
|
+
const effectiveType = networkConnection?.effectiveType || null;
|
|
316
|
+
const saveData = networkConnection?.saveData === true;
|
|
317
|
+
const pdfViewerEnabled = typeof window.navigator.pdfViewerEnabled === 'boolean' ? window.navigator.pdfViewerEnabled : null;
|
|
318
|
+
const webdriver = typeof window.navigator.webdriver === 'boolean' ? window.navigator.webdriver : null;
|
|
319
|
+
const intlSupportedCalendars = await safe(() => {
|
|
320
|
+
if (typeof Intl.supportedValuesOf !== 'function') {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
return Intl.supportedValuesOf('calendar').join(',');
|
|
324
|
+
});
|
|
325
|
+
const permissionsState = await safe(async () => {
|
|
326
|
+
if (!window.navigator.permissions || typeof window.navigator.permissions.query !== 'function') {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
const permissionNames = ['geolocation', 'notifications'];
|
|
330
|
+
const permissionStateMap = {};
|
|
331
|
+
for (const name of permissionNames) {
|
|
332
|
+
try {
|
|
333
|
+
const permissionResult = await window.navigator.permissions.query({ name });
|
|
334
|
+
permissionStateMap[name] = permissionResult.state;
|
|
335
|
+
} catch {
|
|
336
|
+
permissionStateMap[name] = 'unsupported';
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return stableStringify(permissionStateMap);
|
|
340
|
+
});
|
|
341
|
+
const data = {
|
|
342
|
+
vendor: window.navigator.vendor || null,
|
|
343
|
+
platform: window.navigator.platform || null,
|
|
344
|
+
userAgentHighEntropy: userAgentHighEntropy || null,
|
|
345
|
+
mediaCodecs: mediaCodecs || null,
|
|
346
|
+
fonts,
|
|
347
|
+
numberingSystem,
|
|
348
|
+
languages,
|
|
349
|
+
timezone,
|
|
350
|
+
timezoneOffset,
|
|
351
|
+
intlCalendar,
|
|
352
|
+
hourCycle,
|
|
353
|
+
reducedMotion,
|
|
354
|
+
colorScheme,
|
|
355
|
+
prefersContrast,
|
|
356
|
+
prefersReducedTransparency,
|
|
357
|
+
pdfViewerEnabled,
|
|
358
|
+
webdriver,
|
|
359
|
+
intlSupportedCalendars: intlSupportedCalendars || null,
|
|
360
|
+
permissionsState: permissionsState || null,
|
|
361
|
+
hardwareConcurrency: window.navigator.hardwareConcurrency || null,
|
|
362
|
+
deviceMemory: window.navigator.deviceMemory || null,
|
|
363
|
+
forcedColors,
|
|
364
|
+
devicePixelRatio,
|
|
365
|
+
pixelDepth,
|
|
366
|
+
colorDepth,
|
|
367
|
+
touchSupport,
|
|
368
|
+
screenOrientation: screenOrientation || null,
|
|
369
|
+
connectionType,
|
|
370
|
+
effectiveType,
|
|
371
|
+
saveData,
|
|
372
|
+
canvasID,
|
|
373
|
+
webglID,
|
|
374
|
+
webgpuID,
|
|
375
|
+
audioID,
|
|
376
|
+
};
|
|
377
|
+
const mobileData = {
|
|
378
|
+
aspectRatio,
|
|
379
|
+
width: window.screen.width,
|
|
380
|
+
height: window.screen.height,
|
|
381
|
+
maxTouchPoints: window.navigator.maxTouchPoints,
|
|
382
|
+
};
|
|
383
|
+
if (isMobile()) {
|
|
384
|
+
Object.assign(data, mobileData);
|
|
385
|
+
}
|
|
386
|
+
const payload = stableStringify(data);
|
|
387
|
+
const fingerprint = murmurHash3Hex(payload);
|
|
388
|
+
const elapsedMs = Math.round(performance.now() - startTime);
|
|
389
|
+
return { fingerprint, ...data, elapsedMs };
|
|
353
390
|
};
|
|
354
391
|
|
|
355
392
|
if (typeof window !== 'undefined') {
|