Source code
Revision control
Copy as Markdown
Other Tools
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
// Defined by gl-matrix.js
/* global mat4 */
// Defined by ssdeep.js
/* global ssdeep */
// =============================================================
// Utility Functions
var debugMsgs = [];
function debug(...args) {
let msg = "";
if (!args.length) {
debugMsgs.push("");
return;
}
const stringify = o => {
if (typeof o == "string") {
return o;
}
return JSON.stringify(o);
};
const stringifiedArgs = args.map(stringify);
msg += stringifiedArgs.join(" ");
debugMsgs.push(msg);
// Also echo it locally
/* eslint-disable-next-line no-console */
console.log(msg);
}
async function sha1(message) {
const msgUint8 = new TextEncoder().encode(message);
const hashBuffer = await window.crypto.subtle.digest("SHA-1", msgUint8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
return hashHex;
}
async function stringifyError(error) {
if (error instanceof Error) {
const stack = (error.stack ?? "").replaceAll(
/@chrome.+?usercharacteristics.js:/g,
""
);
return `${error.toString()} ${stack}`;
}
// A hacky attempt to extract as much as info from error
const errStr = await (async () => {
const asStr = await (async () => error.toString())().catch(() => "");
const asJson = await (async () => JSON.stringify(error))().catch(() => "");
return asStr.length > asJson.len ? asStr : asJson;
})();
return errStr;
}
function sample(array, count) {
const range = array.length - 1;
if (range <= count) {
return array;
}
const samples = [];
const step = Math.floor(range / count);
for (let i = 0; i < range; i += step) {
samples.push(array[i]);
}
return samples;
}
function mean(array) {
if (array.length === 0) {
return 0;
}
return array.reduce((a, b) => a + b) / array.length;
}
function standardDeviation(array) {
const m = mean(array);
return Math.sqrt(mean(array.map(x => Math.pow(x - m, 2))));
}
// Returns the number of decimal places num has. Useful for
// collecting precision of values reported by the hardware.
function decimalPlaces(num) {
// Omit - sign if num is negative.
const str = num >= 0 ? num.toString() : num.toString().substr(1);
// Handle scientific notation numbers such as 1e-15.
const dashI = str.indexOf("-");
if (dashI !== -1) {
return +str.substr(dashI + 1);
}
// Handle numbers separated by . such as 1.0000015
const dotI = str.indexOf(".");
if (dotI !== -1) {
return str.length - dotI - 1;
}
// Handle numbers separated by , such as 1,0000015
const commaI = str.indexOf(",");
if (commaI !== -1) {
return str.length - commaI - 1;
}
return 0;
}
function timeoutPromise(promise, ms) {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error("TIMEOUT"));
}, ms);
promise.then(
value => {
clearTimeout(timeoutId);
resolve(value);
},
error => {
clearTimeout(timeoutId);
reject(error);
}
);
});
}
// =======================================================================
// WebGL Canvases
function populateWebGLCanvases(contextOptions = {}) {
// The following WebGL code came from https://github.com/mdn/dom-examples/blob/4f305d21de796432dac2e9f2961591e4b7f913c0/webgl-examples/tutorial/sample3/webgl-demo.js
// with some minor modifications
const data = {};
const suffix = contextOptions.forceSoftwareRendering ? "software" : "";
// --------------------------------------------------------------------
// initBuffers
//
// Initialize the buffers we'll need. For this demo, we just
// have one object -- a simple two-dimensional square.
//
function initBuffers(gl) {
// Create a buffer for the square's positions.
const positionBuffer = gl.createBuffer();
// Select the positionBuffer as the one to apply buffer
// operations to from here out.
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
// Now create an array of positions for the square.
const positions = [1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, -1.0];
// Now pass the list of positions into WebGL to build the
// shape. We do this by creating a Float32Array from the
// JavaScript array, then use it to fill the current buffer.
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
// Now set up the colors for the vertices
var colors = [
1.0,
1.0,
1.0,
1.0, // white
1.0,
0.0,
0.0,
1.0, // red
0.0,
1.0,
0.0,
1.0, // green
0.0,
0.0,
1.0,
1.0, // blue
];
const colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.STATIC_DRAW);
return {
position: positionBuffer,
color: colorBuffer,
};
}
// --------------------------------------------------------------------
// Draw the scene.
function drawScene(gl, programInfo, buffers) {
gl.clearColor(0.0, 0.0, 0.0, 1.0); // Clear to black, fully opaque
gl.clearDepth(1.0); // Clear everything
gl.enable(gl.DEPTH_TEST); // Enable depth testing
gl.depthFunc(gl.LEQUAL); // Near things obscure far things
// Clear the canvas before we start drawing on it.
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// Create a perspective matrix, a special matrix that is
// used to simulate the distortion of perspective in a camera.
// Our field of view is 45 degrees, with a width/height
// ratio that matches the display size of the canvas
// and we only want to see objects between 0.1 units
// and 100 units away from the camera.
const fieldOfView = (45 * Math.PI) / 180; // in radians
const aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;
const zNear = 0.1;
const zFar = 100.0;
const projectionMatrix = mat4.create();
// note: glmatrix.js always has the first argument
// as the destination to receive the result.
mat4.perspective(projectionMatrix, fieldOfView, aspect, zNear, zFar);
// Set the drawing position to the "identity" point, which is
// the center of the scene.
const modelViewMatrix = mat4.create();
var squareRotation = 1.0;
// Now move the drawing position a bit to where we want to
// start drawing the square.
mat4.translate(
modelViewMatrix, // destination matrix
modelViewMatrix, // matrix to translate
[-0.0, 0.0, -6.0]
); // amount to translate
mat4.rotate(
modelViewMatrix, // destination matrix
modelViewMatrix, // matrix to rotate
squareRotation, // amount to rotate in radians
[0, 0, 1]
); // axis to rotate around
// Tell WebGL how to pull out the positions from the position
// buffer into the vertexPosition attribute
{
const numComponents = 2;
const type = gl.FLOAT;
const normalize = false;
const stride = 0;
const offset = 0;
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.position);
gl.vertexAttribPointer(
programInfo.attribLocations.vertexPosition,
numComponents,
type,
normalize,
stride,
offset
);
gl.enableVertexAttribArray(programInfo.attribLocations.vertexPosition);
}
// Tell WebGL how to pull out the colors from the color buffer
// into the vertexColor attribute.
{
const numComponents = 4;
const type = gl.FLOAT;
const normalize = false;
const stride = 0;
const offset = 0;
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.color);
gl.vertexAttribPointer(
programInfo.attribLocations.vertexColor,
numComponents,
type,
normalize,
stride,
offset
);
gl.enableVertexAttribArray(programInfo.attribLocations.vertexColor);
}
// Tell WebGL to use our program when drawing
gl.useProgram(programInfo.program);
// Set the shader uniforms
gl.uniformMatrix4fv(
programInfo.uniformLocations.projectionMatrix,
false,
projectionMatrix
);
gl.uniformMatrix4fv(
programInfo.uniformLocations.modelViewMatrix,
false,
modelViewMatrix
);
{
const offset = 0;
const vertexCount = 4;
gl.drawArrays(gl.TRIANGLE_STRIP, offset, vertexCount);
}
}
// --------------------------------------------------------------------
// Initialize a shader program, so WebGL knows how to draw our data
function initShaderProgram(gl, vsSource, fsSource) {
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);
// Create the shader program
const shaderProgram = gl.createProgram();
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);
// If creating the shader program failed, alert
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
alert(
"Unable to initialize the shader program: " +
gl.getProgramInfoLog(shaderProgram)
);
return null;
}
return shaderProgram;
}
// --------------------------------------------------------------------
//
// creates a shader of the given type, uploads the source and
// compiles it.
//
function loadShader(gl, type, source) {
const shader = gl.createShader(type);
// Send the source to the shader object
gl.shaderSource(shader, source);
// Compile the shader program
gl.compileShader(shader);
// See if it compiled successfully
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
alert(
"An error occurred compiling the shaders: " +
gl.getShaderInfoLog(shader)
);
gl.deleteShader(shader);
return null;
}
return shader;
}
// --------------------------------------------------------------------
const canvas = document.getElementById("glcanvas" + suffix);
const gl = canvas.getContext("webgl", contextOptions);
// If we don't have a GL context, give up now
if (!gl) {
alert(
"Unable to initialize WebGL. Your browser or machine may not support it."
);
return {};
}
// Vertex shader program
const vsSource = `
attribute vec4 aVertexPosition;
attribute vec4 aVertexColor;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
varying lowp vec4 vColor;
void main(void) {
gl_Position = uProjectionMatrix * uModelViewMatrix * aVertexPosition;
vColor = aVertexColor;
}
`;
// Fragment shader program
const fsSource = `
varying lowp vec4 vColor;
void main(void) {
gl_FragColor = vColor;
}
`;
// Initialize a shader program; this is where all the lighting
// for the vertices and so forth is established.
const shaderProgram = initShaderProgram(gl, vsSource, fsSource);
// Collect all the info needed to use the shader program.
// Look up which attributes our shader program is using
// for aVertexPosition, aVevrtexColor and also
// look up uniform locations.
const programInfo = {
program: shaderProgram,
attribLocations: {
vertexPosition: gl.getAttribLocation(shaderProgram, "aVertexPosition"),
vertexColor: gl.getAttribLocation(shaderProgram, "aVertexColor"),
},
uniformLocations: {
projectionMatrix: gl.getUniformLocation(
shaderProgram,
"uProjectionMatrix"
),
modelViewMatrix: gl.getUniformLocation(shaderProgram, "uModelViewMatrix"),
},
};
// Here's where we call the routine that builds all the
// objects we'll be drawing.
const buffers = initBuffers(gl);
// Draw the scene
drawScene(gl, programInfo, buffers);
// Write to the fields
data["canvasdata11Webgl" + suffix] = sha1(canvas.toDataURL());
return data;
}
// ==============================================================
// Speech Synthesis Voices
async function populateVoiceList() {
// Replace long prefixes with short ones to reduce the size of the output.
const uriPrefixes = [
[/(?:urn:)?moz-tts:.*?:/, "#m:"],
[/com\.apple\.speech\.synthesis\.voice\./, "#as:"],
[/com\.apple\.voice\.compact./, "#ac:"],
[/com\.apple\.eloquence\./, "#ap:"],
// Populate with more prefixes as needed.
];
function trimVoiceURI(uri) {
for (const [re, replacement] of uriPrefixes) {
uri = uri.replace(re, replacement);
}
return uri;
}
async function processVoices(voices) {
voices = voices
.map(voice => ({
voiceURI: trimVoiceURI(voice.voiceURI),
default: voice.default,
localService: voice.localService,
}))
.sort((a, b) => a.voiceURI.localeCompare(b.voiceURI));
const [localServices, nonLocalServices] = voices.reduce(
(acc, voice) => {
if (voice.localService) {
acc[0].push(voice.voiceURI);
} else {
acc[1].push(voice.voiceURI);
}
return acc;
},
[[], []]
);
const defaultVoice = voices.find(voice => voice.default);
voices = voices.map(voice => voice.voiceURI).sort();
return {
voicesCount: voices.length,
voicesLocalCount: localServices.length,
voicesDefault: defaultVoice ? defaultVoice.voiceURI : null,
voicesSample: sample(voices, 5).join(","),
voicesSha1: await sha1(voices.join("|")),
voicesAllSsdeep: ssdeep.digest(voices.join("|")),
voicesLocalSsdeep: ssdeep.digest(localServices.join("|")),
voicesNonlocalSsdeep: ssdeep.digest(nonLocalServices.join("|")),
};
}
function fetchVoices() {
const promise = new Promise(resolve => {
speechSynthesis.addEventListener("voiceschanged", function () {
resolve(speechSynthesis.getVoices());
});
if (speechSynthesis.getVoices().length !== 0) {
resolve(speechSynthesis.getVoices());
}
});
const timeout = new Promise(resolve => {
setTimeout(() => {
resolve([]);
}, 5000);
});
return Promise.race([promise, timeout]);
}
return fetchVoices().then(processVoices);
}
async function populateMediaCapabilities() {
// Decoding: MP4 and WEBM are PDM dependant, while the other types are not, so for MP4 and WEBM we manually check for mimetypes.
// We also don't make an extra check for media-source as both file and media-source end up calling the same code path except for
// some prefs that block some mime types but we collect them.
// Encoding: It isn't dependant on hardware, so we just skip it, but collect media.encoder.webm.enabled pref.
const mimeTypes = [
// WEBM
"video/webm; codecs=vp9",
"video/webm; codecs=vp8",
"video/webm; codecs=av1",
// MP4
"video/mp4; codecs=vp9",
"video/mp4; codecs=vp8",
"video/mp4; codecs=hev1.1.0.L30.b0",
"video/mp4; codecs=avc1.42000A",
];
const videoConfig = {
type: "file",
video: {
width: 1280,
height: 720,
bitrate: 10000,
framerate: 30,
},
};
// Generates a list of h264 codecs, then checks if they are supported.
// Returns the highest supported level for each profile.
async function h264CodecsSupported() {
// Generate hex values for x.0, x.1, x.2 for x in [4, 6]
const levels = [...Array(3).keys()]
.map(i => [
((i + 4) * 10).toString(16),
((i + 4) * 10 + 1).toString(16),
((i + 4) * 10 + 2).toString(16),
])
.flat();
// Contains profiles without levels. They will be added
// later in the loop.
const profiles = ["avc1.4200", "avc1.4d00", "avc1.6e00", "avc1.7a00"];
const supportLevels = {};
for (const profile of profiles) {
for (const level of levels) {
const mimeType = `video/mp4; codecs=${profile}${level}`;
videoConfig.video.contentType = mimeType;
const capability =
await navigator.mediaCapabilities.decodingInfo(videoConfig);
if (capability.supported) {
supportLevels[profile] = level;
}
}
}
return supportLevels;
}
async function getCapabilities() {
const capabilities = {
unsupported: [],
notSmooth: [],
notPowerEfficient: [],
h264: await h264CodecsSupported(),
};
for (const mime of mimeTypes) {
videoConfig.video.contentType = mime;
const capability =
await navigator.mediaCapabilities.decodingInfo(videoConfig);
const shortMime = mime.split("=")[1];
if (!capability.supported) {
capabilities.unsupported.push(shortMime);
} else {
if (!capability.smooth) {
capabilities.notSmooth.push(shortMime);
}
if (!capability.powerEfficient) {
capabilities.notPowerEfficient.push(shortMime);
}
}
}
return capabilities;
}
const capabilities = await getCapabilities();
return {
mediaCapabilitiesUnsupported: JSON.stringify(capabilities.unsupported),
mediaCapabilitiesNotSmooth: JSON.stringify(capabilities.notSmooth),
mediaCapabilitiesNotEfficient: JSON.stringify(
capabilities.notPowerEfficient
),
mediaCapabilitiesH264: JSON.stringify(capabilities.h264),
};
}
async function populateAudioFingerprint() {
// Trimmed down version of https://github.com/fingerprintjs/fingerprintjs/blob/c463ca034747df80d95cc96a0a9c686d8cd001a5/src/sources/audio.ts
// At that time, fingerprintjs was licensed with MIT.
const hashFromIndex = 4500;
const hashToIndex = 5000;
const context = new window.OfflineAudioContext(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);
const fingerprintPromise = renderPromise.then(
buffer => getHash(buffer.getChannelData(0).subarray(hashFromIndex)),
error => {
if (error === "TIMEOUT" || error.name === "SUSPENDED") {
return "TIMEOUT";
}
throw error;
}
);
/**
* 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("TIMEMOUT"),
Math.min(
runningMaxAwaitTime,
startedRunningAt + runningSufficientTime - Date.now()
)
);
};
const tryRender = () => {
try {
context.startRendering();
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("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]);
}
// 10e13 is the maximum safe number we can use.
// 10e14 is over Number.MAX_SAFE_INTEGER, techinically it isn't but
// 35.x * 10e14 is over Number.MAX_SAFE_INTEGER. We are losing one digit
// of precision but it should hopefully be enough.
return hash * 10e13;
}
finishRendering();
return {
audioFingerprint: fingerprintPromise,
};
}
async function populateCSSQueries() {
return {
monochrome: matchMedia("(monochrome)").matches,
};
}
async function populateNavigatorProperties() {
return {
oscpu: navigator.oscpu,
pdfViewer: navigator.pdfViewerEnabled,
platform: navigator.platform,
};
}
async function populatePointerInfo() {
const capabilities = {
None: 0,
Coarse: 1 << 0,
Fine: 1 << 1,
};
const q = {
isCoarse: matchMedia("(pointer: coarse)").matches,
isFine: matchMedia("(pointer: fine)").matches,
isAnyCoarse: matchMedia("(any-pointer: coarse)").matches,
isAnyFine: matchMedia("(any-pointer: fine)").matches,
};
// Pointer media query matches for primary pointer. So, it can be
// only one of coarse/fine/none.
let pointerType;
if (q.isCoarse) {
pointerType = capabilities.Coarse;
} else {
pointerType = q.isFine ? capabilities.Fine : capabilities.None;
}
// Any-pointer media query matches for any pointer available. So, it
// can be both coarse and fine value, be one of them, or none.
const anyPointerType =
(q.isAnyCoarse && capabilities.Coarse) | (q.isAnyFine && capabilities.Fine);
return {
pointerType,
anyPointerType,
};
}
async function populateICEFoundations() {
// ICE Foundations timeout on CI, so we skip them for automation.
if (window.location.hash === "#automation") {
debug("Skipping ICE Foundations for automation");
return {};
}
function getFoundationsAndLatencies() {
const { promise, resolve, reject } = Promise.withResolvers();
// With no other peers, we wouldn't get prflx candidates.
// Relay type of candidates require a turn server.
// srflx candidates require a stun server.
// So, we'll only get host candidates.
const result = {
latencies: [],
foundations: [],
};
let lastTime;
function calculateLatency() {
const now = window.performance.now();
const latency = window.performance.now() - lastTime;
lastTime = now;
return latency;
}
const pc = new RTCPeerConnection();
pc.onicecandidate = e => {
const latency = calculateLatency();
if (e.candidate && e.candidate.candidate !== "") {
result.latencies.push(latency);
result.foundations.push(e.candidate.foundation);
}
};
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState !== "complete") {
return;
}
pc.close();
resolve(result);
};
pc.createOffer({ offerToReceiveAudio: 1 })
.then(desc => {
pc.setLocalDescription(desc);
lastTime = window.performance.now();
})
.catch(reject);
return promise;
}
// Run get candidates multiple times to see if foundation order changes
// and calculate standard deviation of latencies
const latencies = [];
const foundations = {};
for (let i = 0; i < 10; i++) {
const result = await getFoundationsAndLatencies();
latencies.push(result.latencies);
const hostFoundations = result.foundations.join("");
if (hostFoundations) {
foundations[hostFoundations] = (foundations[hostFoundations] ?? 0) + 1;
}
}
const sdLatencies = [];
for (let i = 0; i < (latencies?.[0]?.length ?? 0); i++) {
sdLatencies.push(standardDeviation(latencies.map(a => a[i])));
}
const sd =
sdLatencies.length > 1
? (sdLatencies.reduce((acc, val) => acc + val, 0) / sdLatencies.length) *
1000
: 0;
return {
iceSd: sd,
iceOrder: Object.keys(foundations).length,
};
}
async function populateSensorInfo() {
const { promise, resolve } = Promise.withResolvers();
const events = {
devicemotion: 0,
deviceorientation: 0,
deviceorientationabsolute: 0,
};
const results = {
frequency: { ...events },
decPlaces: { ...events },
};
const eventCounter = { ...events };
const eventDecPlaces = { ...events };
const eventStarts = { ...events };
const processEvent = eventName => e => {
eventCounter[eventName] += 1;
// Weird behaviour for devicemotion event, probably a bug.
// First devicemotion event has accelerationIncludingGravity but not acceleration.
const property =
e.acceleration?.x || e.alpha || e.accelerationIncludingGravity?.x;
if (!property) {
return;
}
const decPlaces = decimalPlaces(property);
eventDecPlaces[eventName] =
eventDecPlaces[eventName] > decPlaces
? eventDecPlaces[eventName]
: decPlaces;
};
const processResult = eventName => {
const elapsed = (window.performance.now() - eventStarts[eventName]) / 1000;
results.frequency[eventName] = Math.round(
eventCounter[eventName] / elapsed
);
results.decPlaces[eventName] = eventDecPlaces[eventName];
};
const listeners = [];
for (const eventName in events) {
eventStarts[eventName] = window.performance.now();
const listener = processEvent(eventName);
window.addEventListener(eventName, listener);
listeners.push([eventName, listener]);
setTimeout(() => processResult(eventName), 10 * 1000);
}
// A whole extra second to process results
setTimeout(() => {
for (const [eventName, listener] of listeners) {
window.removeEventListener(eventName, listener);
}
resolve({
motionDecimals: results.decPlaces.devicemotion,
orientationDecimals: results.decPlaces.deviceorientation,
orientationabsDecimals: results.decPlaces.deviceorientationabsolute,
motionFreq: results.frequency.devicemotion,
orientationFreq: results.frequency.deviceorientation,
orientationabsFreq: results.frequency.deviceorientationabsolute,
});
}, 11 * 1000);
return promise;
}
async function populateMathML() {
// We only collect width of the math elements.
// FPJS reports that height of elements fluctuates.
// We use getBoundingClientRect().width and not offsetWidth as math elements don't have a offsetWidth property.
const mathElements = [...document.querySelectorAll("math[id]")];
return mathElements.reduce((acc, el) => {
// We multiply by 10^15 to include the decimal part.
acc["mathml" + el.id] = el.getBoundingClientRect().width * 10 ** 15;
return acc;
}, {});
}
async function populateAudioDeviceProperties() {
const ctx = new AudioContext();
await ctx.resume();
// Give firefox some time to calculate latency
await new Promise(resolve => setTimeout(resolve, 2000));
// All the other properties (min/max decibels, smoothingTimeConstant,
// fftSize, frequencyBinCount, baseLatency) are hardcoded.
return {
audioFrames: ctx.outputLatency * ctx.sampleRate,
audioRate: ctx.sampleRate,
audioChannels: ctx.destination.maxChannelCount,
};
}
// A helper function to generate an array of asynchronous functions to populate
// canvases using both software and hardware rendering.
function getCanvasSources() {
const canvasSources = [populateWebGLCanvases];
// Create a source with both software and hardware rendering
return canvasSources
.map(source => {
const functions = [
async () => source({ forceSoftwareRendering: true }),
async () => source({ forceSoftwareRendering: false }),
];
// Using () => {} renames the function, so we rename them again.
// This is needed for error collection.
Object.defineProperty(functions[0], "name", {
value: source.name + "Software",
});
Object.defineProperty(functions[1], "name", {
value: source.name,
});
return functions;
})
.flat();
}
// =======================================================================
// Setup & Populating
/* Pick any local font, we just don't want to needlessly increase binary size */
const LocalFiraSans = new FontFace(
"LocalFiraSans",
"url('chrome://pocket/content/panels/fonts/FiraSans-Regular.woff') format('woff')"
);
if (document.readyState === "loading") {
window.addEventListener("load", startPopulating);
} else {
startPopulating();
}
async function startPopulating() {
const errors = [];
await LocalFiraSans.load()
.then(font => document.fonts.add(font))
.catch(async e => {
// Fail silently
errors.push(`LocalFiraSans: ${await stringifyError(e)}`);
});
// Data contains key: (Promise<any> | any) pairs. The keys are identifiers
// for the data and the values are either a promise that returns a value,
// or a value. Promises are awaited and values are resolved immediately.
const data = {};
const sources = [
...getCanvasSources(),
populateVoiceList,
populateMediaCapabilities,
populateAudioFingerprint,
populatePointerInfo,
populateICEFoundations,
populateSensorInfo,
populateMathML,
populateCSSQueries,
populateNavigatorProperties,
populateAudioDeviceProperties,
];
// Catches errors in promise-creating functions. E.g. if populateVoiceList
// throws an error before returning any of its `key: (Promise<any> | any)`
// pairs, we catch it here. This also catches non-async function errors
for (const source of sources) {
try {
Object.assign(data, await timeoutPromise(source(), 5 * 60 * 1000));
} catch (error) {
errors.push(`${source.name}: ${await stringifyError(error)}`);
}
}
debug("Awaiting", Object.keys(data).length, "data promises.");
await Promise.allSettled(Object.values(data));
debug("Sizes of extractions:");
const output = new Map();
for (const key in data) {
try {
let outputValue = await data[key];
output.set(key, outputValue);
debug(key, output.get(key) ? output.get(key).length : "null");
} catch (e) {
debug("Promise rejected for", key, "Error:", e);
errors.push(`${key}: ${await stringifyError(e)}`);
}
}
output.jsErrors = JSON.stringify(errors);
document.dispatchEvent(
new CustomEvent("UserCharacteristicsDataDone", {
bubbles: true,
detail: {
debug: debugMsgs,
output,
},
})
);
}