Source code
Revision control
Copy as Markdown
Other Tools
// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
/* 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 file,
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import * as constants from "resource://gre/modules/RFPTargetConstants.sys.mjs";
const kPrefResistFingerprinting = "privacy.resistFingerprinting";
const kPrefSpoofEnglish = "privacy.spoof_english";
const kTopicHttpOnModifyRequest = "http-on-modify-request";
const kPrefLetterboxing = "privacy.resistFingerprinting.letterboxing";
const kPrefLetterboxingDimensions =
"privacy.resistFingerprinting.letterboxing.dimensions";
const kPrefLetterboxingTesting =
"privacy.resistFingerprinting.letterboxing.testing";
const kTopicDOMWindowOpened = "domwindowopened";
var logConsole;
function log(msg) {
if (!logConsole) {
logConsole = console.createInstance({
prefix: "RFPHelper",
maxLogLevelPref: "privacy.resistFingerprinting.jsmloglevel",
});
}
logConsole.log(msg);
}
class _RFPHelper {
// ============================================================================
// Shared Setup
// ============================================================================
constructor() {
this._initialized = false;
}
init() {
if (this._initialized) {
return;
}
this._initialized = true;
// Add unconditional observers
Services.prefs.addObserver(kPrefResistFingerprinting, this);
Services.prefs.addObserver(kPrefLetterboxing, this);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"_letterboxingDimensions",
kPrefLetterboxingDimensions,
"",
null,
this._parseLetterboxingDimensions
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"_isLetterboxingTesting",
kPrefLetterboxingTesting,
false
);
// Add RFP and Letterboxing observers if prefs are enabled
this._handleResistFingerprintingChanged();
this._handleLetterboxingPrefChanged();
}
uninit() {
if (!this._initialized) {
return;
}
this._initialized = false;
// Remove unconditional observers
Services.prefs.removeObserver(kPrefResistFingerprinting, this);
Services.prefs.removeObserver(kPrefLetterboxing, this);
// Remove the RFP observers, swallowing exceptions if they weren't present
this._removeRFPObservers();
}
observe(subject, topic, data) {
switch (topic) {
case "nsPref:changed":
this._handlePrefChanged(data);
break;
case kTopicHttpOnModifyRequest:
this._handleHttpOnModifyRequest(subject, data);
break;
case kTopicDOMWindowOpened:
// We attach to the newly created window by adding tabsProgressListener
// and event listener on it. We listen for new tabs being added or
// the change of the content principal and apply margins accordingly.
this._handleDOMWindowOpened(subject);
break;
default:
break;
}
}
handleEvent(aMessage) {
switch (aMessage.type) {
case "TabOpen": {
let tab = aMessage.target;
this._addOrClearContentMargin(tab.linkedBrowser);
break;
}
default:
break;
}
}
_handlePrefChanged(data) {
switch (data) {
case kPrefResistFingerprinting:
this._handleResistFingerprintingChanged();
break;
case kPrefSpoofEnglish:
this._handleSpoofEnglishChanged();
break;
case kPrefLetterboxing:
this._handleLetterboxingPrefChanged();
break;
default:
break;
}
}
contentSizeUpdated(win) {
this._updateMarginsForTabsInWindow(win);
}
// ============================================================================
// Language Prompt
// ============================================================================
_addRFPObservers() {
Services.prefs.addObserver(kPrefSpoofEnglish, this);
if (this._shouldPromptForLanguagePref()) {
Services.obs.addObserver(this, kTopicHttpOnModifyRequest);
}
}
_removeRFPObservers() {
try {
Services.prefs.removeObserver(kPrefSpoofEnglish, this);
} catch (e) {
// do nothing
}
try {
Services.obs.removeObserver(this, kTopicHttpOnModifyRequest);
} catch (e) {
// do nothing
}
}
_handleResistFingerprintingChanged() {
if (Services.prefs.getBoolPref(kPrefResistFingerprinting)) {
this._addRFPObservers();
} else {
this._removeRFPObservers();
}
}
_handleSpoofEnglishChanged() {
switch (Services.prefs.getIntPref(kPrefSpoofEnglish)) {
case 0: // will prompt
// This should only happen when turning privacy.resistFingerprinting off.
// Works like disabling accept-language spoofing.
// fall through
case 1: // don't spoof
// We don't reset intl.accept_languages. Instead, setting
// privacy.spoof_english to 1 allows user to change preferred language
// settings through Preferences UI.
break;
case 2: // spoof
Services.prefs.setCharPref("intl.accept_languages", "en-US, en");
break;
default:
break;
}
}
_shouldPromptForLanguagePref() {
return (
Services.locale.appLocaleAsBCP47.substr(0, 2) !== "en" &&
Services.prefs.getIntPref(kPrefSpoofEnglish) === 0
);
}
_handleHttpOnModifyRequest(subject) {
// If we are loading an HTTP page from content, show the
// "request English language web pages?" prompt.
let httpChannel = subject.QueryInterface(Ci.nsIHttpChannel);
let notificationCallbacks = httpChannel.notificationCallbacks;
if (!notificationCallbacks) {
return;
}
let loadContext = notificationCallbacks.getInterface(Ci.nsILoadContext);
if (!loadContext || !loadContext.isContent) {
return;
}
if (!subject.URI.schemeIs("http") && !subject.URI.schemeIs("https")) {
return;
}
// The above QI did not throw, the scheme is http[s], and we know the
// load context is content, so we must have a true HTTP request from content.
// Stop the observer and display the prompt if another window has
// not already done so.
Services.obs.removeObserver(this, kTopicHttpOnModifyRequest);
if (!this._shouldPromptForLanguagePref()) {
return;
}
this._promptForLanguagePreference();
// The Accept-Language header for this request was set when the
// channel was created. Reset it to match the value that will be
// used for future requests.
let val = this._getCurrentAcceptLanguageValue(subject.URI);
if (val) {
httpChannel.setRequestHeader("Accept-Language", val, false);
}
}
_promptForLanguagePreference() {
// Display two buttons, both with string titles.
const l10n = new Localization(
["toolkit/global/resistFingerPrinting.ftl"],
true
);
const message = l10n.formatValueSync("privacy-spoof-english");
const flags = Services.prompt.STD_YES_NO_BUTTONS;
const response = Services.prompt.confirmEx(
null,
"",
message,
flags,
null,
null,
null,
null,
{ value: false }
);
// Update preferences to reflect their response and to prevent the prompt
// from being displayed again.
Services.prefs.setIntPref(kPrefSpoofEnglish, response == 0 ? 2 : 1);
}
_getCurrentAcceptLanguageValue(uri) {
let channel = Services.io.newChannelFromURI(
uri,
null, // aLoadingNode
Services.scriptSecurityManager.getSystemPrincipal(),
null, // aTriggeringPrincipal
Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
Ci.nsIContentPolicy.TYPE_OTHER
);
let httpChannel;
try {
httpChannel = channel.QueryInterface(Ci.nsIHttpChannel);
} catch (e) {
return null;
}
return httpChannel.getRequestHeader("Accept-Language");
}
// ==============================================================================
// Letterboxing
// ============================================================================
/**
* We use the TabsProgressListener to catch the change of the content
* principal. We would clear the margins around the content viewport if
* it is the system principal.
*/
onLocationChange(aBrowser) {
this._addOrClearContentMargin(aBrowser);
}
_handleLetterboxingPrefChanged() {
if (Services.prefs.getBoolPref(kPrefLetterboxing, false)) {
Services.ww.registerNotification(this);
this._registerLetterboxingActor();
this._attachAllWindows();
} else {
this._unregisterLetterboxingActor();
this._detachAllWindows();
Services.ww.unregisterNotification(this);
}
}
_registerLetterboxingActor() {
/*
* It turns out that this triggers a warning that we're registering a Desktop-only actor
* in toolkit (which will also run on mobile.) It just happens this actor only handles
* letterboxing, which isn't used on mobile, but we should resolve this.
*/
ChromeUtils.registerWindowActor("RFPHelper", {
parent: {
esModuleURI: "resource:///actors/RFPHelperParent.sys.mjs",
},
child: {
esModuleURI: "resource:///actors/RFPHelperChild.sys.mjs",
events: {
resize: {},
},
},
allFrames: true,
});
}
_unregisterLetterboxingActor() {
ChromeUtils.unregisterWindowActor("RFPHelper");
}
// The function to parse the dimension set from the pref value. The pref value
// should be formated as 'width1xheight1, width2xheight2, ...'. For
// example, '100x100, 200x200, 400x200 ...'.
_parseLetterboxingDimensions(aPrefValue) {
if (!aPrefValue || !aPrefValue.match(/^(?:\d+x\d+,\s*)*(?:\d+x\d+)$/)) {
if (aPrefValue) {
console.error(
`Invalid pref value for ${kPrefLetterboxingDimensions}: ${aPrefValue}`
);
}
return [];
}
return aPrefValue.split(",").map(item => {
let sizes = item.split("x").map(size => parseInt(size, 10));
return {
width: sizes[0],
height: sizes[1],
};
});
}
_addOrClearContentMargin(aBrowser) {
let tab = aBrowser.getTabBrowser().getTabForBrowser(aBrowser);
// We won't do anything for lazy browsers.
if (!aBrowser.isConnected) {
return;
}
// We should apply no margin around an empty tab or a tab with system
// principal.
if (tab.isEmpty || aBrowser.contentPrincipal.isSystemPrincipal) {
this._clearContentViewMargin(aBrowser);
} else {
this._roundContentView(aBrowser);
}
}
/**
* Given a width or height, returns the appropriate margin to apply.
*/
steppedRange(aDimension, aIsWidth = false) {
let stepping;
if (aDimension <= 50) {
return 0;
} else if (aDimension <= 500) {
stepping = 50;
} else if (aDimension <= 1600) {
stepping = aIsWidth ? 200 : 100;
} else {
stepping = 200;
}
return (aDimension % stepping) / 2;
}
/**
* The function will round the given browser by adding margins around the
* content viewport.
*/
async _roundContentView(aBrowser) {
let logId = Math.random();
log("_roundContentView[" + logId + "]");
let win = aBrowser.ownerGlobal;
let browserContainer = aBrowser
.getTabBrowser()
.getBrowserContainer(aBrowser);
let { contentWidth, contentHeight, containerWidth, containerHeight } =
await win.promiseDocumentFlushed(() => {
let contentWidth = aBrowser.clientWidth;
let contentHeight = aBrowser.clientHeight;
let containerWidth = browserContainer.clientWidth;
let containerHeight = browserContainer.clientHeight;
// If the findbar or devtools are out, we need to subtract their height (plus 1
// for the separator) from the container height, because we need to adjust our
// letterboxing to account for it; however it is not included in that dimension
// (but rather is subtracted from the content height.)
let findBar = win.gFindBarInitialized ? win.gFindBar : undefined;
let findBarOffset =
findBar && !findBar.hidden ? findBar.clientHeight + 1 : 0;
let devtools = browserContainer.getElementsByClassName(
"devtools-toolbox-bottom-iframe"
);
let devtoolsOffset = devtools.length ? devtools[0].clientHeight : 0;
return {
contentWidth,
contentHeight,
containerWidth,
containerHeight: containerHeight - findBarOffset - devtoolsOffset,
};
});
log(
"_roundContentView[" +
logId +
"] contentWidth=" +
contentWidth +
" contentHeight=" +
contentHeight +
" containerWidth=" +
containerWidth +
" containerHeight=" +
containerHeight +
" "
);
let calcMargins = (aWidth, aHeight) => {
let result;
log(
"_roundContentView[" +
logId +
"] calcMargins(" +
aWidth +
", " +
aHeight +
")"
);
// If the set is empty, we will round the content with the default
// stepping size.
if (!this._letterboxingDimensions.length) {
result = {
width: this.steppedRange(aWidth, true),
height: this.steppedRange(aHeight),
};
log(
"_roundContentView[" +
logId +
"] calcMargins(" +
aWidth +
", " +
aHeight +
") = " +
result.width +
" x " +
result.height
);
return result;
}
let matchingArea = aWidth * aHeight;
let minWaste = Number.MAX_SAFE_INTEGER;
let targetDimensions = undefined;
// Find the desired dimensions which waste the least content area.
for (let dim of this._letterboxingDimensions) {
// We don't need to consider the dimensions which cannot fit into the
// real content size.
if (dim.width > aWidth || dim.height > aHeight) {
continue;
}
let waste = matchingArea - dim.width * dim.height;
if (waste >= 0 && waste < minWaste) {
targetDimensions = dim;
minWaste = waste;
}
}
// If we cannot find any dimensions match to the real content window, this
// means the content area is smaller the smallest size in the set. In this
// case, we won't apply any margins.
if (!targetDimensions) {
result = {
width: 0,
height: 0,
};
} else {
result = {
width: (aWidth - targetDimensions.width) / 2,
height: (aHeight - targetDimensions.height) / 2,
};
}
log(
"_roundContentView[" +
logId +
"] calcMargins(" +
aWidth +
", " +
aHeight +
") = " +
result.width +
" x " +
result.height
);
return result;
};
// Calculating the margins around the browser element in order to round the
// content viewport. We will use a 200x100 stepping if the dimension set
// is not given.
let margins = calcMargins(containerWidth, containerHeight);
// If the size of the content is already quantized, we do nothing.
if (aBrowser.style.margin == `${margins.height}px ${margins.width}px`) {
log("_roundContentView[" + logId + "] is_rounded == true");
if (this._isLetterboxingTesting) {
log(
"_roundContentView[" +
logId +
"] is_rounded == true test:letterboxing:update-margin-finish"
);
Services.obs.notifyObservers(
null,
"test:letterboxing:update-margin-finish"
);
}
return;
}
win.requestAnimationFrame(() => {
log(
"_roundContentView[" +
logId +
"] setting margins to " +
margins.width +
" x " +
margins.height
);
// One cannot (easily) control the color of a margin unfortunately.
// An initial attempt to use a border instead of a margin resulted
// in offset event dispatching; so for now we use a colorless margin.
aBrowser.style.margin = `${margins.height}px ${margins.width}px`;
});
}
_clearContentViewMargin(aBrowser) {
aBrowser.ownerGlobal.requestAnimationFrame(() => {
aBrowser.style.margin = "";
});
}
_updateMarginsForTabsInWindow(aWindow) {
let tabBrowser = aWindow.gBrowser;
for (let tab of tabBrowser.tabs) {
let browser = tab.linkedBrowser;
this._addOrClearContentMargin(browser);
}
}
_attachWindow(aWindow) {
aWindow.gBrowser.addTabsProgressListener(this);
aWindow.addEventListener("TabOpen", this);
// Rounding the content viewport.
this._updateMarginsForTabsInWindow(aWindow);
}
_attachAllWindows() {
let windowList = Services.wm.getEnumerator("navigator:browser");
while (windowList.hasMoreElements()) {
let win = windowList.getNext();
if (win.closed || !win.gBrowser) {
continue;
}
this._attachWindow(win);
}
}
_detachWindow(aWindow) {
let tabBrowser = aWindow.gBrowser;
tabBrowser.removeTabsProgressListener(this);
aWindow.removeEventListener("TabOpen", this);
// Clear all margins and tooltip for all browsers.
for (let tab of tabBrowser.tabs) {
let browser = tab.linkedBrowser;
this._clearContentViewMargin(browser);
}
}
_detachAllWindows() {
let windowList = Services.wm.getEnumerator("navigator:browser");
while (windowList.hasMoreElements()) {
let win = windowList.getNext();
if (win.closed || !win.gBrowser) {
continue;
}
this._detachWindow(win);
}
}
_handleDOMWindowOpened(win) {
let self = this;
win.addEventListener(
"load",
() => {
// We attach to the new window when it has been loaded if the new loaded
// window is a browsing window.
if (
win.document.documentElement.getAttribute("windowtype") !==
"navigator:browser"
) {
return;
}
self._attachWindow(win);
},
{ once: true }
);
}
getTargets() {
return constants.Targets;
}
getTargetDefaults() {
const key =
Services.appinfo.OS === "Android" ? "ANDROID_DEFAULT" : "DESKTOP_DEFAULT";
return constants.DefaultTargets[key];
}
}
export let RFPHelper = new _RFPHelper();