@thumbmarkjs/thumbmarkjs 1.8.1 → 1.9.1

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.
Files changed (56) hide show
  1. package/README.md +9 -4
  2. package/dist/thumbmark.cjs.js +1 -1
  3. package/dist/thumbmark.cjs.js.map +1 -1
  4. package/dist/thumbmark.esm.js +1 -1
  5. package/dist/thumbmark.esm.js.map +1 -1
  6. package/dist/thumbmark.umd.js +1 -1
  7. package/dist/thumbmark.umd.js.map +1 -1
  8. package/dist/types/components/audio/index.d.ts +2 -0
  9. package/dist/types/components/canvas/index.d.ts +3 -0
  10. package/dist/types/components/fonts/index.d.ts +4 -0
  11. package/dist/types/components/hardware/index.d.ts +2 -0
  12. package/dist/types/components/intl/index.d.ts +2 -0
  13. package/dist/types/components/locales/index.d.ts +2 -0
  14. package/dist/types/components/math/index.d.ts +2 -0
  15. package/dist/types/components/mathml/index.d.ts +2 -0
  16. package/dist/types/components/mediaDevices/index.d.ts +2 -0
  17. package/dist/types/components/permissions/index.d.ts +3 -0
  18. package/dist/types/components/plugins/index.d.ts +2 -0
  19. package/dist/types/components/screen/index.d.ts +2 -0
  20. package/dist/types/components/speech/index.d.ts +2 -0
  21. package/dist/types/components/system/browser.d.ts +9 -0
  22. package/dist/types/components/system/index.d.ts +2 -0
  23. package/dist/types/components/webgl/index.d.ts +13 -0
  24. package/dist/types/components/webrtc/index.d.ts +3 -0
  25. package/dist/types/factory.d.ts +66 -0
  26. package/dist/types/functions/api.d.ts +73 -0
  27. package/dist/types/functions/filterComponents.d.ts +15 -0
  28. package/dist/types/functions/index.d.ts +63 -0
  29. package/dist/types/functions/legacy_functions.d.ts +27 -0
  30. package/dist/types/index.d.ts +9 -0
  31. package/dist/types/options.d.ts +76 -0
  32. package/dist/types/thumbmark.d.ts +28 -0
  33. package/dist/types/utils/cache.d.ts +23 -0
  34. package/dist/types/utils/commonPixels.d.ts +1 -0
  35. package/dist/types/utils/ephemeralIFrame.d.ts +4 -0
  36. package/dist/types/utils/getMostFrequent.d.ts +5 -0
  37. package/dist/types/utils/hash.d.ts +5 -0
  38. package/dist/types/utils/imageDataToDataURL.d.ts +1 -0
  39. package/dist/types/utils/log.d.ts +9 -0
  40. package/dist/types/utils/raceAll.d.ts +10 -0
  41. package/dist/types/utils/sort.d.ts +8 -0
  42. package/dist/types/utils/stableStringify.d.ts +22 -0
  43. package/dist/types/utils/version.d.ts +4 -0
  44. package/dist/types/utils/visitorId.d.ts +14 -0
  45. package/package.json +3 -2
  46. package/src/components/audio/index.ts +0 -1
  47. package/src/components/system/browser.ts +24 -4
  48. package/src/components/webgl/index.test.ts +223 -0
  49. package/src/components/webgl/index.ts +188 -146
  50. package/src/components/webrtc/index.ts +15 -38
  51. package/src/functions/filterComponents.test.ts +35 -0
  52. package/src/functions/filterComponents.ts +2 -3
  53. package/src/functions/index.ts +70 -10
  54. package/src/utils/commonPixels.ts +25 -0
  55. package/src/utils/raceAll.ts +22 -12
  56. package/src/utils/stableStringify.ts +6 -4
@@ -0,0 +1,223 @@
1
+ import getWebGL, { __resetWebGLCache, __getWebGLCache } from './index';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mock ImageData — jsdom does not provide a real implementation
5
+ // ---------------------------------------------------------------------------
6
+ class MockImageData {
7
+ data: Uint8ClampedArray;
8
+ width: number;
9
+ height: number;
10
+
11
+ constructor(dataOrWidth: Uint8ClampedArray | number, widthOrUndefined?: number, height?: number) {
12
+ if (dataOrWidth instanceof Uint8ClampedArray) {
13
+ this.data = dataOrWidth;
14
+ this.width = widthOrUndefined ?? 1;
15
+ this.height = height ?? 1;
16
+ } else {
17
+ const w = dataOrWidth as number;
18
+ const h = widthOrUndefined ?? 1;
19
+ this.data = new Uint8ClampedArray(w * h * 4);
20
+ this.width = w;
21
+ this.height = h;
22
+ }
23
+ }
24
+ }
25
+ (global as any).ImageData = MockImageData;
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // WebGL mock factory — returns an object satisfying setupWebGL() and renderImage()
29
+ // ---------------------------------------------------------------------------
30
+ function makeGlMock(overrides: Partial<WebGLRenderingContext> = {}): WebGLRenderingContext {
31
+ const gl: any = {
32
+ VERTEX_SHADER: 35633,
33
+ FRAGMENT_SHADER: 35632,
34
+ ARRAY_BUFFER: 34962,
35
+ COMPILE_STATUS: 35713,
36
+ LINK_STATUS: 35714,
37
+ RGBA: 6408,
38
+ UNSIGNED_BYTE: 5121,
39
+ COLOR_BUFFER_BIT: 16384,
40
+ FLOAT: 5126,
41
+ LINES: 1,
42
+ drawingBufferWidth: 200,
43
+ drawingBufferHeight: 100,
44
+ createShader: jest.fn().mockReturnValue({}),
45
+ shaderSource: jest.fn(),
46
+ compileShader: jest.fn(),
47
+ getShaderParameter: jest.fn().mockReturnValue(true),
48
+ createProgram: jest.fn().mockReturnValue({}),
49
+ attachShader: jest.fn(),
50
+ linkProgram: jest.fn(),
51
+ getProgramParameter: jest.fn().mockReturnValue(true),
52
+ createBuffer: jest.fn().mockReturnValue({}),
53
+ bindBuffer: jest.fn(),
54
+ bufferData: jest.fn(),
55
+ useProgram: jest.fn(),
56
+ getAttribLocation: jest.fn().mockReturnValue(0),
57
+ enableVertexAttribArray: jest.fn(),
58
+ vertexAttribPointer: jest.fn(),
59
+ viewport: jest.fn(),
60
+ clearColor: jest.fn(),
61
+ clear: jest.fn(),
62
+ drawArrays: jest.fn(),
63
+ readPixels: jest.fn(),
64
+ ...overrides,
65
+ };
66
+ return gl as WebGLRenderingContext;
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Suite setup
71
+ // ---------------------------------------------------------------------------
72
+ let originalGetContext: any;
73
+
74
+ beforeAll(() => {
75
+ originalGetContext = HTMLCanvasElement.prototype.getContext;
76
+ });
77
+
78
+ afterAll(() => {
79
+ HTMLCanvasElement.prototype.getContext = originalGetContext;
80
+ });
81
+
82
+ beforeEach(() => {
83
+ __resetWebGLCache();
84
+ });
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Tests
88
+ // ---------------------------------------------------------------------------
89
+ describe('webgl component — context-loss recovery', () => {
90
+
91
+ // Happy path: cache is populated after first successful call
92
+ test('first getWebGL() call populates the cache', async () => {
93
+ HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue(makeGlMock()) as any;
94
+
95
+ await getWebGL();
96
+
97
+ expect(__getWebGLCache()).not.toBeNull();
98
+ });
99
+
100
+ // Cache reuse: two calls share the same canvas object
101
+ test('two consecutive getWebGL() calls reuse the same cached canvas', async () => {
102
+ HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue(makeGlMock()) as any;
103
+
104
+ await getWebGL();
105
+ const cacheAfterFirst = __getWebGLCache();
106
+ expect(cacheAfterFirst).not.toBeNull();
107
+
108
+ await getWebGL();
109
+ const cacheAfterSecond = __getWebGLCache();
110
+
111
+ // Same object reference — no new canvas was created
112
+ expect(cacheAfterSecond).toBe(cacheAfterFirst);
113
+ // getContext called exactly once (canvas created once)
114
+ expect(HTMLCanvasElement.prototype.getContext).toHaveBeenCalledTimes(1);
115
+ });
116
+
117
+ // Context-loss listener: dispatching webglcontextlost clears the cache
118
+ test('webglcontextlost event clears the cache', async () => {
119
+ HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue(makeGlMock()) as any;
120
+
121
+ await getWebGL();
122
+ const cacheBeforeLoss = __getWebGLCache();
123
+ expect(cacheBeforeLoss).not.toBeNull();
124
+
125
+ // Fire the context-lost event on the cached canvas
126
+ cacheBeforeLoss!.canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
127
+
128
+ expect(__getWebGLCache()).toBeNull();
129
+ });
130
+
131
+ // Context-loss listener: event.preventDefault() is called
132
+ test('webglcontextlost listener calls event.preventDefault()', async () => {
133
+ HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue(makeGlMock()) as any;
134
+
135
+ await getWebGL();
136
+ const cache = __getWebGLCache();
137
+ expect(cache).not.toBeNull();
138
+
139
+ const lostEvent = new Event('webglcontextlost', { cancelable: true });
140
+ const preventDefaultSpy = jest.spyOn(lostEvent, 'preventDefault');
141
+
142
+ cache!.canvas.dispatchEvent(lostEvent);
143
+
144
+ expect(preventDefaultSpy).toHaveBeenCalledTimes(1);
145
+ });
146
+
147
+ // After context loss, next getWebGL() call creates a NEW canvas
148
+ test('getWebGL() after context loss rebuilds the cache via a new canvas', async () => {
149
+ const gl = makeGlMock();
150
+ HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue(gl) as any;
151
+
152
+ await getWebGL();
153
+ const firstCache = __getWebGLCache();
154
+ expect(firstCache).not.toBeNull();
155
+
156
+ // Simulate context loss
157
+ firstCache!.canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
158
+ expect(__getWebGLCache()).toBeNull();
159
+
160
+ // Reset mock call count, re-install for rebuild
161
+ HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue(gl) as any;
162
+
163
+ await getWebGL();
164
+ const secondCache = __getWebGLCache();
165
+ expect(secondCache).not.toBeNull();
166
+ // A fresh cache object is created — different reference from the lost one
167
+ expect(secondCache).not.toBe(firstCache);
168
+ // getContext called once for the rebuild (new canvas)
169
+ expect(HTMLCanvasElement.prototype.getContext).toHaveBeenCalledTimes(1);
170
+ });
171
+
172
+ // once: true — the listener fires at most once per canvas
173
+ test('webglcontextlost listener fires only once (once:true)', async () => {
174
+ HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue(makeGlMock()) as any;
175
+
176
+ await getWebGL();
177
+ const firstCache = __getWebGLCache();
178
+ const canvas = firstCache!.canvas;
179
+
180
+ // First dispatch: clears cache
181
+ canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
182
+ expect(__getWebGLCache()).toBeNull();
183
+
184
+ // Second dispatch on the same canvas — listener already removed (once:true),
185
+ // so _cache remains null (unchanged) and no throw occurs
186
+ expect(() => {
187
+ canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
188
+ }).not.toThrow();
189
+ expect(__getWebGLCache()).toBeNull();
190
+ });
191
+
192
+ // renderImage catch block: render failure clears the cache
193
+ test('render failure (useProgram throws) clears the cache', async () => {
194
+ HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue(makeGlMock()) as any;
195
+
196
+ // First call populates cache
197
+ await getWebGL();
198
+ expect(__getWebGLCache()).not.toBeNull();
199
+
200
+ // Patch the cached gl to throw on the next render
201
+ const cache = __getWebGLCache()!;
202
+ (cache as any).gl.useProgram = jest.fn().mockImplementation(() => {
203
+ throw new Error('WebGL context lost');
204
+ });
205
+
206
+ // The call should not throw; it should return the 'unsupported' fallback
207
+ const result = await getWebGL();
208
+ expect(result).toEqual({ webgl: 'unsupported' });
209
+
210
+ // Cache must have been cleared by the catch block
211
+ expect(__getWebGLCache()).toBeNull();
212
+ });
213
+
214
+ // Graceful degradation: getContext returns null → returns 'unsupported'
215
+ test('getWebGL() returns unsupported when WebGL context is unavailable', async () => {
216
+ HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue(null) as any;
217
+
218
+ const result = await getWebGL();
219
+
220
+ expect(result).toEqual({ webgl: 'unsupported' });
221
+ expect(__getWebGLCache()).toBeNull();
222
+ });
223
+ });
@@ -1,146 +1,188 @@
1
- import { componentInterface, includeComponent } from '../../factory'
2
- import { hash } from '../../utils/hash'
3
- import { getCommonPixels } from '../../utils/commonPixels';
4
- import { getBrowser } from '../system/browser';
5
-
6
- const _RUNS = (getBrowser().name !== 'SamsungBrowser') ? 1 : 3;
7
- let canvas: HTMLCanvasElement
8
- let gl: WebGLRenderingContext | null = null;
9
-
10
- function initializeCanvasAndWebGL() {
11
- if (typeof document !== 'undefined') {
12
- canvas = document.createElement('canvas');
13
- canvas.width = 200;
14
- canvas.height = 100;
15
- gl = canvas.getContext('webgl');
16
- }
17
- }
18
-
19
- export default async function getWebGL(): Promise<componentInterface> {
20
- initializeCanvasAndWebGL();
21
-
22
- try {
23
-
24
- if (!gl) {
25
- throw new Error('WebGL not supported');
26
- }
27
-
28
-
29
- const imageDatas: ImageData[] = Array.from({length: _RUNS}, () => createWebGLImageData() );
30
- // and then checking the most common bytes for each channel of each pixel
31
- const commonImageData = getCommonPixels(imageDatas, canvas.width, canvas.height);
32
- //const imageData = createWebGLImageData()
33
-
34
- return {
35
- 'commonPixelsHash': hash(commonImageData.data.toString()).toString(),
36
- }
37
- } catch (error) {
38
- return {
39
- 'webgl': 'unsupported'
40
- }
41
- }
42
- }
43
-
44
- function createWebGLImageData(): ImageData {
45
- try {
46
-
47
- if (!gl) {
48
- throw new Error('WebGL not supported');
49
- }
50
-
51
- const vertexShaderSource = `
52
- attribute vec2 position;
53
- void main() {
54
- gl_Position = vec4(position, 0.0, 1.0);
55
- }
56
- `;
57
-
58
- const fragmentShaderSource = `
59
- precision mediump float;
60
- void main() {
61
- gl_FragColor = vec4(0.812, 0.195, 0.553, 0.921); // Set line color
62
- }
63
- `;
64
-
65
- const vertexShader = gl.createShader(gl.VERTEX_SHADER);
66
- const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
67
-
68
- if (!vertexShader || !fragmentShader) {
69
- throw new Error('Failed to create shaders');
70
- }
71
-
72
- gl.shaderSource(vertexShader, vertexShaderSource);
73
- gl.shaderSource(fragmentShader, fragmentShaderSource);
74
-
75
- gl.compileShader(vertexShader);
76
- if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
77
- throw new Error('Vertex shader compilation failed: ' + gl.getShaderInfoLog(vertexShader));
78
- }
79
-
80
- gl.compileShader(fragmentShader);
81
- if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
82
- throw new Error('Fragment shader compilation failed: ' + gl.getShaderInfoLog(fragmentShader));
83
- }
84
-
85
- const shaderProgram = gl.createProgram();
86
-
87
- if (!shaderProgram) {
88
- throw new Error('Failed to create shader program');
89
- }
90
-
91
- gl.attachShader(shaderProgram, vertexShader);
92
- gl.attachShader(shaderProgram, fragmentShader);
93
- gl.linkProgram(shaderProgram);
94
- if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
95
- throw new Error('Shader program linking failed: ' + gl.getProgramInfoLog(shaderProgram));
96
- }
97
-
98
- gl.useProgram(shaderProgram);
99
-
100
- // Set up vertices to form lines
101
- const numSpokes: number = 137;
102
- const vertices = new Float32Array(numSpokes * 4);
103
- const angleIncrement = (2 * Math.PI) / numSpokes;
104
-
105
- for (let i = 0; i < numSpokes; i++) {
106
- const angle = i * angleIncrement;
107
-
108
- // Define two points for each line (spoke)
109
- vertices[i * 4] = 0; // Center X
110
- vertices[i * 4 + 1] = 0; // Center Y
111
- vertices[i * 4 + 2] = Math.cos(angle) * (canvas.width / 2); // Endpoint X
112
- vertices[i * 4 + 3] = Math.sin(angle) * (canvas.height / 2); // Endpoint Y
113
- }
114
-
115
- const vertexBuffer = gl.createBuffer();
116
- gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
117
- gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
118
-
119
- const positionAttribute = gl.getAttribLocation(shaderProgram, 'position');
120
- gl.enableVertexAttribArray(positionAttribute);
121
- gl.vertexAttribPointer(positionAttribute, 2, gl.FLOAT, false, 0, 0);
122
-
123
- // Render
124
- gl.viewport(0, 0, canvas.width, canvas.height);
125
- gl.clearColor(0.0, 0.0, 0.0, 1.0);
126
- gl.clear(gl.COLOR_BUFFER_BIT);
127
- gl.drawArrays(gl.LINES, 0, numSpokes * 2);
128
-
129
- const pixelData = new Uint8ClampedArray(canvas.width * canvas.height * 4);
130
- gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, pixelData);
131
- const imageData = new ImageData(pixelData, canvas.width, canvas.height);
132
-
133
- return imageData;
134
- } catch (error) {
135
- //console.error(error);
136
- return new ImageData(1, 1);
137
- } finally {
138
- if (gl) {
139
- // Reset WebGL state
140
- gl.bindBuffer(gl.ARRAY_BUFFER, null);
141
- gl.useProgram(null);
142
- gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
143
- gl.clearColor(0.0, 0.0, 0.0, 0.0);
144
- }
145
- }
146
- }
1
+ import { componentInterface } from '../../factory'
2
+ import { hash } from '../../utils/hash'
3
+ import { getCommonPixels } from '../../utils/commonPixels';
4
+ import { getBrowser } from '../system/browser';
5
+
6
+ const _RUNS = (getBrowser().name !== 'SamsungBrowser') ? 1 : 3;
7
+ const _USE_CACHE = getBrowser().name !== 'Brave';
8
+
9
+ // Canvas and viewport dimensions — part of the fingerprint signal, do not change.
10
+ const _CANVAS_W = 200;
11
+ const _CANVAS_H = 100;
12
+ const _NUM_SPOKES = 137;
13
+
14
+ // Shader sources are constant — hoist to module scope to avoid per-call string allocation.
15
+ const _VERTEX_SHADER_SRC = `
16
+ attribute vec2 position;
17
+ void main() {
18
+ gl_Position = vec4(position, 0.0, 1.0);
19
+ }
20
+ `;
21
+
22
+ const _FRAGMENT_SHADER_SRC = `
23
+ precision mediump float;
24
+ void main() {
25
+ gl_FragColor = vec4(0.812, 0.195, 0.553, 0.921); // Set line color
26
+ }
27
+ `;
28
+
29
+ // Precompute the spoke vertices once at module load. The values are determined
30
+ // solely by _NUM_SPOKES, _CANVAS_W, and _CANVAS_H all module constants
31
+ // so they are identical to what the old per-call computation produced.
32
+ const _VERTICES: Float32Array = (() => {
33
+ const v = new Float32Array(_NUM_SPOKES * 4);
34
+ const angleIncrement = (2 * Math.PI) / _NUM_SPOKES;
35
+ for (let i = 0; i < _NUM_SPOKES; i++) {
36
+ const angle = i * angleIncrement;
37
+ v[i * 4] = 0; // Center X
38
+ v[i * 4 + 1] = 0; // Center Y
39
+ v[i * 4 + 2] = Math.cos(angle) * (_CANVAS_W / 2); // Endpoint X
40
+ v[i * 4 + 3] = Math.sin(angle) * (_CANVAS_H / 2); // Endpoint Y
41
+ }
42
+ return v;
43
+ })();
44
+
45
+ interface WebGLCache {
46
+ canvas: HTMLCanvasElement;
47
+ gl: WebGLRenderingContext;
48
+ program: WebGLProgram;
49
+ buffer: WebGLBuffer;
50
+ }
51
+
52
+ // Module-scope cache — only populated when _USE_CACHE is true (non-Brave).
53
+ let _cache: WebGLCache | null = null;
54
+
55
+ /** Test-only: reset the module-scope cache between test runs. */
56
+ export function __resetWebGLCache(): void {
57
+ _cache = null;
58
+ }
59
+
60
+ /** Test-only: read the current cache reference without mutating it. */
61
+ export function __getWebGLCache(): WebGLCache | null {
62
+ return _cache;
63
+ }
64
+
65
+ /**
66
+ * Create a canvas, compile shaders, link program, and upload vertex data.
67
+ * Returns null if any step fails so callers can degrade gracefully.
68
+ */
69
+ function setupWebGL(): WebGLCache | null {
70
+ try {
71
+ if (typeof document === 'undefined') return null;
72
+
73
+ const canvas = document.createElement('canvas');
74
+ canvas.width = _CANVAS_W;
75
+ canvas.height = _CANVAS_H;
76
+
77
+ const gl = canvas.getContext('webgl');
78
+ if (!gl) return null;
79
+
80
+ // When the browser invalidates GPU resources it fires webglcontextlost.
81
+ // Null the cache so the next getOrInitCache() rebuilds via a fresh setupWebGL().
82
+ // { once: true } ensures the listener self-removes after firing so the old
83
+ // canvas does not keep the WebGLCache object alive after recovery.
84
+ canvas.addEventListener('webglcontextlost', (event) => {
85
+ event.preventDefault();
86
+ _cache = null;
87
+ }, { once: true });
88
+
89
+ const vertexShader = gl.createShader(gl.VERTEX_SHADER);
90
+ const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
91
+ if (!vertexShader || !fragmentShader) return null;
92
+
93
+ gl.shaderSource(vertexShader, _VERTEX_SHADER_SRC);
94
+ gl.compileShader(vertexShader);
95
+ if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) return null;
96
+
97
+ gl.shaderSource(fragmentShader, _FRAGMENT_SHADER_SRC);
98
+ gl.compileShader(fragmentShader);
99
+ if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) return null;
100
+
101
+ const program = gl.createProgram();
102
+ if (!program) return null;
103
+
104
+ gl.attachShader(program, vertexShader);
105
+ gl.attachShader(program, fragmentShader);
106
+ gl.linkProgram(program);
107
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return null;
108
+
109
+ const buffer = gl.createBuffer();
110
+ if (!buffer) return null;
111
+
112
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
113
+ gl.bufferData(gl.ARRAY_BUFFER, _VERTICES, gl.STATIC_DRAW);
114
+ gl.bindBuffer(gl.ARRAY_BUFFER, null);
115
+
116
+ return { canvas, gl, program, buffer };
117
+ } catch (_) {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * For non-Brave browsers: lazily initialise once and reuse.
124
+ * For Brave: always create fresh (Brave farbles WebGL per-context, preserving
125
+ * the noise signal that today's per-call setup drives).
126
+ */
127
+ function getOrInitCache(): WebGLCache | null {
128
+ if (_USE_CACHE) {
129
+ if (!_cache) _cache = setupWebGL();
130
+ return _cache;
131
+ }
132
+ // Brave path: fresh context every call, byte-identical behaviour to pre-cache code.
133
+ return setupWebGL();
134
+ }
135
+
136
+ /**
137
+ * Execute one render pass on the shared (or fresh) WebGL context and return
138
+ * the raw pixel data as an ImageData. Returns a 1×1 blank ImageData on error.
139
+ */
140
+ function renderImage(cache: WebGLCache): ImageData {
141
+ const { canvas, gl, program, buffer } = cache;
142
+ try {
143
+ gl.useProgram(program);
144
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
145
+
146
+ const positionAttribute = gl.getAttribLocation(program, 'position');
147
+ gl.enableVertexAttribArray(positionAttribute);
148
+ gl.vertexAttribPointer(positionAttribute, 2, gl.FLOAT, false, 0, 0);
149
+
150
+ gl.viewport(0, 0, canvas.width, canvas.height);
151
+ gl.clearColor(0.0, 0.0, 0.0, 1.0);
152
+ gl.clear(gl.COLOR_BUFFER_BIT);
153
+ gl.drawArrays(gl.LINES, 0, _NUM_SPOKES * 2);
154
+
155
+ const pixelData = new Uint8ClampedArray(canvas.width * canvas.height * 4);
156
+ gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, pixelData);
157
+ return new ImageData(pixelData, canvas.width, canvas.height);
158
+ } catch (_) {
159
+ // Belt-and-suspenders: any render failure (context loss, GPU driver glitch, etc.)
160
+ // invalidates the cache so the next call rebuilds rather than retrying with a stale context.
161
+ _cache = null;
162
+ return new ImageData(1, 1);
163
+ } finally {
164
+ // Reset WebGL state to match pre-cache behaviour.
165
+ gl.bindBuffer(gl.ARRAY_BUFFER, null);
166
+ gl.useProgram(null);
167
+ gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
168
+ gl.clearColor(0.0, 0.0, 0.0, 0.0);
169
+ }
170
+ }
171
+
172
+ export default async function getWebGL(): Promise<componentInterface> {
173
+ const cache = getOrInitCache();
174
+
175
+ if (!cache) {
176
+ return { 'webgl': 'unsupported' };
177
+ }
178
+
179
+ try {
180
+ const imageDatas: ImageData[] = Array.from({ length: _RUNS }, () => renderImage(cache));
181
+ const commonImageData = getCommonPixels(imageDatas, cache.canvas.width, cache.canvas.height);
182
+ return {
183
+ 'commonPixelsHash': hash(commonImageData.data.toString()).toString(),
184
+ };
185
+ } catch (_) {
186
+ return { 'webgl': 'unsupported' };
187
+ }
188
+ }
@@ -5,6 +5,7 @@ import { stableStringify } from '../../utils/stableStringify';
5
5
 
6
6
  export default async function getWebRTC(options?: optionsInterface): Promise<componentInterface | null> {
7
7
  return new Promise((resolve) => {
8
+ let connection: RTCPeerConnection | undefined;
8
9
  try {
9
10
  // Check if WebRTC is supported
10
11
  const RTCPeerConnection = (window as any).RTCPeerConnection || (window as any).webkitRTCPeerConnection || (window as any).mozRTCPeerConnection;
@@ -21,14 +22,17 @@ export default async function getWebRTC(options?: optionsInterface): Promise<com
21
22
  iceServers: []
22
23
  };
23
24
 
24
- const connection = new RTCPeerConnection(config);
25
- connection.createDataChannel(''); // trigger ICE gathering
25
+ connection = new RTCPeerConnection(config);
26
+ // Non-null assertion: connection was just assigned above; if the constructor
27
+ // had thrown we would not reach this line.
28
+ const conn: RTCPeerConnection = connection!;
29
+ conn.createDataChannel(''); // trigger ICE gathering
26
30
 
27
31
  const processOffer = async () => {
28
32
  try {
29
33
  const offerOptions = { offerToReceiveAudio: true, offerToReceiveVideo: true };
30
- const offer = await connection.createOffer(offerOptions);
31
- await connection.setLocalDescription(offer);
34
+ const offer = await conn.createOffer(offerOptions);
35
+ await conn.setLocalDescription(offer);
32
36
 
33
37
  const sdp = offer.sdp || '';
34
38
 
@@ -81,39 +85,11 @@ export default async function getWebRTC(options?: optionsInterface): Promise<com
81
85
  extensionsHash: hash(stableStringify(extensions))
82
86
  };
83
87
 
84
- // Set up for ICE candidate collection with timeout
85
- // Use 60% of the total timeout to ensure this completes before the global timeout
86
- const totalTimeout = options?.timeout || 5000;
87
- const iceTimeout = Math.floor(totalTimeout * 0.9);
88
-
89
- const result = await new Promise<componentInterface>((resolveResult) => {
90
- const timeout = setTimeout(() => {
91
- connection.removeEventListener('icecandidate', onIceCandidate);
92
- connection.close();
93
- resolveResult({
94
- supported: true,
95
- ...compressedData,
96
- timeout: true
97
- });
98
- }, iceTimeout);
99
-
100
- const onIceCandidate = (event: RTCPeerConnectionIceEvent) => {
101
- const candidateObj = event.candidate;
102
- if (!candidateObj || !candidateObj.candidate) return;
103
-
104
- clearTimeout(timeout);
105
- connection.removeEventListener('icecandidate', onIceCandidate);
106
- connection.close();
107
-
108
- resolveResult({
109
- supported: true,
110
- ...compressedData,
111
- candidateType: candidateObj.type || ''
112
- });
113
- };
114
-
115
- connection.addEventListener('icecandidate', onIceCandidate);
116
- });
88
+ // With iceServers:[] only "host" candidates are generated, so waiting for
89
+ // the icecandidate event adds latency without any entropy gain. Close
90
+ // immediately and hard-code the known value to keep the hash stable.
91
+ conn.close();
92
+ const result = { supported: true, ...compressedData, candidateType: 'host' };
117
93
 
118
94
  resolve({
119
95
  details: result,
@@ -121,7 +97,7 @@ export default async function getWebRTC(options?: optionsInterface): Promise<com
121
97
  });
122
98
 
123
99
  } catch (error) {
124
- connection.close();
100
+ conn.close();
125
101
  resolve({
126
102
  supported: true,
127
103
  error: `WebRTC offer failed: ${(error as Error).message}`
@@ -132,6 +108,7 @@ export default async function getWebRTC(options?: optionsInterface): Promise<com
132
108
  processOffer();
133
109
 
134
110
  } catch (error) {
111
+ connection?.close();
135
112
  resolve({
136
113
  supported: false,
137
114
  error: `WebRTC error: ${(error as Error).message}`