@thumbmarkjs/thumbmarkjs 1.3.2 → 1.3.4

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.
@@ -1,5 +1,6 @@
1
1
  import { componentInterface } from '../../factory';
2
2
  import { hash } from '../../utils/hash';
3
+ import { ephemeralIFrame } from '../../utils/ephemeralIFrame';
3
4
 
4
5
  const BLACKBOARD_BOLD = ['\uD835\uDD04', '\uD835\uDD05', '\u212D', '\uD835\uDD07', '\uD835\uDD08', '\uD835\uDD09', '\uD835\uDD38', '\uD835\uDD39', '\u2102', '\uD835\uDD3B', '\uD835\uDD3C', '\uD835\uDD3D'];
5
6
  const GREEK_SYMBOLS = ['\u03B2', '\u03C8', '\u03BB', '\u03B5', '\u03B6', '\u03B1', '\u03BE', '\u03BC', '\u03C1', '\u03C6', '\u03BA', '\u03C4', '\u03B7', '\u03C3', '\u03B9', '\u03C9', '\u03B3', '\u03BD', '\u03C7', '\u03B4', '\u03B8', '\u03C0', '\u03C5', '\u03BF'];
@@ -7,33 +8,58 @@ const GREEK_SYMBOLS = ['\u03B2', '\u03C8', '\u03BB', '\u03B5', '\u03B6', '\u03B1
7
8
  export default async function getMathML(): Promise<componentInterface | null> {
8
9
  return new Promise((resolve) => {
9
10
  try {
10
- if (!isMathMLSupported()) {
11
- resolve({
12
- supported: false,
13
- error: 'MathML not supported'
14
- });
15
- return;
16
- }
11
+ ephemeralIFrame(async ({ iframe }) => {
12
+ try {
13
+ if (!isMathMLSupported(iframe)) {
14
+ resolve({
15
+ supported: false,
16
+ error: 'MathML not supported'
17
+ });
18
+ return;
19
+ }
17
20
 
18
- const structures = [
19
- createMathML('integral', '<msubsup><mo>\u222B</mo><mi>a</mi><mi>b</mi></msubsup><mfrac><mrow><mi>f</mi><mo>(</mo><mi>x</mi><mo>)</mo></mrow><mrow><mi>g</mi><mo>(</mo><mi>x</mi><mo>)</mo></mrow></mfrac><mi>dx</mi>'),
20
- createMathML('fraction', '<mfrac><mrow><mi>\u03C0</mi><mo>\u00D7</mo><msup><mi>r</mi><mn>2</mn></msup></mrow><mrow><mn>2</mn><mi>\u03C3</mi></mrow></mfrac>'),
21
- createMathML('matrix', '<mo>[</mo><mtable><mtr><mtd><mi>\u03B1</mi></mtd><mtd><mi>\u03B2</mi></mtd></mtr><mtr><mtd><mi>\u03B3</mi></mtd><mtd><mi>\u03B4</mi></mtd></mtr></mtable><mo>]</mo>'),
22
- createComplexNestedStructure(),
23
- ...createSymbolStructures()
24
- ];
25
-
26
- const measurements: any = {};
27
- structures.forEach((struct, i) => {
28
- measurements[`struct_${i}`] = measureMathMLStructure(struct);
29
- });
21
+ const structures = [
22
+ createMathML('integral', '<msubsup><mo>\u222B</mo><mi>a</mi><mi>b</mi></msubsup><mfrac><mrow><mi>f</mi><mo>(</mo><mi>x</mi><mo>)</mo></mrow><mrow><mi>g</mi><mo>(</mo><mi>x</mi><mo>)</mo></mrow></mfrac><mi>dx</mi>'),
23
+ createMathML('fraction', '<mfrac><mrow><mi>\u03C0</mi><mo>\u00D7</mo><msup><mi>r</mi><mn>2</mn></msup></mrow><mrow><mn>2</mn><mi>\u03C3</mi></mrow></mfrac>'),
24
+ createMathML('matrix', '<mo>[</mo><mtable><mtr><mtd><mi>\u03B1</mi></mtd><mtd><mi>\u03B2</mi></mtd></mtr><mtr><mtd><mi>\u03B3</mi></mtd><mtd><mi>\u03B4</mi></mtd></mtr></mtable><mo>]</mo>'),
25
+ createComplexNestedStructure(),
26
+ ...createSymbolStructures()
27
+ ];
30
28
 
31
- resolve({
32
- //supported: true,
33
- //measurements,
34
- hash: hash(JSON.stringify(measurements))
35
- });
29
+ const dimensionsArray: any[] = [];
30
+ let fontStyleHash: string = '';
36
31
 
32
+ structures.forEach((struct, i) => {
33
+ const measurement = measureMathMLStructure(struct, iframe);
34
+ // Extract dimensions for this structure
35
+ dimensionsArray.push({
36
+ width: measurement.dimensions.width,
37
+ height: measurement.dimensions.height
38
+ });
39
+ // Capture font style hash from the first structure (it's the same for all)
40
+ if (i === 0 && measurement.fontInfo) {
41
+ fontStyleHash = hash(JSON.stringify(measurement.fontInfo));
42
+ }
43
+ });
44
+
45
+ const details = {
46
+ fontStyleHash,
47
+ dimensions: dimensionsArray
48
+ };
49
+
50
+ resolve({
51
+ //supported: true,
52
+ details,
53
+ hash: hash(JSON.stringify(details))
54
+ });
55
+
56
+ } catch (error) {
57
+ resolve({
58
+ supported: false,
59
+ error: `MathML error: ${(error as Error).message}`
60
+ });
61
+ }
62
+ });
37
63
  } catch (error) {
38
64
  resolve({
39
65
  supported: false,
@@ -43,17 +69,17 @@ export default async function getMathML(): Promise<componentInterface | null> {
43
69
  });
44
70
  }
45
71
 
46
- function isMathMLSupported(): boolean {
72
+ function isMathMLSupported(iframe: Document): boolean {
47
73
  try {
48
- const testElement = document.createElement('math');
74
+ const testElement = iframe.createElement('math');
49
75
  testElement.innerHTML = '<mrow><mi>x</mi></mrow>';
50
76
  testElement.style.position = 'absolute';
51
77
  testElement.style.visibility = 'hidden';
52
-
53
- document.body.appendChild(testElement);
78
+
79
+ iframe.body.appendChild(testElement);
54
80
  const rect = testElement.getBoundingClientRect();
55
- document.body.removeChild(testElement);
56
-
81
+ iframe.body.removeChild(testElement);
82
+
57
83
  return rect.width > 0 && rect.height > 0;
58
84
  } catch {
59
85
  return false;
@@ -101,25 +127,26 @@ function createSymbolStructures(): string[] {
101
127
  return structures;
102
128
  }
103
129
 
104
- function measureMathMLStructure(mathml: string): any {
130
+ function measureMathMLStructure(mathml: string, iframe: Document): any {
105
131
  try {
106
- const mathElement = document.createElement('math');
132
+ const mathElement = iframe.createElement('math');
107
133
  mathElement.innerHTML = mathml.replace(/<\/?math>/g, '');
108
134
  mathElement.style.whiteSpace = 'nowrap';
109
135
  mathElement.style.position = 'absolute';
110
136
  mathElement.style.visibility = 'hidden';
111
137
  mathElement.style.top = '-9999px';
112
-
113
- document.body.appendChild(mathElement);
114
-
138
+
139
+ iframe.body.appendChild(mathElement);
140
+
115
141
  const rect = mathElement.getBoundingClientRect();
116
- const computedStyle = window.getComputedStyle(mathElement);
117
-
142
+ const iframeWindow = iframe.defaultView || window;
143
+ const computedStyle = iframeWindow.getComputedStyle(mathElement);
144
+
118
145
  const measurements = {
119
146
  dimensions: {
120
147
  width: rect.width,
121
148
  height: rect.height,
122
-
149
+
123
150
  },
124
151
  fontInfo: {
125
152
  fontFamily: computedStyle.fontFamily,
@@ -129,7 +156,7 @@ function measureMathMLStructure(mathml: string): any {
129
156
  lineHeight: computedStyle.lineHeight,
130
157
  // Enhanced font properties for better system detection
131
158
  fontVariant: computedStyle.fontVariant || 'normal',
132
- fontStretch: computedStyle.fontStretch || 'normal',
159
+ fontStretch: computedStyle.fontStretch || 'normal',
133
160
  fontSizeAdjust: computedStyle.fontSizeAdjust || 'none',
134
161
  textRendering: computedStyle.textRendering || 'auto',
135
162
  fontFeatureSettings: computedStyle.fontFeatureSettings || 'normal',
@@ -137,10 +164,10 @@ function measureMathMLStructure(mathml: string): any {
137
164
  fontKerning: computedStyle.fontKerning || 'auto'
138
165
  }
139
166
  };
140
-
141
- document.body.removeChild(mathElement);
167
+
168
+ iframe.body.removeChild(mathElement);
142
169
  return measurements;
143
-
170
+
144
171
  } catch (error) {
145
172
  return {
146
173
  error: (error as Error).message
@@ -25,13 +25,18 @@ const defaultPermissionKeys: PermissionName[] = [
25
25
  ] as PermissionName[];
26
26
 
27
27
  export default async function getPermissions(options?: optionsInterface): Promise<componentInterface> {
28
- let permission_keys = options?.permissions_to_check || defaultPermissionKeys;
28
+ const permission_keys = options?.permissions_to_check || defaultPermissionKeys;
29
29
  const retries = 3;
30
- const permissionPromises: Promise<componentInterface>[] = Array.from({length: retries}, () => getBrowserPermissionsOnce(permission_keys) );
31
- return Promise.all(permissionPromises).then((resolvedPermissions) => {
32
- const permission = mostFrequentValuesInArrayOfDictionaries(resolvedPermissions, permission_keys);
33
- return permission;
34
- });
30
+
31
+ // Run permission checks multiple times
32
+ const results = await Promise.all(
33
+ Array.from({ length: retries }, () => getBrowserPermissionsOnce(permission_keys))
34
+ );
35
+
36
+ // Get most frequent values across all retries
37
+ const permissionStatus = mostFrequentValuesInArrayOfDictionaries(results, permission_keys);
38
+
39
+ return permissionStatus;
35
40
  }
36
41
 
37
42
  async function getBrowserPermissionsOnce(permission_keys: PermissionName[]): Promise<componentInterface> {
@@ -0,0 +1,103 @@
1
+ import { componentInterface } from '../../factory';
2
+ import { hash } from '../../utils/hash';
3
+
4
+ const VOICE_LOAD_TIMEOUT = 800; // milliseconds to wait for voices to load
5
+
6
+ export default async function getSpeech(): Promise<componentInterface | null> {
7
+ return new Promise((resolve) => {
8
+ try {
9
+ // Check if Speech Synthesis API is available
10
+ if (typeof window === 'undefined' || !window.speechSynthesis || typeof window.speechSynthesis.getVoices !== 'function') {
11
+ resolve({
12
+ supported: false,
13
+ error: 'Speech Synthesis API not supported'
14
+ });
15
+ return;
16
+ }
17
+
18
+ let voicesResolved = false;
19
+ let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
20
+
21
+ const processVoices = (voices: SpeechSynthesisVoice[]) => {
22
+ if (voicesResolved) return;
23
+ voicesResolved = true;
24
+
25
+ // Clear timeout if it exists
26
+ if (timeoutHandle) {
27
+ clearTimeout(timeoutHandle);
28
+ }
29
+
30
+ try {
31
+ // Collect voice signatures
32
+ const voiceSignatures = voices.map((voice) => {
33
+ // Escape commas and backslashes in voice properties
34
+ const escapeValue = (value: string): string => {
35
+ return value.replace(/\\/g, '\\\\').replace(/,/g, '\\,');
36
+ };
37
+
38
+ // Format: voiceURI,name,lang,localService,default
39
+ const signature = [
40
+ escapeValue(voice.voiceURI || ''),
41
+ escapeValue(voice.name || ''),
42
+ escapeValue(voice.lang || ''),
43
+ voice.localService ? '1' : '0',
44
+ voice.default ? '1' : '0'
45
+ ].join(',');
46
+
47
+ return signature;
48
+ });
49
+
50
+ // Sort alphabetically for consistent ordering
51
+ voiceSignatures.sort();
52
+
53
+ // Create details object with count and hash
54
+ const details = {
55
+ voiceCount: voices.length,
56
+ voicesHash: hash(JSON.stringify(voiceSignatures))
57
+ };
58
+
59
+ resolve({
60
+ details,
61
+ hash: hash(JSON.stringify(details))
62
+ });
63
+
64
+ } catch (error) {
65
+ resolve({
66
+ supported: true,
67
+ error: `Voice processing failed: ${(error as Error).message}`
68
+ });
69
+ }
70
+ };
71
+
72
+ // Try to get voices immediately
73
+ const voices = window.speechSynthesis.getVoices();
74
+
75
+ // If voices are available immediately, process them
76
+ if (voices.length > 0) {
77
+ processVoices(voices);
78
+ return;
79
+ }
80
+
81
+ // Set up timeout in case voices never load
82
+ timeoutHandle = setTimeout(() => {
83
+ const voices = window.speechSynthesis.getVoices();
84
+ processVoices(voices);
85
+ }, VOICE_LOAD_TIMEOUT);
86
+
87
+ // Listen for voiceschanged event (for browsers that load voices asynchronously)
88
+ const onVoicesChanged = () => {
89
+ window.speechSynthesis.removeEventListener('voiceschanged', onVoicesChanged);
90
+ const voices = window.speechSynthesis.getVoices();
91
+ processVoices(voices);
92
+ };
93
+
94
+ window.speechSynthesis.addEventListener('voiceschanged', onVoicesChanged);
95
+
96
+ } catch (error) {
97
+ resolve({
98
+ supported: false,
99
+ error: `Speech Synthesis error: ${(error as Error).message}`
100
+ });
101
+ }
102
+ });
103
+ }
@@ -64,9 +64,19 @@ export default async function getWebRTC(): Promise<componentInterface | null> {
64
64
  }).filter(Boolean);
65
65
  };
66
66
 
67
- const codecsSdp = {
68
- audio: constructDescriptions('audio', getDescriptors('audio')),
69
- video: constructDescriptions('video', getDescriptors('video'))
67
+ const audioCodecs = constructDescriptions('audio', getDescriptors('audio'));
68
+ const videoCodecs = constructDescriptions('video', getDescriptors('video'));
69
+
70
+ const compressedData = {
71
+ audio: {
72
+ count: audioCodecs.length,
73
+ hash: hash(JSON.stringify(audioCodecs))
74
+ },
75
+ video: {
76
+ count: videoCodecs.length,
77
+ hash: hash(JSON.stringify(videoCodecs))
78
+ },
79
+ extensionsHash: hash(JSON.stringify(extensions))
70
80
  };
71
81
 
72
82
  // Set up for ICE candidate collection with timeout
@@ -74,10 +84,9 @@ export default async function getWebRTC(): Promise<componentInterface | null> {
74
84
  const timeout = setTimeout(() => {
75
85
  connection.removeEventListener('icecandidate', onIceCandidate);
76
86
  connection.close();
77
- resolveResult({
87
+ resolveResult({
78
88
  supported: true,
79
- codecsSdp,
80
- extensions: extensions as string[],
89
+ ...compressedData,
81
90
  timeout: true
82
91
  });
83
92
  }, 3000);
@@ -90,10 +99,9 @@ export default async function getWebRTC(): Promise<componentInterface | null> {
90
99
  connection.removeEventListener('icecandidate', onIceCandidate);
91
100
  connection.close();
92
101
 
93
- resolveResult({
102
+ resolveResult({
94
103
  supported: true,
95
- codecsSdp,
96
- extensions: extensions as string[],
104
+ ...compressedData,
97
105
  candidateType: candidateObj.type || ''
98
106
  });
99
107
  };
@@ -102,6 +110,7 @@ export default async function getWebRTC(): Promise<componentInterface | null> {
102
110
  });
103
111
 
104
112
  resolve({
113
+ details: result,
105
114
  hash: hash(JSON.stringify(result)),
106
115
  });
107
116
 
package/src/factory.ts CHANGED
@@ -23,6 +23,7 @@ import getWebGL from "./components/webgl";
23
23
  // Import experimental component functions
24
24
  import getWebRTC from "./components/webrtc";
25
25
  import getMathML from "./components/mathml";
26
+ import getSpeech from "./components/speech";
26
27
 
27
28
  /**
28
29
  * @description key->function map of built-in components. Do not call the function here.
@@ -46,7 +47,8 @@ export const tm_component_promises = {
46
47
  */
47
48
  export const tm_experimental_component_promises = {
48
49
  'webrtc': getWebRTC,
49
- 'mathml': getMathML
50
+ 'mathml': getMathML,
51
+ 'speech': getSpeech
50
52
  };
51
53
 
52
54
  // the component interface is the form of the JSON object the function's promise must return
@@ -1,4 +1,4 @@
1
- import { optionsInterface, API_ENDPOINT } from '../options';
1
+ import { optionsInterface, DEFAULT_API_ENDPOINT } from '../options';
2
2
  import { componentInterface } from '../factory';
3
3
  import { getVisitorId, setVisitorId } from '../utils/visitorId';
4
4
  import { getVersion } from "../utils/version";
@@ -63,7 +63,8 @@ export const getApiPromise = (
63
63
  }
64
64
 
65
65
  // 3. Otherwise, initiate a new API call with timeout.
66
- const endpoint = `${API_ENDPOINT}/thumbmark`;
66
+ const apiEndpoint = options.api_endpoint || DEFAULT_API_ENDPOINT;
67
+ const endpoint = `${apiEndpoint}/thumbmark`;
67
68
  const visitorId = getVisitorId();
68
69
  const requestBody: any = {
69
70
  components,
@@ -55,9 +55,11 @@ export async function getThumbmark(options?: optionsInterface): Promise<thumbmar
55
55
 
56
56
  // Resolve experimental components only when logging
57
57
  let experimentalComponents = {};
58
+ let experimentalElapsed = {};
58
59
  if (shouldLog || _options.experimental) {
59
- const { resolvedComponents } = await resolveClientComponents(tm_experimental_component_promises, _options);
60
+ const { elapsed: expElapsed, resolvedComponents } = await resolveClientComponents(tm_experimental_component_promises, _options);
60
61
  experimentalComponents = resolvedComponents;
62
+ experimentalElapsed = expElapsed;
61
63
  }
62
64
 
63
65
  const apiPromise = _options.api_key ? getApiPromise(_options, clientComponentsResult) : null;
@@ -82,7 +84,8 @@ export async function getThumbmark(options?: optionsInterface): Promise<thumbmar
82
84
  }
83
85
 
84
86
  // Only add 'elapsed' if performance is true
85
- const maybeElapsed = _options.performance ? { elapsed } : {};
87
+ const allElapsed = { ...elapsed, ...experimentalElapsed };
88
+ const maybeElapsed = _options.performance ? { elapsed: allElapsed } : {};
86
89
  const apiComponents = filterThumbmarkData(apiResult?.components || {}, _options);
87
90
  const components = {...clientComponentsResult, ...apiComponents};
88
91
  const info: infoInterface = apiResult?.info || { uniqueness: { score: 'api only' } };
package/src/options.ts CHANGED
@@ -5,13 +5,14 @@ export interface optionsInterface {
5
5
  timeout?: number,
6
6
  logging?: boolean,
7
7
  api_key?: string,
8
+ api_endpoint?: string,
8
9
  cache_api_call?: boolean,
9
10
  performance?: boolean,
10
11
  stabilize?: string[],
11
12
  experimental?: boolean,
12
13
  }
13
14
 
14
- export const API_ENDPOINT = 'https://api.thumbmarkjs.com';
15
+ export const DEFAULT_API_ENDPOINT = 'https://api.thumbmarkjs.com';
15
16
 
16
17
  export const defaultOptions: optionsInterface = {
17
18
  exclude: [],
@@ -46,15 +47,14 @@ export const stabilizationExclusionRules = {
46
47
  'iframe': [
47
48
  {
48
49
  exclude: [
49
- 'permissions.camera',
50
- 'permission.geolocation',
51
- 'permissions.microphone',
52
50
  'system.applePayVersion',
53
- 'system.cookieEnabled'
51
+ 'system.cookieEnabled',
54
52
  ],
55
53
  browsers: ['safari']
56
54
  },
57
-
55
+ {
56
+ exclude: ['permissions']
57
+ }
58
58
  ],
59
59
  'vpn': [
60
60
  { exclude: ['ip'] },
package/src/utils/log.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { componentInterface } from '../factory';
2
- import { optionsInterface } from '../options';
2
+ import { optionsInterface, DEFAULT_API_ENDPOINT } from '../options';
3
3
  import { getVersion } from './version';
4
- import { API_ENDPOINT } from '../options';
5
4
 
6
5
  // ===================== Logging (Internal) =====================
7
6
 
@@ -11,7 +10,8 @@ import { API_ENDPOINT } from '../options';
11
10
  * @internal
12
11
  */
13
12
  export async function logThumbmarkData(thisHash: string, thumbmarkData: componentInterface, options: optionsInterface, experimentalData: componentInterface = {}): Promise<void> {
14
- const url = `${API_ENDPOINT}/log`;
13
+ const apiEndpoint = DEFAULT_API_ENDPOINT;
14
+ const url = `${apiEndpoint}/log`;
15
15
  const payload = {
16
16
  thumbmark: thisHash,
17
17
  components: thumbmarkData,